功能:新增托盘二维码与用户分配

This commit is contained in:
焦龙言
2026-07-26 11:48:22 +08:00
parent f3eda856b0
commit 6851499be2
7 changed files with 1635 additions and 2 deletions
@@ -0,0 +1,133 @@
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.master_vo import (
DeletePalletModel,
PalletCreateModel,
PalletEditModel,
PalletModel,
PalletPageQueryModel,
)
from module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil
pallet_controller = APIRouterPro(
prefix='/master/pallet', tags=['基础资料-托盘管理'], dependencies=[PreAuthDependency()]
)
@pallet_controller.get(
'/list',
summary='获取托盘分页列表接口',
description='用于获取托盘分页列表',
response_model=PageResponseModel[PalletModel],
dependencies=[UserInterfaceAuthDependency('master:pallet:list')],
)
async def get_master_pallet_list(
request: Request,
pallet_page_query: Annotated[PalletPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
result = await PalletService.list_pallet(query_db, pallet_page_query, is_page=True)
return ResponseUtil.success(model_content=result)
@pallet_controller.get(
'/{pallet_id}/qrcode',
summary='补打托盘二维码接口',
description='使用托盘原有固定标识重新生成二维码',
response_class=Response,
responses={200: {'content': {'image/png': {}}}},
dependencies=[UserInterfaceAuthDependency('master:pallet:print')],
)
async def query_master_pallet_qrcode(
request: Request,
pallet_id: Annotated[int, Path(gt=0, description='托盘ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
png = await PalletService.get_pallet_qrcode(query_db, pallet_id)
return Response(content=png, media_type='image/png')
@pallet_controller.get(
'/{pallet_id}',
summary='获取托盘详情接口',
description='用于获取指定托盘的详细信息',
response_model=DataResponseModel[PalletModel],
dependencies=[UserInterfaceAuthDependency('master:pallet:query')],
)
async def query_detail_master_pallet(
request: Request,
pallet_id: Annotated[int, Path(gt=0, description='托盘ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
result = await PalletService.detail_pallet(query_db, pallet_id)
return ResponseUtil.success(data=result)
@pallet_controller.post(
'',
summary='新增托盘接口',
description='用于新增托盘及初始库存',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:pallet:add')],
)
@Log(title='托盘管理', business_type=BusinessType.INSERT)
async def add_master_pallet(
request: Request,
add_pallet: PalletCreateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await PalletService.add_pallet(query_db, add_pallet, current_user.user.user_name)
return ResponseUtil.success(msg=result.message)
@pallet_controller.put(
'',
summary='编辑托盘接口',
description='仅编辑托盘容量、资产状态和备注',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:pallet:edit')],
)
@Log(title='托盘管理', business_type=BusinessType.UPDATE)
async def edit_master_pallet(
request: Request,
edit_pallet: PalletEditModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await PalletService.edit_pallet(query_db, edit_pallet, current_user.user.user_name)
return ResponseUtil.success(msg=result.message)
@pallet_controller.delete(
'/{pallet_ids}',
summary='删除托盘接口',
description='用于删除尚未入库且没有流转事件的托盘',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:pallet:remove')],
)
@Log(title='托盘管理', business_type=BusinessType.DELETE)
async def delete_master_pallet(
request: Request,
pallet_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:
delete_pallet = DeletePalletModel(palletIds=[int(pallet_id) for pallet_id in pallet_ids.split(',')])
result = await PalletService.remove_pallet(query_db, delete_pallet)
return ResponseUtil.success(msg=result.message)
@@ -12,13 +12,61 @@ from common.enums import BusinessType
from common.router import APIRouterPro from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.entity.vo.master_vo import DeleteSiteModel, SiteModel, SitePageQueryModel from module_business.entity.vo.master_vo import (
BusinessUserOptionModel,
DeleteSiteModel,
SiteModel,
SitePageQueryModel,
UserAssignModel,
)
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
ASSIGN_LOG = Log
ASSIGN_CURRENT_USER = CurrentUserDependency
site_controller = APIRouterPro( site_controller = APIRouterPro(
prefix='/master/site', tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()] prefix='/master/site', tags=['基础资料-客户现场管理'], dependencies=[PreAuthDependency()]
) )
site_user_controller = APIRouterPro(
prefix='/master/site', tags=['基础资料-客户现场用户分配'], dependencies=[PreAuthDependency()]
)
@site_user_controller.get(
'/{site_id}/users',
summary='获取客户现场可分配用户接口',
description='获取启用且未删除的用户及客户现场分配状态',
response_model=DataResponseModel[list[BusinessUserOptionModel]],
dependencies=[UserInterfaceAuthDependency('master:site:assign')],
)
async def get_master_site_users(
request: Request,
site_id: Annotated[int, Path(gt=0, description='客户现场ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
users = await PalletService.list_site_users(query_db, site_id)
return ResponseUtil.success(data=users)
@site_user_controller.put(
'/{site_id}/users',
summary='分配客户现场用户接口',
description='完整替换客户现场的用户关联',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:site:assign')],
)
@ASSIGN_LOG(title='客户现场用户分配', business_type=BusinessType.GRANT)
async def assign_master_site_users(
request: Request,
site_id: Annotated[int, Path(gt=0, description='客户现场ID')],
assignment: UserAssignModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, ASSIGN_CURRENT_USER()],
) -> Response:
result = await PalletService.assign_site_users(query_db, site_id, assignment)
return ResponseUtil.success(msg=result.message)
@site_controller.get( @site_controller.get(
@@ -12,13 +12,61 @@ from common.enums import BusinessType
from common.router import APIRouterPro from common.router import APIRouterPro
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
from module_admin.entity.vo.user_vo import CurrentUserModel from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.entity.vo.master_vo import DeleteWarehouseModel, WarehouseModel, WarehousePageQueryModel from module_business.entity.vo.master_vo import (
BusinessUserOptionModel,
DeleteWarehouseModel,
UserAssignModel,
WarehouseModel,
WarehousePageQueryModel,
)
from module_business.service.master_service import MasterService from module_business.service.master_service import MasterService
from module_business.service.pallet_service import PalletService
from utils.response_util import ResponseUtil from utils.response_util import ResponseUtil
ASSIGN_LOG = Log
ASSIGN_CURRENT_USER = CurrentUserDependency
warehouse_controller = APIRouterPro( warehouse_controller = APIRouterPro(
prefix='/master/warehouse', tags=['基础资料-仓库管理'], dependencies=[PreAuthDependency()] prefix='/master/warehouse', tags=['基础资料-仓库管理'], dependencies=[PreAuthDependency()]
) )
warehouse_user_controller = APIRouterPro(
prefix='/master/warehouse', tags=['基础资料-仓库用户分配'], dependencies=[PreAuthDependency()]
)
@warehouse_user_controller.get(
'/{warehouse_id}/users',
summary='获取仓库可分配用户接口',
description='获取启用且未删除的用户及仓库分配状态',
response_model=DataResponseModel[list[BusinessUserOptionModel]],
dependencies=[UserInterfaceAuthDependency('master:warehouse:assign')],
)
async def get_master_warehouse_users(
request: Request,
warehouse_id: Annotated[int, Path(gt=0, description='仓库ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
) -> Response:
users = await PalletService.list_warehouse_users(query_db, warehouse_id)
return ResponseUtil.success(data=users)
@warehouse_user_controller.put(
'/{warehouse_id}/users',
summary='分配仓库用户接口',
description='完整替换仓库的用户关联',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('master:warehouse:assign')],
)
@ASSIGN_LOG(title='仓库用户分配', business_type=BusinessType.GRANT)
async def assign_master_warehouse_users(
request: Request,
warehouse_id: Annotated[int, Path(gt=0, description='仓库ID')],
assignment: UserAssignModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, ASSIGN_CURRENT_USER()],
) -> Response:
result = await PalletService.assign_warehouse_users(query_db, warehouse_id, assignment)
return ResponseUtil.success(msg=result.message)
@warehouse_controller.get( @warehouse_controller.get(
@@ -1,3 +1,4 @@
from datetime import datetime
from typing import Any, Literal from typing import Any, Literal
from pydantic import BaseModel from pydantic import BaseModel
@@ -6,9 +7,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser
from module_business.entity.do.master_do import ( from module_business.entity.do.master_do import (
BizCustomer, BizCustomer,
BizCustomerSite, BizCustomerSite,
BizPallet,
BizProductionLine, BizProductionLine,
BizStrain, BizStrain,
BizUserSite, BizUserSite,
@@ -29,6 +32,8 @@ from module_business.entity.vo.master_vo import (
CustomerPageQueryModel, CustomerPageQueryModel,
LineModel, LineModel,
LinePageQueryModel, LinePageQueryModel,
PalletEditModel,
PalletPageQueryModel,
SiteModel, SiteModel,
SitePageQueryModel, SitePageQueryModel,
StrainModel, StrainModel,
@@ -357,6 +362,128 @@ class MasterDao:
async def delete_warehouse(cls, db: AsyncSession, warehouse_id: int) -> None: async def delete_warehouse(cls, db: AsyncSession, warehouse_id: int) -> None:
await cls._delete(db, BizWarehouse, BizWarehouse.warehouse_id, warehouse_id) await cls._delete(db, BizWarehouse, BizWarehouse.warehouse_id, warehouse_id)
@classmethod
async def list_pallet(
cls,
db: AsyncSession,
query_object: PalletPageQueryModel,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.asset_status:
conditions.append(BizPallet.asset_status == query_object.asset_status)
statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
async def get_pallet(
cls,
db: AsyncSession,
pallet_id: int,
*,
for_update: bool = False,
) -> BizPallet | None:
statement = select(BizPallet).where(BizPallet.pallet_id == pallet_id)
if for_update:
statement = statement.with_for_update()
result = await db.execute(statement)
return result.scalar_one_or_none()
@classmethod
async def find_pallet_by_code(cls, db: AsyncSession, pallet_code: str) -> BizPallet | None:
return await cls._find(db, BizPallet, BizPallet.pallet_code, pallet_code)
@classmethod
async def add_pallet(cls, db: AsyncSession, pallet: BizPallet) -> BizPallet:
db.add(pallet)
await db.flush()
return pallet
@classmethod
async def update_pallet(
cls,
db: AsyncSession,
pallet: PalletEditModel,
update_by: str,
update_time: datetime,
) -> None:
await db.execute(
update(BizPallet)
.where(BizPallet.pallet_id == pallet.pallet_id)
.values(
capacity_packages=pallet.capacity_packages,
asset_status=pallet.asset_status,
remark=pallet.remark,
update_by=update_by,
update_time=update_time,
)
)
@classmethod
async def delete_pallet(cls, db: AsyncSession, pallet_id: int) -> None:
await cls._delete(db, BizPallet, BizPallet.pallet_id, pallet_id)
@classmethod
async def get_pallet_inventory(
cls,
db: AsyncSession,
pallet_id: int,
*,
for_update: bool = False,
) -> BizPalletInventory | None:
statement = select(BizPalletInventory).where(BizPalletInventory.pallet_id == pallet_id)
if for_update:
statement = statement.with_for_update()
result = await db.execute(statement)
return result.scalar_one_or_none()
@classmethod
async def add_pallet_inventory(
cls,
db: AsyncSession,
inventory: BizPalletInventory,
) -> BizPalletInventory:
db.add(inventory)
await db.flush()
return inventory
@classmethod
async def delete_pallet_inventory(cls, db: AsyncSession, pallet_id: int) -> None:
await cls._delete(db, BizPalletInventory, BizPalletInventory.pallet_id, pallet_id)
@classmethod
async def count_pallet_events(cls, db: AsyncSession, pallet_id: int) -> int:
result = await db.execute(
select(func.count('*')).select_from(BizPalletEvent).where(BizPalletEvent.pallet_id == pallet_id)
)
return result.scalar() or 0
@classmethod
async def list_assignable_users(cls, db: AsyncSession) -> list[Any]:
result = await db.execute(
select(SysUser.user_id, SysUser.user_name, SysUser.nick_name)
.where(SysUser.status == '0', SysUser.del_flag == '0')
.order_by(SysUser.user_id)
)
return list(result.all())
@classmethod
async def list_assignable_users_by_ids(cls, db: AsyncSession, user_ids: list[int]) -> list[Any]:
if not user_ids:
return []
result = await db.execute(
select(SysUser.user_id, SysUser.user_name, SysUser.nick_name)
.where(
SysUser.user_id.in_(user_ids),
SysUser.status == '0',
SysUser.del_flag == '0',
)
.order_by(SysUser.user_id)
)
return list(result.all())
@classmethod @classmethod
async def count_references( async def count_references(
cls, cls,
@@ -389,3 +389,19 @@ class UserAssignModel(BaseModel):
user_ids: list[PositiveId] = Field(min_length=1, description='用户ID列表') user_ids: list[PositiveId] = Field(min_length=1, description='用户ID列表')
_validate_unique_ids = field_validator('user_ids')(_ensure_unique_ids) _validate_unique_ids = field_validator('user_ids')(_ensure_unique_ids)
class BusinessUserOptionModel(BaseModel):
"""仓库或客户现场可分配用户的最小只读模型。"""
model_config = ConfigDict(
alias_generator=to_camel,
from_attributes=True,
populate_by_name=True,
extra='forbid',
)
user_id: PositiveId = Field(description='用户ID')
user_name: str = Field(min_length=1, max_length=30, pattern=r'\S', description='用户账号')
nick_name: str | None = Field(default=None, max_length=30, description='用户昵称')
assigned: bool = Field(description='是否已分配')
@@ -0,0 +1,282 @@
import secrets
from datetime import datetime
from io import BytesIO
from typing import Any, Literal
import qrcode
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from common.vo import CrudResponseModel, PageModel
from exceptions.exception import ServiceException
from module_business.dao.access_dao import AccessDao
from module_business.dao.master_dao import MasterDao
from module_business.domain.enums import PalletFlowState
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizPalletInventory
from module_business.entity.vo.master_vo import (
BusinessUserOptionModel,
DeletePalletModel,
PalletCreateModel,
PalletEditModel,
PalletModel,
PalletPageQueryModel,
UserAssignModel,
)
AssignmentResource = Literal['warehouse', 'site']
_ASSIGNMENT_LABELS: dict[AssignmentResource, str] = {
'warehouse': '仓库',
'site': '客户现场',
}
class PalletService:
"""托盘资产及业务用户分配服务层。"""
@staticmethod
def render_qr_png(qr_token: str) -> bytes:
"""将固定二维码标识渲染为 PNG;补打不会生成新标识。"""
buffer = BytesIO()
qrcode.make(qr_token).save(buffer, format='PNG')
return buffer.getvalue()
@classmethod
async def get_pallet_qrcode(cls, db: AsyncSession, pallet_id: int) -> bytes:
pallet = await MasterDao.get_pallet(db, pallet_id)
if pallet is None:
raise ServiceException(message='托盘不存在')
return cls.render_qr_png(pallet.qr_token)
@staticmethod
def _extract_pallet_ids(value: int | DeletePalletModel) -> list[int]:
pallet_ids = [value] if isinstance(value, int) else list(value.pallet_ids)
if not pallet_ids or any(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)
@classmethod
async def _can_remove(cls, db: AsyncSession, pallet_id: int) -> bool:
inventory = await MasterDao.get_pallet_inventory(db, pallet_id)
if inventory is None or inventory.flow_state != PalletFlowState.UNSTOCKED.value:
return False
return await MasterDao.count_pallet_events(db, pallet_id) == 0
@classmethod
async def _to_pallet_model(cls, db: AsyncSession, record: Any) -> PalletModel:
model = PalletModel.model_validate(record)
return model.model_copy(update={'can_remove': await cls._can_remove(db, model.pallet_id)})
@classmethod
async def _lock_pallet_inventory(cls, db: AsyncSession, pallet_id: int) -> tuple[BizPallet, BizPalletInventory]:
"""按库存、托盘顺序加锁,避免与流转事务形成反序死锁。"""
inventory = await MasterDao.get_pallet_inventory(db, pallet_id, for_update=True)
if inventory is None:
if await MasterDao.get_pallet(db, pallet_id) is None:
raise ServiceException(message='托盘不存在')
raise ServiceException(message='托盘库存不存在')
pallet = await MasterDao.get_pallet(db, pallet_id, for_update=True)
if pallet is None:
raise ServiceException(message='托盘不存在')
return pallet, inventory
@classmethod
async def list_pallet(
cls,
db: AsyncSession,
query_object: PalletPageQueryModel,
is_page: bool = True,
) -> PageModel | list[PalletModel]:
result = await MasterDao.list_pallet(db, query_object, is_page)
if isinstance(result, PageModel):
rows = [await cls._to_pallet_model(db, row) for row in result.rows]
return result.model_copy(update={'rows': rows})
return [await cls._to_pallet_model(db, row) for row in result]
@classmethod
async def detail_pallet(cls, db: AsyncSession, pallet_id: int) -> PalletModel:
pallet = await MasterDao.get_pallet(db, pallet_id)
if pallet is None:
raise ServiceException(message='托盘不存在')
return await cls._to_pallet_model(db, pallet)
@classmethod
async def add_pallet(
cls,
db: AsyncSession,
pallet: PalletCreateModel,
current_user_name: str,
) -> CrudResponseModel:
try:
if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None:
raise ServiceException(message='托盘编号已存在')
current_time = datetime.now()
pallet_record = BizPallet(
pallet_code=pallet.pallet_code,
qr_token=secrets.token_urlsafe(32),
capacity_packages=pallet.capacity_packages,
asset_status='0',
remark=pallet.remark,
create_by=current_user_name,
create_time=current_time,
update_by=current_user_name,
update_time=current_time,
)
pallet_record = await MasterDao.add_pallet(db, pallet_record)
inventory = BizPalletInventory(
pallet_id=pallet_record.pallet_id,
flow_state='UNSTOCKED',
remaining_packages=0,
version=0,
updated_at=current_time,
)
await MasterDao.add_pallet_inventory(db, inventory)
await db.commit()
return CrudResponseModel(is_success=True, message='新增成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='新增托盘失败,托盘编号或二维码标识已存在') from None
except Exception:
await db.rollback()
raise
@classmethod
async def edit_pallet(
cls,
db: AsyncSession,
pallet: PalletEditModel,
current_user_name: str,
) -> CrudResponseModel:
try:
_, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id)
if pallet.capacity_packages < inventory.remaining_packages:
raise ServiceException(message='托盘容量不能小于当前剩余包数')
await MasterDao.update_pallet(
db,
pallet,
update_by=current_user_name,
update_time=datetime.now(),
)
await db.commit()
return CrudResponseModel(is_success=True, message='更新成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='修改托盘失败,托盘状态已变化') from None
except Exception:
await db.rollback()
raise
@classmethod
async def remove_pallet(
cls,
db: AsyncSession,
value: int | DeletePalletModel,
) -> CrudResponseModel:
try:
pallet_ids = cls._extract_pallet_ids(value)
for pallet_id in pallet_ids:
_, inventory = await cls._lock_pallet_inventory(db, pallet_id)
if inventory.flow_state != PalletFlowState.UNSTOCKED.value:
raise ServiceException(message='托盘未处于未入库状态,不能删除')
if await MasterDao.count_pallet_events(db, pallet_id) > 0:
raise ServiceException(message='托盘已有流转事件,不能删除')
for pallet_id in pallet_ids:
await MasterDao.delete_pallet_inventory(db, pallet_id)
await MasterDao.delete_pallet(db, pallet_id)
await db.commit()
return CrudResponseModel(is_success=True, message='删除成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message='删除托盘失败,托盘状态或关联数据已变化') from None
except Exception:
await db.rollback()
raise
@classmethod
async def _list_resource_users(
cls,
db: AsyncSession,
resource: AssignmentResource,
resource_id: int,
) -> list[BusinessUserOptionModel]:
if await getattr(MasterDao, f'get_{resource}')(db, resource_id) is None:
raise ServiceException(message=f'{_ASSIGNMENT_LABELS[resource]}不存在')
users = await MasterDao.list_assignable_users(db)
result = []
association_method = getattr(AccessDao, f'list_user_{resource}_ids')
for user in users:
assigned_ids = await association_method(db, user.user_id)
result.append(
BusinessUserOptionModel(
user_id=user.user_id,
user_name=user.user_name,
nick_name=user.nick_name,
assigned=resource_id in assigned_ids,
)
)
return result
@classmethod
async def list_warehouse_users(
cls,
db: AsyncSession,
warehouse_id: int,
) -> list[BusinessUserOptionModel]:
return await cls._list_resource_users(db, 'warehouse', warehouse_id)
@classmethod
async def list_site_users(
cls,
db: AsyncSession,
site_id: int,
) -> list[BusinessUserOptionModel]:
return await cls._list_resource_users(db, 'site', site_id)
@classmethod
async def _assign_resource_users(
cls,
db: AsyncSession,
resource: AssignmentResource,
resource_id: int,
assignment: UserAssignModel,
) -> CrudResponseModel:
label = _ASSIGNMENT_LABELS[resource]
try:
if await getattr(MasterDao, f'get_{resource}')(db, resource_id) is None:
raise ServiceException(message=f'{label}不存在')
users = await MasterDao.list_assignable_users_by_ids(db, assignment.user_ids)
if {user.user_id for user in users} != set(assignment.user_ids):
raise ServiceException(message='存在不存在、停用或已删除的用户')
await getattr(AccessDao, f'replace_{resource}_users')(db, resource_id, assignment.user_ids)
await db.commit()
return CrudResponseModel(is_success=True, message='分配成功')
except IntegrityError:
await db.rollback()
raise ServiceException(message=f'分配{label}用户失败,关联数据无效') from None
except Exception:
await db.rollback()
raise
@classmethod
async def assign_warehouse_users(
cls,
db: AsyncSession,
warehouse_id: int,
assignment: UserAssignModel,
) -> CrudResponseModel:
return await cls._assign_resource_users(db, 'warehouse', warehouse_id, assignment)
@classmethod
async def assign_site_users(
cls,
db: AsyncSession,
site_id: int,
assignment: UserAssignModel,
) -> CrudResponseModel:
return await cls._assign_resource_users(db, 'site', site_id, assignment)
@@ -0,0 +1,979 @@
import inspect
import secrets
import unittest
from datetime import datetime
from types import SimpleNamespace
from typing import Any
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 ServiceException
from module_admin.entity.do.user_do import SysUser
from module_admin.service.login_service import LoginService
from module_business.controller import pallet_controller as pallet_controller_module
from module_business.controller import site_controller as site_controller_module
from module_business.controller import warehouse_controller as warehouse_controller_module
from module_business.dao.access_dao import AccessDao
from module_business.dao.master_dao import MasterDao
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizPalletInventory
from module_business.entity.vo.master_vo import (
BusinessUserOptionModel,
DeletePalletModel,
PalletCreateModel,
PalletEditModel,
PalletModel,
PalletPageQueryModel,
UserAssignModel,
)
from module_business.service import pallet_service as pallet_service_module
from module_business.service.pallet_service import PalletService
from utils.page_util import PageUtil
def compile_sql(statement: Any) -> str:
return ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split())
def integrity_error() -> IntegrityError:
return IntegrityError('statement', {}, RuntimeError('constraint'))
def make_pallet_record(pallet_id: int, capacity_packages: int = 20) -> SimpleNamespace:
return SimpleNamespace(
pallet_id=pallet_id,
pallet_code=f'P-{pallet_id:03d}',
qr_token=f'fixed-token-{pallet_id}',
capacity_packages=capacity_packages,
asset_status='0',
remark=None,
)
class TestPalletServiceContract(unittest.TestCase):
def test_service_is_available(self) -> None:
self.assertIsNotNone(PalletService)
class TestPalletVoContract(unittest.TestCase):
def test_business_user_option_only_exposes_safe_fields(self) -> None:
source = SimpleNamespace(
user_id=7,
user_name='operator',
nick_name=None,
assigned=True,
password='secret',
email='operator@example.com',
phonenumber='13800000000',
token='token',
)
option = BusinessUserOptionModel.model_validate(source)
self.assertEqual(
{'userId': 7, 'userName': 'operator', 'nickName': None, 'assigned': True},
option.model_dump(by_alias=True),
)
self.assertTrue(
{'password', 'email', 'phonenumber', 'phone', 'token'}.isdisjoint(BusinessUserOptionModel.model_fields)
)
def test_business_user_option_validates_identity_fields(self) -> None:
invalid_values = (
{'userId': 0, 'userName': 'operator', 'assigned': False},
{'userId': 1, 'userName': '', 'assigned': False},
{'userId': 1, 'userName': ' ', 'assigned': False},
)
for value in invalid_values:
with self.subTest(value=value), self.assertRaises(ValidationError):
BusinessUserOptionModel.model_validate(value)
def test_pallet_edit_rejects_code_and_qr_token(self) -> None:
base = {'palletId': 1, 'capacityPackages': 20, 'assetStatus': '0'}
for identity_field in ({'palletCode': 'P-001'}, {'qrToken': 'fixed-token'}):
with self.subTest(identity_field=identity_field), self.assertRaises(ValidationError):
PalletEditModel.model_validate({**base, **identity_field})
class TestPalletMasterDao(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
async def test_list_pallet_filters_and_orders_by_pallet_id(self) -> None:
query = PalletPageQueryModel(palletCode='P-00', assetStatus='0')
expected = object()
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=expected)) as paginate:
actual = await MasterDao.list_pallet(self.db, query, is_page=True)
self.assertIs(expected, actual)
statement = paginate.await_args.args[1]
sql = compile_sql(statement)
self.assertIn('FROM biz_pallet', sql)
self.assertIn("biz_pallet.pallet_code LIKE '%P-00%'", sql)
self.assertIn("biz_pallet.asset_status = '0'", sql)
self.assertIn('ORDER BY biz_pallet.pallet_id', sql)
async def test_add_pallet_and_inventory_only_flush_current_transaction(self) -> None:
pallet = BizPallet(
pallet_code='P-001',
qr_token='fixed-token',
capacity_packages=20,
asset_status='0',
)
inventory = BizPalletInventory(
pallet_id=3,
flow_state='UNSTOCKED',
remaining_packages=0,
version=0,
)
actual = await MasterDao.add_pallet(self.db, pallet)
await MasterDao.add_pallet_inventory(self.db, inventory)
self.assertIs(pallet, actual)
self.assertEqual([pallet, inventory], [call.args[0] for call in self.db.add.call_args_list])
self.assertEqual(2, self.db.flush.await_count)
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_update_pallet_sql_never_contains_identity_fields(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=18, assetStatus='1', remark='停用')
updated_at = datetime(2026, 7, 26, 10, 30)
await MasterDao.update_pallet(self.db, edit, update_by='admin', update_time=updated_at)
statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement)
self.assertIn('UPDATE biz_pallet', sql)
self.assertIn('capacity_packages=18', sql)
self.assertIn("asset_status='1'", sql)
self.assertIn("remark='停用'", sql)
self.assertIn("update_by='admin'", sql)
self.assertIn('WHERE biz_pallet.pallet_id = 3', sql)
self.assertNotIn('pallet_code=', sql)
self.assertNotIn('qr_token=', sql)
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_inventory_delete_and_event_count_target_one_pallet(self) -> None:
count_result = MagicMock()
count_result.scalar.return_value = 2
self.db.execute.side_effect = [MagicMock(), count_result]
await MasterDao.delete_pallet_inventory(self.db, pallet_id=9)
count = await MasterDao.count_pallet_events(self.db, pallet_id=9)
delete_statement = self.db.execute.await_args_list[0].args[0]
self.assertTrue(delete_statement.table.compare(BizPalletInventory.__table__))
self.assertIn('biz_pallet_inventory.pallet_id = 9', compile_sql(delete_statement))
count_statement = self.db.execute.await_args_list[1].args[0]
self.assertIn('FROM biz_pallet_event', compile_sql(count_statement))
self.assertIn('biz_pallet_event.pallet_id = 9', compile_sql(count_statement))
self.assertEqual(2, count)
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_inventory_write_read_uses_row_lock(self) -> None:
result = MagicMock()
inventory = SimpleNamespace(pallet_id=9)
result.scalar_one_or_none.return_value = inventory
self.db.execute.return_value = result
actual = await MasterDao.get_pallet_inventory(self.db, pallet_id=9, for_update=True)
self.assertIs(inventory, actual)
statement = self.db.execute.await_args.args[0]
self.assertIn('biz_pallet_inventory.pallet_id = 9', compile_sql(statement))
self.assertIn('FOR UPDATE', compile_sql(statement))
async def test_pallet_write_read_uses_row_lock(self) -> None:
result = MagicMock()
pallet = make_pallet_record(9)
result.scalar_one_or_none.return_value = pallet
self.db.execute.return_value = result
actual = await MasterDao.get_pallet(self.db, pallet_id=9, for_update=True)
self.assertIs(pallet, actual)
statement = self.db.execute.await_args.args[0]
self.assertIn('biz_pallet.pallet_id = 9', compile_sql(statement))
self.assertIn('FOR UPDATE', compile_sql(statement))
async def test_assignable_user_queries_filter_disabled_and_deleted_rows(self) -> None:
list_result = MagicMock()
list_result.all.return_value = ()
selected_result = MagicMock()
selected_result.all.return_value = ()
self.db.execute.side_effect = [list_result, selected_result]
self.assertEqual([], await MasterDao.list_assignable_users(self.db))
self.assertEqual([], await MasterDao.list_assignable_users_by_ids(self.db, [2, 5]))
for statement in (call.args[0] for call in self.db.execute.await_args_list):
sql = compile_sql(statement)
self.assertIn('FROM sys_user', sql)
self.assertIn("sys_user.status = '0'", sql)
self.assertIn("sys_user.del_flag = '0'", sql)
self.assertNotIn('password', sql)
self.assertEqual(
[SysUser.user_id, SysUser.user_name, SysUser.nick_name],
list(statement.selected_columns),
)
self.assertIn('ORDER BY sys_user.user_id', compile_sql(self.db.execute.await_args_list[0].args[0]))
selected_sql = compile_sql(self.db.execute.await_args_list[1].args[0])
self.assertIn('sys_user.user_id IN (2, 5)', selected_sql)
class TestPalletCreationService(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.create = PalletCreateModel(palletCode='P-001', capacityPackages=20, remark='测试托盘')
self.fixed_time = datetime(2026, 7, 26, 11, 30)
async def test_add_generates_server_token_and_initial_inventory_in_one_transaction(self) -> None:
actions: list[str] = []
captured: dict[str, Any] = {}
async def add_pallet(_db: AsyncSession, pallet: BizPallet) -> BizPallet:
actions.append('pallet')
captured['pallet'] = pallet
pallet.pallet_id = 17
return pallet
async def add_inventory(_db: AsyncSession, inventory: BizPalletInventory) -> BizPalletInventory:
actions.append('inventory')
captured['inventory'] = inventory
return inventory
async def commit() -> None:
actions.append('commit')
self.db.commit.side_effect = commit
with (
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=add_pallet)),
patch.object(MasterDao, 'add_pallet_inventory', new=AsyncMock(side_effect=add_inventory)),
patch.object(secrets, 'token_urlsafe', return_value='server-fixed-token') as token_urlsafe,
patch.object(pallet_service_module, 'datetime', create=True) as service_datetime,
):
service_datetime.now.return_value = self.fixed_time
result = await PalletService.add_pallet(self.db, self.create, current_user_name='admin')
token_urlsafe.assert_called_once_with(32)
pallet = captured['pallet']
self.assertEqual('P-001', pallet.pallet_code)
self.assertEqual('server-fixed-token', pallet.qr_token)
self.assertEqual(20, pallet.capacity_packages)
self.assertEqual('0', pallet.asset_status)
self.assertEqual('admin', pallet.create_by)
self.assertEqual(self.fixed_time, pallet.create_time)
self.assertEqual('admin', pallet.update_by)
self.assertEqual(self.fixed_time, pallet.update_time)
inventory = captured['inventory']
self.assertEqual(17, inventory.pallet_id)
self.assertEqual('UNSTOCKED', inventory.flow_state)
self.assertEqual(0, inventory.remaining_packages)
self.assertEqual(0, inventory.version)
self.assertEqual(['pallet', 'inventory', 'commit'], actions)
self.db.commit.assert_awaited_once()
self.db.rollback.assert_not_awaited()
self.assertIsInstance(result, CrudResponseModel)
self.assertTrue(result.is_success)
async def test_add_rolls_back_when_pallet_flush_fails(self) -> None:
failure = RuntimeError('flush failed')
add_inventory = AsyncMock()
with (
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=failure)),
patch.object(MasterDao, 'add_pallet_inventory', new=add_inventory),
self.assertRaisesRegex(RuntimeError, 'flush failed'),
):
await PalletService.add_pallet(self.db, self.create, current_user_name='admin')
add_inventory.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_add_rolls_back_when_commit_fails(self) -> None:
async def assign_id(_db: AsyncSession, pallet: BizPallet) -> BizPallet:
pallet.pallet_id = 17
return pallet
self.db.commit.side_effect = RuntimeError('commit failed')
with (
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=assign_id)),
patch.object(MasterDao, 'add_pallet_inventory', new=AsyncMock()),
self.assertRaisesRegex(RuntimeError, 'commit failed'),
):
await PalletService.add_pallet(self.db, self.create, current_user_name='admin')
self.db.rollback.assert_awaited_once()
async def test_add_converts_integrity_error_to_stable_service_exception(self) -> None:
with (
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'add_pallet', new=AsyncMock(side_effect=integrity_error())),
self.assertRaises(ServiceException) as raised,
):
await PalletService.add_pallet(self.db, self.create, current_user_name='admin')
self.assertEqual('新增托盘失败,托盘编号或二维码标识已存在', raised.exception.message)
self.db.rollback.assert_awaited_once()
self.db.commit.assert_not_awaited()
async def test_add_rejects_duplicate_code_before_writing(self) -> None:
add_pallet = AsyncMock()
with (
patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=object())),
patch.object(MasterDao, 'add_pallet', new=add_pallet),
self.assertRaises(ServiceException) as raised,
):
await PalletService.add_pallet(self.db, self.create, current_user_name='admin')
self.assertEqual('托盘编号已存在', raised.exception.message)
add_pallet.assert_not_awaited()
self.db.rollback.assert_awaited_once()
class TestPalletQrService(unittest.IsolatedAsyncioTestCase):
def test_render_qr_png_returns_real_stable_png(self) -> None:
first = PalletService.render_qr_png('fixed-original-token')
second = PalletService.render_qr_png('fixed-original-token')
self.assertEqual(b'\x89PNG\r\n\x1a\n', first[:8])
self.assertEqual(first, second)
async def test_get_qrcode_uses_original_persisted_token(self) -> None:
db = MagicMock(spec=AsyncSession)
with (
patch.object(
MasterDao,
'get_pallet',
new=AsyncMock(return_value=SimpleNamespace(qr_token='persisted-token')),
),
patch.object(PalletService, 'render_qr_png', return_value=b'png-bytes') as render,
patch.object(secrets, 'token_urlsafe') as token_urlsafe,
):
actual = await PalletService.get_pallet_qrcode(db, pallet_id=7)
self.assertEqual(b'png-bytes', actual)
render.assert_called_once_with('persisted-token')
token_urlsafe.assert_not_called()
async def test_get_qrcode_rejects_missing_pallet(self) -> None:
db = MagicMock(spec=AsyncSession)
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException) as raised,
):
await PalletService.get_pallet_qrcode(db, pallet_id=404)
self.assertEqual('托盘不存在', raised.exception.message)
class TestPalletQueryAndEditService(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
async def test_list_computes_can_remove_from_inventory_and_events(self) -> None:
page = PageModel[Any](
rows=[make_pallet_record(1), make_pallet_record(2)],
pageNum=1,
pageSize=10,
total=2,
hasNext=False,
)
query = PalletPageQueryModel()
with (
patch.object(MasterDao, 'list_pallet', new=AsyncMock(return_value=page)),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(
side_effect=[
SimpleNamespace(flow_state='UNSTOCKED'),
SimpleNamespace(flow_state='UNSTOCKED'),
]
),
),
patch.object(MasterDao, 'count_pallet_events', new=AsyncMock(side_effect=[0, 1])),
):
actual = await PalletService.list_pallet(self.db, query, is_page=True)
self.assertIsInstance(actual, PageModel)
self.assertTrue(all(isinstance(row, PalletModel) for row in actual.rows))
self.assertEqual([True, False], [row.can_remove for row in actual.rows])
async def test_detail_computes_false_when_inventory_is_not_unstocked(self) -> None:
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(return_value=SimpleNamespace(flow_state='EMPTY_IN_WAREHOUSE')),
),
patch.object(MasterDao, 'count_pallet_events', new=AsyncMock(return_value=0)),
):
actual = await PalletService.detail_pallet(self.db, pallet_id=3)
self.assertIsInstance(actual, PalletModel)
self.assertFalse(actual.can_remove)
async def test_detail_rejects_missing_pallet(self) -> None:
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException) as raised,
):
await PalletService.detail_pallet(self.db, pallet_id=404)
self.assertEqual('托盘不存在', raised.exception.message)
async def test_edit_updates_only_mutable_fields_with_server_audit(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=12, assetStatus='1', remark='停用')
updated_at = datetime(2026, 7, 26, 12, 30)
update_pallet = AsyncMock()
lock_actions: list[str] = []
async def get_pallet(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
lock_actions.append('pallet')
return make_pallet_record(3)
async def get_inventory(*_args: Any, **_kwargs: Any) -> SimpleNamespace:
lock_actions.append('inventory')
return SimpleNamespace(remaining_packages=12)
get_pallet_mock = AsyncMock(side_effect=get_pallet)
get_inventory_mock = AsyncMock(side_effect=get_inventory)
with (
patch.object(MasterDao, 'get_pallet', new=get_pallet_mock),
patch.object(MasterDao, 'get_pallet_inventory', new=get_inventory_mock),
patch.object(MasterDao, 'update_pallet', new=update_pallet),
patch.object(pallet_service_module, 'datetime') as service_datetime,
):
service_datetime.now.return_value = updated_at
result = await PalletService.edit_pallet(self.db, edit, current_user_name='admin')
update_pallet.assert_awaited_once_with(
self.db,
edit,
update_by='admin',
update_time=updated_at,
)
self.assertEqual(['inventory', 'pallet'], lock_actions)
get_pallet_mock.assert_awaited_once_with(self.db, 3, for_update=True)
get_inventory_mock.assert_awaited_once_with(self.db, 3, for_update=True)
self.db.commit.assert_awaited_once()
self.db.rollback.assert_not_awaited()
self.assertTrue(result.is_success)
async def test_edit_rejects_capacity_below_remaining_packages(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=11, assetStatus='0')
update_pallet = AsyncMock()
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(return_value=SimpleNamespace(remaining_packages=12)),
),
patch.object(MasterDao, 'update_pallet', new=update_pallet),
self.assertRaises(ServiceException) as raised,
):
await PalletService.edit_pallet(self.db, edit, current_user_name='admin')
self.assertEqual('托盘容量不能小于当前剩余包数', raised.exception.message)
update_pallet.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_edit_rejects_missing_inventory(self) -> None:
edit = PalletEditModel(palletId=3, capacityPackages=20, assetStatus='0')
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(3))),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=None)),
self.assertRaises(ServiceException) as raised,
):
await PalletService.edit_pallet(self.db, edit, current_user_name='admin')
self.assertEqual('托盘库存不存在', raised.exception.message)
self.db.rollback.assert_awaited_once()
class TestPalletRemovalService(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
async def test_batch_remove_preflights_all_then_deletes_inventory_before_pallet(self) -> None:
actions: list[str] = []
async def get_pallet(
_db: AsyncSession,
pallet_id: int,
*,
for_update: bool = False,
) -> SimpleNamespace:
actions.append(f'get-pallet-{pallet_id}-{for_update}')
return make_pallet_record(pallet_id)
async def get_inventory(
_db: AsyncSession,
pallet_id: int,
*,
for_update: bool = False,
) -> SimpleNamespace:
actions.append(f'get-inventory-{pallet_id}-{for_update}')
return SimpleNamespace(flow_state='UNSTOCKED')
async def count_events(_db: AsyncSession, pallet_id: int) -> int:
actions.append(f'count-events-{pallet_id}')
return 0
async def delete_inventory(_db: AsyncSession, pallet_id: int) -> None:
actions.append(f'delete-inventory-{pallet_id}')
async def delete_pallet(_db: AsyncSession, pallet_id: int) -> None:
actions.append(f'delete-pallet-{pallet_id}')
async def commit() -> None:
actions.append('commit')
self.db.commit.side_effect = commit
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(side_effect=get_pallet)),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(side_effect=get_inventory)),
patch.object(MasterDao, 'count_pallet_events', new=AsyncMock(side_effect=count_events)),
patch.object(MasterDao, 'delete_pallet_inventory', new=AsyncMock(side_effect=delete_inventory)),
patch.object(MasterDao, 'delete_pallet', new=AsyncMock(side_effect=delete_pallet)),
):
result = await PalletService.remove_pallet(self.db, DeletePalletModel(palletIds=[5, 4]))
self.assertEqual(
[
'get-inventory-4-True',
'get-pallet-4-True',
'count-events-4',
'get-inventory-5-True',
'get-pallet-5-True',
'count-events-5',
'delete-inventory-4',
'delete-pallet-4',
'delete-inventory-5',
'delete-pallet-5',
'commit',
],
actions,
)
self.db.commit.assert_awaited_once()
self.db.rollback.assert_not_awaited()
self.assertTrue(result.is_success)
async def test_remove_rejects_invalid_state_before_any_delete(self) -> None:
cases = (
('托盘不存在', None, None, 0),
('托盘库存不存在', make_pallet_record(4), None, 0),
(
'托盘未处于未入库状态,不能删除',
make_pallet_record(4),
SimpleNamespace(flow_state='EMPTY_IN_WAREHOUSE'),
0,
),
(
'托盘已有流转事件,不能删除',
make_pallet_record(4),
SimpleNamespace(flow_state='UNSTOCKED'),
1,
),
)
for message, pallet, inventory, event_count in cases:
with self.subTest(message=message):
self.db.reset_mock()
delete_inventory = AsyncMock()
delete_pallet = AsyncMock()
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=pallet)),
patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)),
patch.object(MasterDao, 'count_pallet_events', new=AsyncMock(return_value=event_count)),
patch.object(MasterDao, 'delete_pallet_inventory', new=delete_inventory),
patch.object(MasterDao, 'delete_pallet', new=delete_pallet),
self.assertRaises(ServiceException) as raised,
):
await PalletService.remove_pallet(self.db, 4)
self.assertEqual(message, raised.exception.message)
delete_inventory.assert_not_awaited()
delete_pallet.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_remove_integrity_error_rolls_back_with_stable_failure(self) -> None:
with (
patch.object(MasterDao, 'get_pallet', new=AsyncMock(return_value=make_pallet_record(4))),
patch.object(
MasterDao,
'get_pallet_inventory',
new=AsyncMock(return_value=SimpleNamespace(flow_state='UNSTOCKED')),
),
patch.object(MasterDao, 'count_pallet_events', new=AsyncMock(return_value=0)),
patch.object(MasterDao, 'delete_pallet_inventory', new=AsyncMock()),
patch.object(MasterDao, 'delete_pallet', new=AsyncMock(side_effect=integrity_error())),
self.assertRaises(ServiceException) as raised,
):
await PalletService.remove_pallet(self.db, 4)
self.assertEqual('删除托盘失败,托盘状态或关联数据已变化', raised.exception.message)
self.db.rollback.assert_awaited_once()
self.db.commit.assert_not_awaited()
class TestBusinessUserAssignmentService(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
self.users = [
SimpleNamespace(
user_id=2,
user_name='alice',
nick_name='艾丽丝',
password='secret-a',
email='alice@example.com',
phonenumber='13800000000',
),
SimpleNamespace(
user_id=5,
user_name='bob',
nick_name=None,
password='secret-b',
email='bob@example.com',
phonenumber='13900000000',
),
]
async def test_assignment_get_returns_only_safe_fields_with_assigned_marker(self) -> None:
cases = (
('warehouse', 7, [7], []),
('site', 9, [9], []),
)
for resource, resource_id, first_ids, second_ids in cases:
with self.subTest(resource=resource):
association_method = f'list_user_{resource}_ids'
with (
patch.object(
MasterDao,
f'get_{resource}',
new=AsyncMock(return_value=SimpleNamespace()),
),
patch.object(MasterDao, 'list_assignable_users', new=AsyncMock(return_value=self.users)),
patch.object(
AccessDao,
association_method,
new=AsyncMock(side_effect=[first_ids, second_ids]),
) as list_ids,
):
actual = await getattr(PalletService, f'list_{resource}_users')(self.db, resource_id)
self.assertEqual(
[
{'userId': 2, 'userName': 'alice', 'nickName': '艾丽丝', 'assigned': True},
{'userId': 5, 'userName': 'bob', 'nickName': None, 'assigned': False},
],
[option.model_dump(by_alias=True) for option in actual],
)
self.assertTrue(all(isinstance(option, BusinessUserOptionModel) for option in actual))
self.assertEqual(
[(self.db, 2), (self.db, 5)],
[call.args for call in list_ids.await_args_list],
)
async def test_assignment_get_rejects_missing_resource_before_user_query(self) -> None:
cases = (('warehouse', 7, '仓库不存在'), ('site', 9, '客户现场不存在'))
for resource, resource_id, message in cases:
with self.subTest(resource=resource):
list_users = AsyncMock()
with (
patch.object(MasterDao, f'get_{resource}', new=AsyncMock(return_value=None)),
patch.object(MasterDao, 'list_assignable_users', new=list_users),
self.assertRaises(ServiceException) as raised,
):
await getattr(PalletService, f'list_{resource}_users')(self.db, resource_id)
self.assertEqual(message, raised.exception.message)
list_users.assert_not_awaited()
async def test_assignment_put_validates_users_replaces_and_commits(self) -> None:
assignment = UserAssignModel(userIds=[2, 5])
for resource, resource_id in (('warehouse', 7), ('site', 9)):
with self.subTest(resource=resource):
self.db.reset_mock()
replace = AsyncMock()
with (
patch.object(
MasterDao,
f'get_{resource}',
new=AsyncMock(return_value=SimpleNamespace()),
),
patch.object(
MasterDao,
'list_assignable_users_by_ids',
new=AsyncMock(return_value=self.users),
) as list_users,
patch.object(AccessDao, f'replace_{resource}_users', new=replace),
):
result = await getattr(PalletService, f'assign_{resource}_users')(
self.db,
resource_id,
assignment,
)
list_users.assert_awaited_once_with(self.db, [2, 5])
replace.assert_awaited_once_with(self.db, resource_id, [2, 5])
self.db.commit.assert_awaited_once()
self.db.rollback.assert_not_awaited()
self.assertTrue(result.is_success)
async def test_assignment_put_rejects_missing_disabled_or_deleted_user(self) -> None:
replace = AsyncMock()
assignment = UserAssignModel(userIds=[2, 5])
with (
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace())),
patch.object(
MasterDao,
'list_assignable_users_by_ids',
new=AsyncMock(return_value=[self.users[0]]),
),
patch.object(AccessDao, 'replace_warehouse_users', new=replace),
self.assertRaises(ServiceException) as raised,
):
await PalletService.assign_warehouse_users(self.db, 7, assignment)
self.assertEqual('存在不存在、停用或已删除的用户', raised.exception.message)
replace.assert_not_awaited()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_replace_failure_rolls_back_and_preserves_original_exception(self) -> None:
assignment = UserAssignModel(userIds=[2, 5])
with (
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace())),
patch.object(
MasterDao,
'list_assignable_users_by_ids',
new=AsyncMock(return_value=self.users),
),
patch.object(
AccessDao,
'replace_site_users',
new=AsyncMock(side_effect=RuntimeError('replace failed')),
),
self.assertRaisesRegex(RuntimeError, 'replace failed'),
):
await PalletService.assign_site_users(self.db, 9, assignment)
self.db.commit.assert_not_awaited()
self.db.rollback.assert_awaited_once()
async def test_assignment_commit_failure_rolls_back(self) -> None:
assignment = UserAssignModel(userIds=[2, 5])
self.db.commit.side_effect = RuntimeError('commit failed')
with (
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace())),
patch.object(
MasterDao,
'list_assignable_users_by_ids',
new=AsyncMock(return_value=self.users),
),
patch.object(AccessDao, 'replace_warehouse_users', new=AsyncMock()),
self.assertRaisesRegex(RuntimeError, 'commit failed'),
):
await PalletService.assign_warehouse_users(self.db, 7, assignment)
self.db.rollback.assert_awaited_once()
async def test_assignment_integrity_error_has_stable_message_and_rolls_back(self) -> None:
assignment = UserAssignModel(userIds=[2, 5])
with (
patch.object(MasterDao, 'get_warehouse', new=AsyncMock(return_value=SimpleNamespace())),
patch.object(
MasterDao,
'list_assignable_users_by_ids',
new=AsyncMock(return_value=self.users),
),
patch.object(
AccessDao,
'replace_warehouse_users',
new=AsyncMock(side_effect=integrity_error()),
),
self.assertRaises(ServiceException) as raised,
):
await PalletService.assign_warehouse_users(self.db, 7, assignment)
self.assertEqual('分配仓库用户失败,关联数据无效', raised.exception.message)
self.db.rollback.assert_awaited_once()
class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase):
@staticmethod
def _routers() -> tuple[Any, ...]:
return (
pallet_controller_module.pallet_controller,
warehouse_controller_module.warehouse_user_controller,
site_controller_module.site_user_controller,
)
@classmethod
def _routes(cls) -> dict[tuple[str, str], APIRoute]:
routes = {}
for router in cls._routers():
for route in router.routes:
if isinstance(route, APIRoute):
routes[(route.path, next(iter(route.methods)))] = route
return routes
def test_openapi_has_all_pallet_and_assignment_operations(self) -> None:
app = FastAPI()
for router in self._routers():
app.include_router(router)
schema = app.openapi()
expected_operations = {
('/master/pallet/list', 'get'),
('/master/pallet/{pallet_id}', 'get'),
('/master/pallet', 'post'),
('/master/pallet', 'put'),
('/master/pallet/{pallet_ids}', 'delete'),
('/master/pallet/{pallet_id}/qrcode', 'get'),
('/master/warehouse/{warehouse_id}/users', 'get'),
('/master/warehouse/{warehouse_id}/users', 'put'),
('/master/site/{site_id}/users', 'get'),
('/master/site/{site_id}/users', 'put'),
}
for path, method in expected_operations:
with self.subTest(path=path, method=method):
self.assertIn(path, schema['paths'])
self.assertIn(method, schema['paths'][path])
self.assertIn(
'image/png',
schema['paths']['/master/pallet/{pallet_id}/qrcode']['get']['responses']['200']['content'],
)
def test_route_response_models_are_exact(self) -> None:
routes = self._routes()
self.assertEqual(
PageResponseModel[PalletModel],
routes[('/master/pallet/list', 'GET')].response_model,
)
self.assertEqual(
DataResponseModel[PalletModel],
routes[('/master/pallet/{pallet_id}', 'GET')].response_model,
)
for path, method in (
('/master/pallet', 'POST'),
('/master/pallet', 'PUT'),
('/master/pallet/{pallet_ids}', 'DELETE'),
('/master/warehouse/{warehouse_id}/users', 'PUT'),
('/master/site/{site_id}/users', 'PUT'),
):
self.assertEqual(ResponseBaseModel, routes[(path, method)].response_model)
user_response = DataResponseModel[list[BusinessUserOptionModel]]
self.assertEqual(
user_response,
routes[('/master/warehouse/{warehouse_id}/users', 'GET')].response_model,
)
self.assertEqual(
user_response,
routes[('/master/site/{site_id}/users', 'GET')].response_model,
)
self.assertIsNone(routes[('/master/pallet/{pallet_id}/qrcode', 'GET')].response_model)
def test_permissions_are_exact_and_all_routes_require_pre_auth(self) -> None:
expected_permissions = {
('/master/pallet/list', 'GET'): 'master:pallet:list',
('/master/pallet/{pallet_id}', 'GET'): 'master:pallet:query',
('/master/pallet', 'POST'): 'master:pallet:add',
('/master/pallet', 'PUT'): 'master:pallet:edit',
('/master/pallet/{pallet_ids}', 'DELETE'): 'master:pallet:remove',
('/master/pallet/{pallet_id}/qrcode', 'GET'): 'master:pallet:print',
('/master/warehouse/{warehouse_id}/users', 'GET'): 'master:warehouse:assign',
('/master/warehouse/{warehouse_id}/users', 'PUT'): 'master:warehouse:assign',
('/master/site/{site_id}/users', 'GET'): 'master:site:assign',
('/master/site/{site_id}/users', 'PUT'): 'master:site:assign',
}
actual_permissions = {}
for key, route in self._routes().items():
dependencies = [
dependency.dependency
for dependency in route.dependencies
if isinstance(dependency.dependency, CheckUserInterfaceAuth)
]
self.assertEqual(1, len(dependencies))
self.assertTrue(any(isinstance(dependency.dependency, PreAuth) for dependency in route.dependencies))
actual_permissions[key] = dependencies[0].perm
self.assertEqual(expected_permissions, actual_permissions)
self.assertEqual(8, len(set(actual_permissions.values())))
def test_write_routes_have_log_and_current_user(self) -> None:
write_routes = (
('/master/pallet', 'POST'),
('/master/pallet', 'PUT'),
('/master/pallet/{pallet_ids}', 'DELETE'),
('/master/warehouse/{warehouse_id}/users', 'PUT'),
('/master/site/{site_id}/users', 'PUT'),
)
routes = self._routes()
for key in write_routes:
route = routes[key]
self.assertTrue(hasattr(route.endpoint, '__wrapped__'))
self.assertTrue(
any(
dependency.name == 'current_user' and dependency.call == LoginService.get_current_user
for dependency in route.dependant.dependencies
)
)
def test_qrcode_route_is_declared_before_dynamic_detail(self) -> None:
route_paths = [route.path for route in pallet_controller_module.pallet_controller.routes]
self.assertLess(
route_paths.index('/master/pallet/{pallet_id}/qrcode'),
route_paths.index('/master/pallet/{pallet_id}'),
)
async def test_qrcode_endpoint_returns_real_image_png_body(self) -> None:
png = PalletService.render_qr_png('persisted-token')
db = MagicMock(spec=AsyncSession)
with patch.object(PalletService, 'get_pallet_qrcode', new=AsyncMock(return_value=png)) as get_qrcode:
response = await pallet_controller_module.query_master_pallet_qrcode(
request=MagicMock(),
pallet_id=7,
query_db=db,
)
get_qrcode.assert_awaited_once_with(db, 7)
self.assertEqual('image/png', response.media_type)
self.assertEqual(b'\x89PNG\r\n\x1a\n', response.body[:8])
def test_controller_sources_do_not_require_system_user_list_or_cache(self) -> None:
sources = '\n'.join(
inspect.getsource(module)
for module in (
pallet_controller_module,
warehouse_controller_module,
site_controller_module,
)
)
self.assertNotIn('system:user:list', sources)
self.assertNotIn('ApiCache', sources)
if __name__ == '__main__':
unittest.main()