功能:实现单托盘装填
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
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,
|
||||
FillingCreateModel,
|
||||
FillingModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
from module_business.service.filling_service import FillingService
|
||||
from utils.response_util import ResponseUtil
|
||||
|
||||
filling_controller = APIRouterPro(
|
||||
prefix='/business/filling',
|
||||
tags=['业务管理-托盘装填'],
|
||||
dependencies=[PreAuthDependency()],
|
||||
)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/list',
|
||||
summary='获取托盘装填记录分页列表接口',
|
||||
description='按当前用户仓库数据范围获取托盘装填记录分页列表',
|
||||
response_model=PageResponseModel[FillingModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
|
||||
)
|
||||
async def get_business_filling_list(
|
||||
request: Request,
|
||||
filling_page_query: Annotated[FillingPageQueryModel, Query()],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.get_filling_list_services(
|
||||
query_db,
|
||||
filling_page_query,
|
||||
current_user,
|
||||
is_page=True,
|
||||
)
|
||||
return ResponseUtil.success(model_content=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/strain-options',
|
||||
summary='获取装填菌种选项接口',
|
||||
description='获取全部启用菌种的业务选项',
|
||||
response_model=DataResponseModel[list[BusinessOptionModel]],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
|
||||
)
|
||||
async def get_business_filling_strain_options(
|
||||
request: Request,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.get_strain_options_services(query_db, current_user)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/scan/{qr_token}',
|
||||
summary='扫描待装填托盘接口',
|
||||
description='按托盘当前仓库数据范围判断空托盘是否可装填',
|
||||
response_model=DataResponseModel[PalletScanResponseModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
|
||||
)
|
||||
async def scan_business_filling_pallet(
|
||||
request: Request,
|
||||
qr_token: Annotated[
|
||||
str,
|
||||
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
|
||||
],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.scan_filling_pallet_services(
|
||||
query_db,
|
||||
qr_token,
|
||||
current_user,
|
||||
)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@filling_controller.get(
|
||||
'/{filling_id}',
|
||||
summary='获取托盘装填记录详情接口',
|
||||
description='按仓库数据范围获取指定托盘装填记录',
|
||||
response_model=DataResponseModel[FillingModel],
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
|
||||
)
|
||||
async def query_detail_business_filling(
|
||||
request: Request,
|
||||
filling_id: Annotated[int, Path(gt=0, description='装填记录ID')],
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.filling_detail_services(query_db, filling_id, current_user)
|
||||
return ResponseUtil.success(data=result)
|
||||
|
||||
|
||||
@filling_controller.post(
|
||||
'',
|
||||
summary='单托盘装填接口',
|
||||
description='在仓库内即时确认单个空托盘装填',
|
||||
response_model=ResponseBaseModel,
|
||||
dependencies=[UserInterfaceAuthDependency('business:filling:add')],
|
||||
)
|
||||
@Log(title='托盘装填', business_type=BusinessType.INSERT)
|
||||
async def add_business_filling(
|
||||
request: Request,
|
||||
add_filling: FillingCreateModel,
|
||||
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||
) -> Response:
|
||||
result = await FillingService.fill_pallet_services(query_db, add_filling, current_user)
|
||||
return ResponseUtil.success(msg=result.message)
|
||||
@@ -60,6 +60,15 @@ class OperationDao:
|
||||
scoped_warehouse_ids = sorted(set(warehouse_ids))
|
||||
return [BizInboundOrder.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
|
||||
|
||||
@staticmethod
|
||||
def _filling_scope_conditions(
|
||||
warehouse_ids: list[int] | None,
|
||||
) -> list[ColumnElement[bool]]:
|
||||
if warehouse_ids is None:
|
||||
return []
|
||||
scoped_warehouse_ids = sorted(set(warehouse_ids))
|
||||
return [BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
|
||||
|
||||
@staticmethod
|
||||
async def _paginate(
|
||||
db: AsyncSession,
|
||||
@@ -97,8 +106,9 @@ class OperationDao:
|
||||
model: type[Any],
|
||||
key_column: InstrumentedAttribute,
|
||||
idempotency_key: str,
|
||||
*extra_conditions: ColumnElement[bool],
|
||||
) -> Any | None:
|
||||
result = await db.execute(select(model).where(key_column == idempotency_key))
|
||||
result = await db.execute(select(model).where(key_column == idempotency_key, *extra_conditions))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
@@ -300,12 +310,14 @@ class OperationDao:
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
idempotency_key: str,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
) -> BizFillingRecord | None:
|
||||
return await cls._get_by_key(
|
||||
db,
|
||||
BizFillingRecord,
|
||||
BizFillingRecord.idempotency_key,
|
||||
idempotency_key,
|
||||
*cls._filling_scope_conditions(warehouse_ids),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@@ -437,8 +449,9 @@ class OperationDao:
|
||||
db: AsyncSession,
|
||||
query_object: FillingPageQueryModel,
|
||||
is_page: bool = True,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
) -> PageModel | list[dict[str, Any]]:
|
||||
conditions = []
|
||||
conditions = cls._filling_scope_conditions(warehouse_ids)
|
||||
if query_object.filling_no:
|
||||
conditions.append(BizFillingRecord.filling_no.like(f'%{query_object.filling_no}%'))
|
||||
if query_object.warehouse_id:
|
||||
@@ -470,12 +483,14 @@ class OperationDao:
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
filling_id: int,
|
||||
warehouse_ids: list[int] | None = None,
|
||||
) -> BizFillingRecord | None:
|
||||
return await cls._get_by_id(
|
||||
db,
|
||||
BizFillingRecord,
|
||||
BizFillingRecord.filling_id,
|
||||
filling_id,
|
||||
*cls._filling_scope_conditions(warehouse_ids),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,464 @@
|
||||
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 PalletEventType, PalletFlowState
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.entity.do.master_do import BizPallet, BizStrain
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory
|
||||
from module_business.entity.vo.master_vo import StrainPageQueryModel
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingCreateModel,
|
||||
FillingModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
|
||||
|
||||
class FillingService:
|
||||
"""仓库内单托盘即时装填服务层。"""
|
||||
|
||||
@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)
|
||||
|
||||
@staticmethod
|
||||
def _build_request_hash(page_object: FillingCreateModel) -> str:
|
||||
payload = json.dumps(
|
||||
{
|
||||
'qr_token': page_object.qr_token.strip(),
|
||||
'warehouse_id': page_object.warehouse_id,
|
||||
'strain_id': page_object.strain_id,
|
||||
'batch_no': page_object.batch_no.strip(),
|
||||
'package_spec': page_object.package_spec.strip(),
|
||||
'initial_packages': page_object.initial_packages,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@classmethod
|
||||
async def _warehouse_scope(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
) -> list[int] | None:
|
||||
if cls._is_admin(current_user):
|
||||
return None
|
||||
user_id, _ = cls._current_identity(current_user)
|
||||
return sorted(set(await AccessDao.list_user_warehouse_ids(query_db, user_id)))
|
||||
|
||||
@classmethod
|
||||
def _raise_filling_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='无权访问该托盘')
|
||||
|
||||
@staticmethod
|
||||
def _validate_empty_warehouse_pallet(
|
||||
pallet: BizPallet,
|
||||
inventory: BizPalletInventory,
|
||||
warehouse_id: int,
|
||||
qr_token: str,
|
||||
) -> None:
|
||||
if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != qr_token:
|
||||
raise ServiceException(message='托盘二维码与库存不一致')
|
||||
if pallet.asset_status != '0':
|
||||
raise ServiceException(message='托盘资产已停用,不允许装填')
|
||||
if (
|
||||
inventory.flow_state != PalletFlowState.EMPTY_IN_WAREHOUSE.value
|
||||
or inventory.warehouse_id != warehouse_id
|
||||
or inventory.site_id is not None
|
||||
or inventory.current_line_id is not None
|
||||
or inventory.active_filling_id is not None
|
||||
or inventory.remaining_packages != 0
|
||||
):
|
||||
raise ServiceException(message='托盘不符合仓库空托盘装填条件')
|
||||
if type(inventory.version) is not int or inventory.version < 0:
|
||||
raise ServiceException(message='托盘库存版本无效')
|
||||
|
||||
@staticmethod
|
||||
def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None:
|
||||
if type(initial_packages) is not int or initial_packages <= 0:
|
||||
raise ServiceException(message='装填包数必须为正整数')
|
||||
if initial_packages > pallet.capacity_packages:
|
||||
raise ServiceException(message='装填包数不能超过托盘容量')
|
||||
|
||||
@staticmethod
|
||||
def _validate_active_strain(strain: BizStrain | None) -> BizStrain:
|
||||
if strain is None:
|
||||
raise ServiceException(message='菌种不存在')
|
||||
if strain.status != '0':
|
||||
raise ServiceException(message='菌种已停用')
|
||||
return strain
|
||||
|
||||
@staticmethod
|
||||
def _is_exact_idempotent_result(
|
||||
filling: BizFillingRecord,
|
||||
page_object: FillingCreateModel,
|
||||
request_hash: str,
|
||||
) -> bool:
|
||||
return (
|
||||
filling.idempotency_key == page_object.idempotency_key
|
||||
and filling.warehouse_id == page_object.warehouse_id
|
||||
and filling.request_hash == request_hash
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _success(filling_id: int) -> CrudResponseModel:
|
||||
return CrudResponseModel(is_success=True, message='装填成功', result=filling_id)
|
||||
|
||||
@classmethod
|
||||
async def get_filling_list_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
query_object: FillingPageQueryModel,
|
||||
current_user: CurrentUserModel,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[FillingModel]:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
and query_object.warehouse_id is not None
|
||||
and query_object.warehouse_id not in warehouse_ids
|
||||
):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
result = await OperationDao.get_filling_list(
|
||||
query_db,
|
||||
query_object,
|
||||
is_page,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
rows = result.rows if isinstance(result, PageModel) else result
|
||||
models = [FillingModel.model_validate(record) for record in rows]
|
||||
if isinstance(result, PageModel):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
|
||||
@classmethod
|
||||
async def filling_detail_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
filling_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
) -> FillingModel:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
filling = await OperationDao.get_filling_by_id(
|
||||
query_db,
|
||||
filling_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if filling is None:
|
||||
cls._raise_filling_not_accessible(current_user)
|
||||
return FillingModel.model_validate(filling)
|
||||
|
||||
@classmethod
|
||||
async def get_strain_options_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
) -> list[BusinessOptionModel]:
|
||||
cls._current_identity(current_user)
|
||||
strains = await MasterDao.list_strain(
|
||||
query_db,
|
||||
StrainPageQueryModel(status='0'),
|
||||
False,
|
||||
)
|
||||
options = [
|
||||
BusinessOptionModel(
|
||||
id=cls._record_value(strain, 'strain_id'),
|
||||
code=cls._record_value(strain, 'strain_code'),
|
||||
name=cls._record_value(strain, 'strain_name'),
|
||||
)
|
||||
for strain in strains
|
||||
if cls._record_value(strain, 'status') == '0'
|
||||
]
|
||||
return sorted(options, key=lambda option: option.id)
|
||||
|
||||
@classmethod
|
||||
async def scan_filling_pallet_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
qr_token: str,
|
||||
current_user: CurrentUserModel,
|
||||
) -> PalletScanResponseModel:
|
||||
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.warehouse_id is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在仓库')
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
inventory.warehouse_id,
|
||||
)
|
||||
cls._validate_empty_warehouse_pallet(
|
||||
pallet,
|
||||
inventory,
|
||||
inventory.warehouse_id,
|
||||
qr_token,
|
||||
)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.EMPTY_IN_WAREHOUSE,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
site_id=None,
|
||||
current_line_id=None,
|
||||
active_filling_id=None,
|
||||
strain_id=None,
|
||||
strain_code=None,
|
||||
strain_name=None,
|
||||
batch_no=None,
|
||||
package_spec=None,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=0,
|
||||
allowed_actions=['filling'],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _recover_integrity_error(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: FillingCreateModel,
|
||||
request_hash: str,
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if warehouse_ids is not None and page_object.warehouse_id not in warehouse_ids:
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
recovered = await OperationDao.get_filling_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if recovered is None:
|
||||
if not cls._is_admin(current_user):
|
||||
raise PermissionException(data='', message='无权访问该装填记录')
|
||||
raise ServiceException(message='装填失败,数据冲突')
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
recovered.warehouse_id,
|
||||
)
|
||||
if not cls._is_exact_idempotent_result(recovered, page_object, request_hash):
|
||||
raise ServiceException(message='装填失败,幂等键已被其他请求使用')
|
||||
return cls._success(recovered.filling_id)
|
||||
finally:
|
||||
await query_db.rollback()
|
||||
|
||||
@classmethod
|
||||
async def fill_pallet_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: FillingCreateModel,
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
recovery_transaction_ended = False
|
||||
try:
|
||||
request_hash = cls._build_request_hash(page_object)
|
||||
user_id, user_name = cls._current_identity(current_user)
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if warehouse_ids is not None and page_object.warehouse_id not in warehouse_ids:
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
existing = await OperationDao.get_filling_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if existing is not None:
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
existing.warehouse_id,
|
||||
)
|
||||
if not cls._is_exact_idempotent_result(existing, page_object, request_hash):
|
||||
raise ServiceException(message='装填失败,幂等键已被其他请求使用')
|
||||
filling_id = existing.filling_id
|
||||
await query_db.rollback()
|
||||
return cls._success(filling_id)
|
||||
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
page_object.warehouse_id,
|
||||
)
|
||||
warehouse = await MasterDao.get_warehouse(query_db, page_object.warehouse_id)
|
||||
if warehouse is None:
|
||||
raise ServiceException(message='仓库不存在')
|
||||
if warehouse.status != '0':
|
||||
raise ServiceException(message='仓库已停用')
|
||||
|
||||
scanned_pallet = await InventoryDao.get_pallet_by_qr_token(
|
||||
query_db,
|
||||
page_object.qr_token,
|
||||
)
|
||||
if scanned_pallet is None:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
|
||||
inventory = await InventoryDao.get_inventory_for_update(
|
||||
query_db,
|
||||
scanned_pallet.pallet_id,
|
||||
)
|
||||
if inventory is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
|
||||
if inventory.warehouse_id is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在仓库')
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
inventory.warehouse_id,
|
||||
)
|
||||
locked_pallets = await InventoryDao.get_pallets_by_ids(
|
||||
query_db,
|
||||
[scanned_pallet.pallet_id],
|
||||
)
|
||||
if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != scanned_pallet.pallet_id:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
pallet = locked_pallets[0]
|
||||
|
||||
if inventory.active_filling_id is not None:
|
||||
concurrent = await OperationDao.get_active_filling_for_update(
|
||||
query_db,
|
||||
inventory.active_filling_id,
|
||||
)
|
||||
if concurrent is not None and concurrent.idempotency_key == page_object.idempotency_key:
|
||||
# 结束首次非锁定读取建立的 RR 快照,再按最新数据范围恢复并发成功结果。
|
||||
await query_db.rollback()
|
||||
recovery_transaction_ended = True
|
||||
return await cls._recover_integrity_error(
|
||||
query_db,
|
||||
page_object,
|
||||
request_hash,
|
||||
current_user,
|
||||
)
|
||||
|
||||
cls._validate_empty_warehouse_pallet(
|
||||
pallet,
|
||||
inventory,
|
||||
page_object.warehouse_id,
|
||||
page_object.qr_token,
|
||||
)
|
||||
cls._validate_package_count(page_object.initial_packages, pallet)
|
||||
PalletStateMachine.ensure_transition(
|
||||
PalletFlowState(inventory.flow_state),
|
||||
PalletFlowState.FILLED_IN_WAREHOUSE,
|
||||
)
|
||||
strain = cls._validate_active_strain(await MasterDao.get_strain(query_db, page_object.strain_id))
|
||||
|
||||
current_time = datetime.now()
|
||||
filling = BizFillingRecord(
|
||||
filling_no=f'FL-{secrets.token_hex(16)}',
|
||||
pallet_id=pallet.pallet_id,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
strain_id=strain.strain_id,
|
||||
strain_code_snapshot=strain.strain_code,
|
||||
strain_name_snapshot=strain.strain_name,
|
||||
batch_no=page_object.batch_no.strip(),
|
||||
package_spec=page_object.package_spec.strip(),
|
||||
initial_packages=page_object.initial_packages,
|
||||
active_slot='ACTIVE',
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
request_hash=request_hash,
|
||||
operator_id=user_id,
|
||||
operated_at=current_time,
|
||||
create_by=user_name,
|
||||
create_time=current_time,
|
||||
update_by=user_name,
|
||||
update_time=current_time,
|
||||
)
|
||||
filling = await OperationDao.add_filling_record(query_db, filling)
|
||||
filling_id = filling.filling_id
|
||||
|
||||
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.active_filling_id = filling_id
|
||||
inventory.remaining_packages = page_object.initial_packages
|
||||
inventory.flow_state = PalletFlowState.FILLED_IN_WAREHOUSE.value
|
||||
inventory.version += 1
|
||||
inventory.updated_at = current_time
|
||||
event = BizPalletEvent(
|
||||
pallet_id=inventory.pallet_id,
|
||||
event_type=PalletEventType.FILLING_CONFIRMED.value,
|
||||
source_type='FILLING',
|
||||
source_id=filling_id,
|
||||
before_state=before_state,
|
||||
after_state=PalletFlowState.FILLED_IN_WAREHOUSE.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=inventory.warehouse_id,
|
||||
after_site_id=inventory.site_id,
|
||||
after_line_id=inventory.current_line_id,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=page_object.initial_packages,
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
request_hash=request_hash,
|
||||
)
|
||||
await InventoryDao.add_event(query_db, inventory, event)
|
||||
await query_db.commit()
|
||||
return cls._success(filling_id)
|
||||
except IntegrityError:
|
||||
await query_db.rollback()
|
||||
return await cls._recover_integrity_error(
|
||||
query_db,
|
||||
page_object,
|
||||
request_hash,
|
||||
current_user,
|
||||
)
|
||||
except Exception:
|
||||
if not recovery_transaction_ended:
|
||||
await query_db.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,919 @@
|
||||
import hashlib
|
||||
import inspect
|
||||
import json
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, get_type_hints
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.aspect.interface_auth import CheckUserInterfaceAuth
|
||||
from common.aspect.pre_auth import PreAuth
|
||||
from common.vo import CrudResponseModel, DataResponseModel, PageModel, PageResponseModel, ResponseBaseModel
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
|
||||
from module_admin.service.login_service import LoginService
|
||||
from module_business.controller import filling_controller as filling_controller_module
|
||||
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 PalletEventType, PalletFlowState
|
||||
from module_business.entity.do.master_do import BizPallet, BizStrain
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingCreateModel,
|
||||
FillingModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.filling_service import FillingService
|
||||
|
||||
|
||||
def integrity_error() -> IntegrityError:
|
||||
return IntegrityError('statement', {}, RuntimeError('constraint'))
|
||||
|
||||
|
||||
def make_user(user_id: int = 7) -> CurrentUserModel:
|
||||
return CurrentUserModel(
|
||||
permissions=[],
|
||||
roles=[],
|
||||
user=UserInfoModel(userId=user_id, userName='admin' if user_id == 1 else f'user-{user_id}'),
|
||||
)
|
||||
|
||||
|
||||
def make_page(
|
||||
*,
|
||||
qr_token: str = 'qr-2',
|
||||
warehouse_id: int = 7,
|
||||
strain_id: int = 11,
|
||||
batch_no: str = 'B-20260726',
|
||||
package_spec: str = '20kg',
|
||||
initial_packages: int = 18,
|
||||
idempotency_key: str = 'fill-key',
|
||||
) -> FillingCreateModel:
|
||||
return FillingCreateModel(
|
||||
qrToken=qr_token,
|
||||
warehouseId=warehouse_id,
|
||||
strainId=strain_id,
|
||||
batchNo=batch_no,
|
||||
packageSpec=package_spec,
|
||||
initialPackages=initial_packages,
|
||||
idempotencyKey=idempotency_key,
|
||||
)
|
||||
|
||||
|
||||
def make_pallet(
|
||||
pallet_id: int = 2,
|
||||
*,
|
||||
qr_token: str = 'qr-2',
|
||||
capacity_packages: int = 20,
|
||||
asset_status: str = '0',
|
||||
) -> BizPallet:
|
||||
return BizPallet(
|
||||
pallet_id=pallet_id,
|
||||
pallet_code=f'P-{pallet_id:03d}',
|
||||
qr_token=qr_token,
|
||||
capacity_packages=capacity_packages,
|
||||
asset_status=asset_status,
|
||||
)
|
||||
|
||||
|
||||
def make_inventory(
|
||||
pallet_id: int = 2,
|
||||
*,
|
||||
flow_state: str = PalletFlowState.EMPTY_IN_WAREHOUSE.value,
|
||||
warehouse_id: int | None = 7,
|
||||
site_id: int | None = None,
|
||||
current_line_id: int | None = None,
|
||||
active_filling_id: int | None = None,
|
||||
remaining_packages: int = 0,
|
||||
last_event_id: int | None = 40,
|
||||
version: int = 3,
|
||||
) -> BizPalletInventory:
|
||||
return BizPalletInventory(
|
||||
pallet_id=pallet_id,
|
||||
flow_state=flow_state,
|
||||
warehouse_id=warehouse_id,
|
||||
site_id=site_id,
|
||||
current_line_id=current_line_id,
|
||||
active_filling_id=active_filling_id,
|
||||
remaining_packages=remaining_packages,
|
||||
last_event_id=last_event_id,
|
||||
version=version,
|
||||
updated_at=datetime(2026, 7, 25, 9, 0),
|
||||
)
|
||||
|
||||
|
||||
def make_strain(
|
||||
strain_id: int = 11,
|
||||
*,
|
||||
status: str = '0',
|
||||
code: str = 'S-011',
|
||||
name: str = '香菇 11',
|
||||
) -> BizStrain:
|
||||
return BizStrain(
|
||||
strain_id=strain_id,
|
||||
strain_code=code,
|
||||
strain_name=name,
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def make_filling(
|
||||
filling_id: int = 31,
|
||||
*,
|
||||
warehouse_id: int = 7,
|
||||
idempotency_key: str = 'fill-key',
|
||||
request_hash: str | None = None,
|
||||
) -> BizFillingRecord:
|
||||
page = make_page(warehouse_id=warehouse_id, idempotency_key=idempotency_key)
|
||||
return BizFillingRecord(
|
||||
filling_id=filling_id,
|
||||
filling_no=f'FL-{filling_id}',
|
||||
pallet_id=2,
|
||||
warehouse_id=warehouse_id,
|
||||
strain_id=11,
|
||||
strain_code_snapshot='S-011',
|
||||
strain_name_snapshot='香菇 11',
|
||||
batch_no=page.batch_no,
|
||||
package_spec=page.package_spec,
|
||||
initial_packages=page.initial_packages,
|
||||
active_slot='ACTIVE',
|
||||
idempotency_key=idempotency_key,
|
||||
request_hash=request_hash or FillingService._build_request_hash(page),
|
||||
operator_id=7,
|
||||
operated_at=datetime(2026, 7, 26, 10, 30),
|
||||
create_by='user-7',
|
||||
create_time=datetime(2026, 7, 26, 10, 30),
|
||||
update_by='user-7',
|
||||
update_time=datetime(2026, 7, 26, 10, 30),
|
||||
)
|
||||
|
||||
|
||||
class TestFillingPublicContract(unittest.TestCase):
|
||||
def test_service_and_controller_are_available(self) -> None:
|
||||
self.assertIsNotNone(FillingService)
|
||||
self.assertIsNotNone(filling_controller_module.filling_controller)
|
||||
|
||||
def test_fill_signature_and_types_are_exact(self) -> None:
|
||||
signature = inspect.signature(FillingService.fill_pallet_services)
|
||||
self.assertEqual(('query_db', 'page_object', 'current_user'), tuple(signature.parameters))
|
||||
hints = get_type_hints(FillingService.fill_pallet_services)
|
||||
self.assertIs(AsyncSession, hints['query_db'])
|
||||
self.assertIs(FillingCreateModel, hints['page_object'])
|
||||
self.assertIs(CurrentUserModel, hints['current_user'])
|
||||
self.assertIs(CrudResponseModel, hints['return'])
|
||||
|
||||
def test_request_hash_is_stable_normalized_sha256_and_excludes_key(self) -> None:
|
||||
page = make_page(batch_no=' B-20260726 ', package_spec=' 20kg ', idempotency_key='key-a')
|
||||
expected_payload = json.dumps(
|
||||
{
|
||||
'batch_no': 'B-20260726',
|
||||
'initial_packages': 18,
|
||||
'package_spec': '20kg',
|
||||
'qr_token': 'qr-2',
|
||||
'strain_id': 11,
|
||||
'warehouse_id': 7,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
expected = hashlib.sha256(expected_payload).hexdigest()
|
||||
|
||||
self.assertEqual(expected, FillingService._build_request_hash(page))
|
||||
self.assertEqual(
|
||||
expected, FillingService._build_request_hash(page.model_copy(update={'idempotency_key': 'key-b'}))
|
||||
)
|
||||
self.assertNotEqual(
|
||||
expected, FillingService._build_request_hash(page.model_copy(update={'initial_packages': 17}))
|
||||
)
|
||||
|
||||
def test_create_model_requires_strict_positive_integer_packages(self) -> None:
|
||||
for invalid in (True, 0, -1, 1.5):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
make_page(initial_packages=invalid)
|
||||
|
||||
|
||||
class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.db = MagicMock(spec=AsyncSession)
|
||||
self.user = make_user()
|
||||
|
||||
async def test_list_pushes_user_warehouse_scope_and_converts_rows(self) -> None:
|
||||
query = FillingPageQueryModel(pageNum=1, pageSize=10)
|
||||
row = make_filling()
|
||||
page = PageModel(rows=[row], pageNum=1, pageSize=10, total=1, hasNext=False)
|
||||
get_list = AsyncMock(return_value=page)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 7])),
|
||||
patch.object(OperationDao, 'get_filling_list', new=get_list),
|
||||
):
|
||||
result = await FillingService.get_filling_list_services(self.db, query, self.user)
|
||||
|
||||
get_list.assert_awaited_once_with(self.db, query, True, warehouse_ids=[7, 9])
|
||||
self.assertIsInstance(result.rows[0], FillingModel)
|
||||
self.assertEqual(1, result.total)
|
||||
|
||||
async def test_list_with_empty_scope_returns_empty_page_without_global_rows(self) -> None:
|
||||
query = FillingPageQueryModel()
|
||||
empty_page = PageModel(rows=[], pageNum=1, pageSize=10, total=0, hasNext=False)
|
||||
get_list = AsyncMock(return_value=empty_page)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])),
|
||||
patch.object(OperationDao, 'get_filling_list', new=get_list),
|
||||
):
|
||||
result = await FillingService.get_filling_list_services(self.db, query, self.user)
|
||||
|
||||
get_list.assert_awaited_once_with(self.db, query, True, warehouse_ids=[])
|
||||
self.assertEqual([], result.rows)
|
||||
self.assertEqual(0, result.total)
|
||||
|
||||
async def test_list_rejects_explicit_warehouse_outside_scope(self) -> None:
|
||||
query = FillingPageQueryModel(warehouseId=8)
|
||||
get_list = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(OperationDao, 'get_filling_list', new=get_list),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await FillingService.get_filling_list_services(self.db, query, self.user)
|
||||
get_list.assert_not_awaited()
|
||||
|
||||
async def test_admin_list_is_unscoped(self) -> None:
|
||||
query = FillingPageQueryModel()
|
||||
get_list = AsyncMock(return_value=[])
|
||||
scope = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=scope),
|
||||
patch.object(OperationDao, 'get_filling_list', new=get_list),
|
||||
):
|
||||
result = await FillingService.get_filling_list_services(self.db, query, make_user(1), is_page=False)
|
||||
scope.assert_not_awaited()
|
||||
get_list.assert_awaited_once_with(self.db, query, False, warehouse_ids=None)
|
||||
self.assertEqual([], result)
|
||||
|
||||
async def test_detail_is_scoped_before_lookup_and_hides_missing_for_normal_user(self) -> None:
|
||||
get_detail = AsyncMock(return_value=None)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(OperationDao, 'get_filling_by_id', new=get_detail),
|
||||
self.assertRaises(PermissionException) as raised,
|
||||
):
|
||||
await FillingService.filling_detail_services(self.db, 31, self.user)
|
||||
get_detail.assert_awaited_once_with(self.db, 31, warehouse_ids=[7])
|
||||
self.assertEqual('无权访问该装填记录', raised.exception.message)
|
||||
|
||||
async def test_admin_detail_keeps_precise_not_found_error(self) -> None:
|
||||
get_detail = AsyncMock(return_value=None)
|
||||
with (
|
||||
patch.object(OperationDao, 'get_filling_by_id', new=get_detail),
|
||||
self.assertRaises(ServiceException) as raised,
|
||||
):
|
||||
await FillingService.filling_detail_services(self.db, 31, make_user(1))
|
||||
get_detail.assert_awaited_once_with(self.db, 31, warehouse_ids=None)
|
||||
self.assertEqual('装填记录不存在', raised.exception.message)
|
||||
|
||||
async def test_strain_options_return_only_enabled_business_options(self) -> None:
|
||||
strains = [
|
||||
{'strainId': 3, 'strainCode': 'S-3', 'strainName': '菌种三', 'status': '0'},
|
||||
{'strainId': 2, 'strainCode': 'S-2', 'strainName': '菌种二', 'status': '1'},
|
||||
{'strainId': 1, 'strainCode': 'S-1', 'strainName': '菌种一', 'status': '0'},
|
||||
]
|
||||
list_strain = AsyncMock(return_value=strains)
|
||||
with patch.object(MasterDao, 'list_strain', new=list_strain):
|
||||
result = await FillingService.get_strain_options_services(self.db, self.user)
|
||||
|
||||
query = list_strain.await_args.args[1]
|
||||
self.assertEqual('0', query.status)
|
||||
self.assertFalse(list_strain.await_args.args[2])
|
||||
self.assertEqual([1, 3], [option.id for option in result])
|
||||
self.assertTrue(all(isinstance(option, BusinessOptionModel) for option in result))
|
||||
|
||||
async def test_scan_derives_scope_from_inventory_and_is_read_only(self) -> None:
|
||||
pallet = make_pallet()
|
||||
inventory = make_inventory()
|
||||
access = AsyncMock()
|
||||
pallet_lock = AsyncMock()
|
||||
inventory_lock = AsyncMock()
|
||||
with (
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet)),
|
||||
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock),
|
||||
):
|
||||
result = await FillingService.scan_filling_pallet_services(self.db, 'qr-2', self.user)
|
||||
|
||||
access.assert_awaited_once_with(self.db, self.user, 7)
|
||||
inventory_lock.assert_not_awaited()
|
||||
pallet_lock.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE, result.flow_state)
|
||||
self.assertEqual(['filling'], result.allowed_actions)
|
||||
self.assertEqual(20, result.capacity_packages)
|
||||
self.assertEqual(0, result.remaining_packages)
|
||||
|
||||
async def test_scan_checks_access_before_exposing_invalid_state(self) -> None:
|
||||
access = AsyncMock(side_effect=PermissionException(data='', message='无权访问该仓库'))
|
||||
with (
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
|
||||
patch.object(
|
||||
MasterDao,
|
||||
'get_pallet_inventory',
|
||||
new=AsyncMock(return_value=make_inventory(flow_state=PalletFlowState.FILLED_IN_WAREHOUSE.value)),
|
||||
),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await FillingService.scan_filling_pallet_services(self.db, 'qr-2', self.user)
|
||||
access.assert_awaited_once_with(self.db, self.user, 7)
|
||||
|
||||
async def test_scan_hides_nonexistent_pallet_from_normal_user_but_admin_gets_precise_error(self) -> None:
|
||||
for user, exception_type in ((self.user, PermissionException), (make_user(1), ServiceException)):
|
||||
with (
|
||||
self.subTest(admin=user.user.admin),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=None)),
|
||||
self.assertRaises(exception_type),
|
||||
):
|
||||
await FillingService.scan_filling_pallet_services(self.db, 'missing', user)
|
||||
|
||||
async def test_scan_admin_distinguishes_missing_inventory(self) -> None:
|
||||
with (
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=make_pallet())),
|
||||
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)),
|
||||
self.assertRaises(ServiceException) as raised,
|
||||
):
|
||||
await FillingService.scan_filling_pallet_services(self.db, 'qr-2', make_user(1))
|
||||
self.assertEqual('托盘库存不存在', raised.exception.message)
|
||||
|
||||
|
||||
class TestFillPalletService(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.db = MagicMock(spec=AsyncSession)
|
||||
self.user = make_user()
|
||||
self.page = make_page()
|
||||
self.pallet = make_pallet()
|
||||
self.inventory = make_inventory()
|
||||
self.strain = make_strain()
|
||||
self.warehouse = SimpleNamespace(warehouse_id=7, status='0')
|
||||
self.fixed_time = datetime(2026, 7, 26, 10, 30)
|
||||
|
||||
async def test_fill_locks_inventory_then_pallet_and_writes_one_atomic_event( # noqa: PLR0915
|
||||
self,
|
||||
) -> None:
|
||||
actions: list[str] = []
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
async def lock_inventory(*_args: Any) -> BizPalletInventory:
|
||||
actions.append('inventory-lock')
|
||||
return self.inventory
|
||||
|
||||
async def lock_pallets(*_args: Any) -> list[BizPallet]:
|
||||
actions.append('pallet-lock')
|
||||
return [self.pallet]
|
||||
|
||||
async def get_strain(*_args: Any) -> BizStrain:
|
||||
actions.append('strain-read')
|
||||
return self.strain
|
||||
|
||||
async def add_filling(_db: AsyncSession, filling: BizFillingRecord) -> BizFillingRecord:
|
||||
actions.append('filling-flush')
|
||||
filling.filling_id = 31
|
||||
captured['filling'] = filling
|
||||
return filling
|
||||
|
||||
async def add_event(
|
||||
_db: AsyncSession,
|
||||
inventory: BizPalletInventory,
|
||||
event: BizPalletEvent,
|
||||
) -> BizPalletEvent:
|
||||
actions.append('event-flush')
|
||||
event.event_id = 91
|
||||
event.event_seq = 6
|
||||
inventory.last_event_id = 91
|
||||
captured['event'] = event
|
||||
return event
|
||||
|
||||
async def commit() -> None:
|
||||
actions.append('commit')
|
||||
captured['filling'].__dict__.pop('filling_id', None)
|
||||
|
||||
self.db.commit.side_effect = commit
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(side_effect=lock_inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallets)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(side_effect=get_strain)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=add_filling)),
|
||||
patch.object(InventoryDao, 'add_event', new=AsyncMock(side_effect=add_event)),
|
||||
patch('module_business.service.filling_service.datetime') as mocked_datetime,
|
||||
):
|
||||
mocked_datetime.now.return_value = self.fixed_time
|
||||
result = await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual(
|
||||
['inventory-lock', 'pallet-lock', 'strain-read', 'filling-flush', 'event-flush', 'commit'],
|
||||
actions,
|
||||
)
|
||||
filling = captured['filling']
|
||||
self.assertTrue(filling.filling_no.startswith('FL-'))
|
||||
self.assertEqual(2, filling.pallet_id)
|
||||
self.assertEqual(7, filling.warehouse_id)
|
||||
self.assertEqual(11, filling.strain_id)
|
||||
self.assertEqual('S-011', filling.strain_code_snapshot)
|
||||
self.assertEqual('香菇 11', filling.strain_name_snapshot)
|
||||
self.assertEqual('B-20260726', filling.batch_no)
|
||||
self.assertEqual('20kg', filling.package_spec)
|
||||
self.assertEqual(18, filling.initial_packages)
|
||||
self.assertEqual('ACTIVE', filling.active_slot)
|
||||
self.assertEqual('fill-key', filling.idempotency_key)
|
||||
self.assertEqual(FillingService._build_request_hash(self.page), filling.request_hash)
|
||||
self.assertEqual(7, filling.operator_id)
|
||||
self.assertEqual(self.fixed_time, filling.operated_at)
|
||||
self.assertEqual('user-7', filling.create_by)
|
||||
self.assertEqual(self.fixed_time, filling.create_time)
|
||||
self.assertEqual('user-7', filling.update_by)
|
||||
self.assertEqual(self.fixed_time, filling.update_time)
|
||||
|
||||
self.assertEqual(31, self.inventory.active_filling_id)
|
||||
self.assertEqual(18, self.inventory.remaining_packages)
|
||||
self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE.value, self.inventory.flow_state)
|
||||
self.assertEqual(4, self.inventory.version)
|
||||
self.assertEqual(self.fixed_time, self.inventory.updated_at)
|
||||
self.assertEqual(91, self.inventory.last_event_id)
|
||||
|
||||
event = captured['event']
|
||||
self.assertEqual(PalletEventType.FILLING_CONFIRMED.value, event.event_type)
|
||||
self.assertEqual('FILLING', event.source_type)
|
||||
self.assertEqual(31, event.source_id)
|
||||
self.assertEqual(PalletFlowState.EMPTY_IN_WAREHOUSE.value, event.before_state)
|
||||
self.assertEqual(PalletFlowState.FILLED_IN_WAREHOUSE.value, event.after_state)
|
||||
self.assertEqual(7, event.before_warehouse_id)
|
||||
self.assertEqual(7, event.after_warehouse_id)
|
||||
self.assertIsNone(event.before_site_id)
|
||||
self.assertIsNone(event.after_site_id)
|
||||
self.assertIsNone(event.before_line_id)
|
||||
self.assertIsNone(event.after_line_id)
|
||||
self.assertEqual(0, event.before_remaining_packages)
|
||||
self.assertEqual(18, event.after_remaining_packages)
|
||||
self.assertEqual(7, event.operator_id)
|
||||
self.assertEqual(self.fixed_time, event.occurred_at)
|
||||
self.assertEqual('fill-key', event.idempotency_key)
|
||||
self.assertEqual(filling.request_hash, event.request_hash)
|
||||
self.db.commit.assert_awaited_once_with()
|
||||
self.db.rollback.assert_not_awaited()
|
||||
self.assertTrue(result.is_success)
|
||||
self.assertEqual('装填成功', result.message)
|
||||
self.assertEqual(31, result.result)
|
||||
|
||||
async def _assert_fill_rejected(
|
||||
self,
|
||||
*,
|
||||
page: FillingCreateModel | None = None,
|
||||
pallet: BizPallet | None = None,
|
||||
inventory: BizPalletInventory | None = None,
|
||||
strain: BizStrain | None = None,
|
||||
) -> None:
|
||||
add_filling = AsyncMock()
|
||||
add_event = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=pallet or self.pallet)),
|
||||
patch.object(
|
||||
InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=inventory or self.inventory)
|
||||
),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[pallet or self.pallet])),
|
||||
patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(return_value=None)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=strain or self.strain)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=add_filling),
|
||||
patch.object(InventoryDao, 'add_event', new=add_event),
|
||||
self.assertRaises(ServiceException),
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, page or self.page, self.user)
|
||||
add_filling.assert_not_awaited()
|
||||
add_event.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_fill_rejects_packages_over_persisted_pallet_capacity(self) -> None:
|
||||
await self._assert_fill_rejected(page=make_page(initial_packages=21))
|
||||
|
||||
async def test_fill_rejects_stopped_pallet_and_stopped_or_missing_strain(self) -> None:
|
||||
await self._assert_fill_rejected(pallet=make_pallet(asset_status='1'))
|
||||
self.db.reset_mock()
|
||||
await self._assert_fill_rejected(strain=make_strain(status='1'))
|
||||
self.db.reset_mock()
|
||||
add_filling = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=self.inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[self.pallet])),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=None)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=add_filling),
|
||||
self.assertRaises(ServiceException),
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
add_filling.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_fill_rechecks_persisted_qr_and_all_empty_inventory_invariants_after_locks(self) -> None:
|
||||
invalid_inventories = (
|
||||
make_inventory(flow_state=PalletFlowState.FILLED_IN_WAREHOUSE.value),
|
||||
make_inventory(warehouse_id=8),
|
||||
make_inventory(site_id=3),
|
||||
make_inventory(current_line_id=4),
|
||||
make_inventory(active_filling_id=30),
|
||||
make_inventory(remaining_packages=1),
|
||||
)
|
||||
for inventory in invalid_inventories:
|
||||
with self.subTest(values=vars(inventory)):
|
||||
self.db.reset_mock()
|
||||
await self._assert_fill_rejected(inventory=inventory)
|
||||
self.db.reset_mock()
|
||||
await self._assert_fill_rejected(pallet=make_pallet(qr_token='persisted-other'))
|
||||
|
||||
async def test_same_idempotency_key_and_hash_returns_existing_result_before_locks(self) -> None:
|
||||
existing = make_filling()
|
||||
inventory_lock = AsyncMock()
|
||||
access = AsyncMock()
|
||||
|
||||
async def rollback() -> None:
|
||||
existing.__dict__.pop('filling_id', None)
|
||||
|
||||
self.db.rollback.side_effect = rollback
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
patch.object(
|
||||
OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=existing)
|
||||
) as get_key,
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=inventory_lock),
|
||||
):
|
||||
result = await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
get_key.assert_awaited_once_with(self.db, 'fill-key', warehouse_ids=[7])
|
||||
access.assert_awaited_once_with(self.db, self.user, 7)
|
||||
inventory_lock.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
self.assertTrue(result.is_success)
|
||||
self.assertEqual(31, result.result)
|
||||
|
||||
async def test_same_idempotency_key_with_different_hash_is_rejected(self) -> None:
|
||||
existing = make_filling(request_hash='different-hash')
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=existing)),
|
||||
self.assertRaises(ServiceException) as raised,
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
self.assertIn('幂等键', raised.exception.message)
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_idempotency_retry_cannot_read_outside_current_scope(self) -> None:
|
||||
get_key = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=get_key),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
get_key.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_fill_hides_qr_whose_locked_inventory_is_outside_actual_scope(self) -> None:
|
||||
foreign_warehouse_id = 8
|
||||
inventory = make_inventory(
|
||||
warehouse_id=foreign_warehouse_id,
|
||||
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE.value,
|
||||
)
|
||||
pallet_lock = AsyncMock(return_value=[make_pallet(asset_status='1')])
|
||||
|
||||
async def assert_access(_db: AsyncSession, _user: CurrentUserModel, warehouse_id: int) -> None:
|
||||
if warehouse_id == foreign_warehouse_id:
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
access = AsyncMock(side_effect=assert_access)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=pallet_lock),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual([7, foreign_warehouse_id], [call.args[2] for call in access.await_args_list])
|
||||
pallet_lock.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_concurrent_same_key_recovers_after_waiting_for_inventory_lock(self) -> None:
|
||||
recovered = make_filling()
|
||||
inventory = make_inventory(
|
||||
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE.value,
|
||||
active_filling_id=31,
|
||||
remaining_packages=18,
|
||||
version=4,
|
||||
)
|
||||
actions: list[str] = []
|
||||
|
||||
async def lock_inventory(*_args: Any) -> BizPalletInventory:
|
||||
actions.append('inventory-lock')
|
||||
return inventory
|
||||
|
||||
async def lock_pallet(*_args: Any) -> list[BizPallet]:
|
||||
actions.append('pallet-lock')
|
||||
return [self.pallet]
|
||||
|
||||
async def lock_active(*_args: Any) -> BizFillingRecord:
|
||||
actions.append('filling-lock')
|
||||
return recovered
|
||||
|
||||
add_filling = AsyncMock()
|
||||
add_event = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_filling_by_idempotency_key',
|
||||
new=AsyncMock(side_effect=[None, recovered]),
|
||||
) as get_key,
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(side_effect=lock_inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(side_effect=lock_pallet)),
|
||||
patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(side_effect=lock_active)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=add_filling),
|
||||
patch.object(InventoryDao, 'add_event', new=add_event),
|
||||
):
|
||||
result = await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual(['inventory-lock', 'pallet-lock', 'filling-lock'], actions)
|
||||
self.assertEqual(2, get_key.await_count)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.assertEqual(31, result.result)
|
||||
add_filling.assert_not_awaited()
|
||||
add_event.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_concurrent_same_key_with_different_hash_is_stably_rejected(self) -> None:
|
||||
recovered = make_filling(request_hash='different-hash')
|
||||
inventory = make_inventory(
|
||||
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE.value,
|
||||
active_filling_id=31,
|
||||
remaining_packages=18,
|
||||
version=4,
|
||||
)
|
||||
add_filling = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_filling_by_idempotency_key',
|
||||
new=AsyncMock(side_effect=[None, recovered]),
|
||||
) as get_key,
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[self.pallet])),
|
||||
patch.object(OperationDao, 'get_active_filling_for_update', new=AsyncMock(return_value=recovered)),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=add_filling),
|
||||
self.assertRaises(ServiceException) as raised,
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual('装填失败,幂等键已被其他请求使用', raised.exception.message)
|
||||
self.assertEqual(2, get_key.await_count)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
add_filling.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_any_write_failure_rolls_back_without_commit(self) -> None:
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=AsyncMock(return_value=None)),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=self.inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[self.pallet])),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=self.strain)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=RuntimeError('flush failed'))),
|
||||
self.assertRaises(RuntimeError),
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
self.db.rollback.assert_awaited_once()
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_request_hash_failure_also_rolls_back(self) -> None:
|
||||
with (
|
||||
patch.object(FillingService, '_build_request_hash', side_effect=RuntimeError('hash failed')),
|
||||
self.assertRaises(RuntimeError),
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
self.db.rollback.assert_awaited_once()
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
async def test_integrity_error_recovers_only_exact_authorized_idempotent_result(self) -> None:
|
||||
recovered = make_filling()
|
||||
key_reads = AsyncMock(side_effect=[None, recovered])
|
||||
access = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
patch.object(OperationDao, 'get_filling_by_idempotency_key', new=key_reads),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=self.inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[self.pallet])),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=self.strain)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=integrity_error())),
|
||||
):
|
||||
result = await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
|
||||
self.assertEqual(2, key_reads.await_count)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
access.assert_any_await(self.db, self.user, 7)
|
||||
self.assertTrue(result.is_success)
|
||||
|
||||
async def test_integrity_error_without_scoped_idempotent_result_is_hidden_and_never_succeeds(self) -> None:
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=AsyncMock()),
|
||||
patch.object(
|
||||
OperationDao,
|
||||
'get_filling_by_idempotency_key',
|
||||
new=AsyncMock(side_effect=[None, None]),
|
||||
),
|
||||
patch.object(InventoryDao, 'get_pallet_by_qr_token', new=AsyncMock(return_value=self.pallet)),
|
||||
patch.object(InventoryDao, 'get_inventory_for_update', new=AsyncMock(return_value=self.inventory)),
|
||||
patch.object(InventoryDao, 'get_pallets_by_ids', new=AsyncMock(return_value=[self.pallet])),
|
||||
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=self.warehouse)),
|
||||
patch.object(MasterDao, 'get_strain', new=AsyncMock(return_value=self.strain)),
|
||||
patch.object(OperationDao, 'add_filling_record', new=AsyncMock(side_effect=integrity_error())),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await FillingService.fill_pallet_services(self.db, self.page, self.user)
|
||||
self.assertEqual(2, self.db.rollback.await_count)
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
class TestFillingOperationDaoScope(unittest.IsolatedAsyncioTestCase):
|
||||
@staticmethod
|
||||
def _compiled(statement: Any) -> str:
|
||||
return str(statement.compile(compile_kwargs={'literal_binds': True})).lower()
|
||||
|
||||
async def test_filling_list_pushes_warehouse_scope_into_sql(self) -> None:
|
||||
query = FillingPageQueryModel()
|
||||
paginate = AsyncMock(return_value=[])
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
with patch.object(OperationDao, '_paginate', new=paginate):
|
||||
await OperationDao.get_filling_list(db, query, False, warehouse_ids=[9, 7, 7])
|
||||
sql = self._compiled(paginate.await_args.args[1])
|
||||
self.assertIn('biz_filling_record.warehouse_id in (7, 9)', sql)
|
||||
|
||||
async def test_filling_list_empty_scope_compiles_to_always_false_sql(self) -> None:
|
||||
query = FillingPageQueryModel()
|
||||
paginate = AsyncMock(return_value=[])
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
with patch.object(OperationDao, '_paginate', new=paginate):
|
||||
await OperationDao.get_filling_list(db, query, False, warehouse_ids=[])
|
||||
sql = self._compiled(paginate.await_args.args[1])
|
||||
self.assertTrue('false' in sql or '0 = 1' in sql, sql)
|
||||
|
||||
async def test_filling_detail_and_idempotency_reads_push_scope_into_sql(self) -> None:
|
||||
for method, key in (
|
||||
(OperationDao.get_filling_by_id, 31),
|
||||
(OperationDao.get_filling_by_idempotency_key, 'fill-key'),
|
||||
):
|
||||
with self.subTest(method=method.__name__):
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = None
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute.return_value = result
|
||||
await method(db, key, warehouse_ids=[7])
|
||||
sql = self._compiled(db.execute.await_args.args[0])
|
||||
self.assertIn('biz_filling_record.warehouse_id in (7)', sql)
|
||||
|
||||
|
||||
class TestFillingController(unittest.TestCase):
|
||||
@staticmethod
|
||||
def _routes() -> dict[tuple[str, str], APIRoute]:
|
||||
return {
|
||||
(route.path, next(iter(route.methods))): route
|
||||
for route in filling_controller_module.filling_controller.routes
|
||||
if isinstance(route, APIRoute)
|
||||
}
|
||||
|
||||
def test_openapi_has_exact_five_operations_and_response_models(self) -> None:
|
||||
app = FastAPI()
|
||||
app.include_router(filling_controller_module.filling_controller)
|
||||
schema = app.openapi()
|
||||
expected = {
|
||||
('/business/filling/list', 'get'),
|
||||
('/business/filling/strain-options', 'get'),
|
||||
('/business/filling/scan/{qr_token}', 'get'),
|
||||
('/business/filling/{filling_id}', 'get'),
|
||||
('/business/filling', 'post'),
|
||||
}
|
||||
self.assertEqual(expected, {(path, method) for path, methods in schema['paths'].items() for method in methods})
|
||||
scan_parameters = schema['paths']['/business/filling/scan/{qr_token}']['get']['parameters']
|
||||
self.assertEqual({'qr_token'}, {parameter['name'] for parameter in scan_parameters})
|
||||
|
||||
routes = self._routes()
|
||||
self.assertEqual(PageResponseModel[FillingModel], routes[('/business/filling/list', 'GET')].response_model)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/filling/strain-options', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[PalletScanResponseModel],
|
||||
routes[('/business/filling/scan/{qr_token}', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[FillingModel],
|
||||
routes[('/business/filling/{filling_id}', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(ResponseBaseModel, routes[('/business/filling', 'POST')].response_model)
|
||||
|
||||
def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
|
||||
paths = [route.path for route in filling_controller_module.filling_controller.routes]
|
||||
detail_index = paths.index('/business/filling/{filling_id}')
|
||||
self.assertLess(paths.index('/business/filling/strain-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index)
|
||||
|
||||
def test_each_route_has_exact_permission_and_three_unique_values(self) -> None:
|
||||
expected = {
|
||||
('/business/filling/list', 'GET'): 'business:filling:list',
|
||||
('/business/filling/strain-options', 'GET'): 'business:filling:query',
|
||||
('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query',
|
||||
('/business/filling/{filling_id}', 'GET'): 'business:filling:query',
|
||||
('/business/filling', 'POST'): 'business:filling:add',
|
||||
}
|
||||
actual = {}
|
||||
for key, route in self._routes().items():
|
||||
permissions = [
|
||||
dependency.dependency
|
||||
for dependency in route.dependencies
|
||||
if isinstance(dependency.dependency, CheckUserInterfaceAuth)
|
||||
]
|
||||
self.assertEqual(1, len(permissions), key)
|
||||
self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies))
|
||||
actual[key] = permissions[0].perm
|
||||
self.assertEqual(expected, actual)
|
||||
self.assertEqual(
|
||||
{'business:filling:list', 'business:filling:query', 'business:filling:add'},
|
||||
set(actual.values()),
|
||||
)
|
||||
|
||||
def test_all_routes_inject_current_user_and_only_post_has_log(self) -> None:
|
||||
for key, route in self._routes().items():
|
||||
self.assertTrue(
|
||||
any(
|
||||
dependency.name == 'current_user' and dependency.call == LoginService.get_current_user
|
||||
for dependency in route.dependant.dependencies
|
||||
),
|
||||
key,
|
||||
)
|
||||
self.assertEqual(key == ('/business/filling', 'POST'), hasattr(route.endpoint, '__wrapped__'))
|
||||
|
||||
def test_controller_does_not_add_scan_export_or_draft_permissions(self) -> None:
|
||||
source = inspect.getsource(filling_controller_module)
|
||||
for forbidden in (
|
||||
'business:filling:scan',
|
||||
'business:filling:export',
|
||||
'business:filling:edit',
|
||||
'business:filling:remove',
|
||||
'business:filling:confirm',
|
||||
):
|
||||
self.assertNotIn(forbidden, source)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -127,7 +127,7 @@ class TestDaoPublicContract(unittest.TestCase):
|
||||
'get_active_filling_for_update': ('db', 'filling_id'),
|
||||
'get_inbound_by_confirm_idempotency_key': ('db', 'idempotency_key'),
|
||||
'get_shipment_by_confirm_idempotency_key': ('db', 'idempotency_key'),
|
||||
'get_filling_by_idempotency_key': ('db', 'idempotency_key'),
|
||||
'get_filling_by_idempotency_key': ('db', 'idempotency_key', 'warehouse_ids'),
|
||||
'get_usage_by_idempotency_key': ('db', 'idempotency_key'),
|
||||
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'),
|
||||
'get_return_by_confirm_idempotency_key': ('db', 'idempotency_key'),
|
||||
@@ -136,7 +136,7 @@ class TestDaoPublicContract(unittest.TestCase):
|
||||
'update_inbound': ('db', 'inbound'),
|
||||
'delete_inbound': ('db', 'inbound_id'),
|
||||
'replace_inbound_items': ('db', 'inbound_id', 'items'),
|
||||
'get_filling_by_id': ('db', 'filling_id'),
|
||||
'get_filling_by_id': ('db', 'filling_id', 'warehouse_ids'),
|
||||
'add_filling_record': ('db', 'filling'),
|
||||
'update_filling_record': ('db', 'filling'),
|
||||
'get_shipment_by_id': ('db', 'shipment_id'),
|
||||
@@ -164,7 +164,12 @@ class TestDaoPublicContract(unittest.TestCase):
|
||||
self.assertIs(True, inbound_list_signature.parameters['is_page'].default)
|
||||
self.assertIsNone(inbound_list_signature.parameters['warehouse_ids'].default)
|
||||
|
||||
for method_name in ('get_inbound_for_update', 'get_inbound_by_id'):
|
||||
for method_name in (
|
||||
'get_inbound_for_update',
|
||||
'get_inbound_by_id',
|
||||
'get_filling_by_idempotency_key',
|
||||
'get_filling_by_id',
|
||||
):
|
||||
with self.subTest(method=method_name):
|
||||
signature = inspect.signature(getattr(OperationDao, method_name))
|
||||
warehouse_ids = signature.parameters.get('warehouse_ids')
|
||||
@@ -172,12 +177,15 @@ class TestDaoPublicContract(unittest.TestCase):
|
||||
if warehouse_ids is not None:
|
||||
self.assertIsNone(warehouse_ids.default)
|
||||
|
||||
for method_name in (
|
||||
'get_filling_list',
|
||||
'get_shipment_list',
|
||||
'get_usage_list',
|
||||
'get_return_list',
|
||||
):
|
||||
filling_list_signature = inspect.signature(OperationDao.get_filling_list)
|
||||
self.assertEqual(
|
||||
('db', 'query_object', 'is_page', 'warehouse_ids'),
|
||||
tuple(filling_list_signature.parameters),
|
||||
)
|
||||
self.assertIs(True, filling_list_signature.parameters['is_page'].default)
|
||||
self.assertIsNone(filling_list_signature.parameters['warehouse_ids'].default)
|
||||
|
||||
for method_name in ('get_shipment_list', 'get_usage_list', 'get_return_list'):
|
||||
with self.subTest(method=method_name):
|
||||
signature = inspect.signature(getattr(OperationDao, method_name))
|
||||
self.assertEqual(('db', 'query_object', 'is_page'), tuple(signature.parameters))
|
||||
|
||||
Reference in New Issue
Block a user