功能:实现客户现场使用记录
This commit is contained in:
@@ -0,0 +1,124 @@
|
|||||||
|
"""客户现场使用接口。"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Path, Query, Request, Response
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from common.annotation.log_annotation import Log
|
||||||
|
from common.aspect.db_seesion import DBSessionDependency
|
||||||
|
from common.aspect.interface_auth import UserInterfaceAuthDependency
|
||||||
|
from common.aspect.pre_auth import CurrentUserDependency, PreAuthDependency
|
||||||
|
from common.enums import BusinessType
|
||||||
|
from common.router import APIRouterPro
|
||||||
|
from common.vo import DataResponseModel, PageResponseModel, ResponseBaseModel
|
||||||
|
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||||
|
from module_business.entity.vo.operation_vo import (
|
||||||
|
BusinessOptionModel,
|
||||||
|
PalletScanResponseModel,
|
||||||
|
UsageCreateModel,
|
||||||
|
UsageModel,
|
||||||
|
UsagePageQueryModel,
|
||||||
|
)
|
||||||
|
from module_business.service.usage_service import UsageService
|
||||||
|
from utils.response_util import ResponseUtil
|
||||||
|
|
||||||
|
usage_controller = APIRouterPro(
|
||||||
|
prefix='/business/usage',
|
||||||
|
tags=['业务管理-客户现场使用'],
|
||||||
|
dependencies=[PreAuthDependency()],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@usage_controller.get(
|
||||||
|
'/list',
|
||||||
|
summary='获取客户现场使用记录分页列表接口',
|
||||||
|
description='按当前用户客户现场数据范围获取使用记录',
|
||||||
|
response_model=PageResponseModel[UsageModel],
|
||||||
|
dependencies=[UserInterfaceAuthDependency('business:usage:list')],
|
||||||
|
)
|
||||||
|
async def get_business_usage_list(
|
||||||
|
request: Request,
|
||||||
|
usage_page_query: Annotated[UsagePageQueryModel, Query()],
|
||||||
|
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||||
|
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||||
|
) -> Response:
|
||||||
|
result = await UsageService.get_usage_list_services(
|
||||||
|
query_db,
|
||||||
|
usage_page_query,
|
||||||
|
current_user,
|
||||||
|
is_page=True,
|
||||||
|
)
|
||||||
|
return ResponseUtil.success(model_content=result)
|
||||||
|
|
||||||
|
|
||||||
|
@usage_controller.get(
|
||||||
|
'/line-options',
|
||||||
|
summary='获取客户现场启用产线选项接口',
|
||||||
|
description='仅返回当前用户有权访问的指定客户现场启用产线',
|
||||||
|
response_model=DataResponseModel[list[BusinessOptionModel]],
|
||||||
|
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
|
||||||
|
)
|
||||||
|
async def get_business_usage_line_options(
|
||||||
|
request: Request,
|
||||||
|
site_id: Annotated[int, Query(alias='siteId', gt=0, description='客户现场ID')],
|
||||||
|
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||||
|
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||||
|
) -> Response:
|
||||||
|
result = await UsageService.get_line_options_services(query_db, site_id, current_user)
|
||||||
|
return ResponseUtil.success(data=result)
|
||||||
|
|
||||||
|
|
||||||
|
@usage_controller.get(
|
||||||
|
'/scan/{qr_token}',
|
||||||
|
summary='扫描客户现场待使用托盘接口',
|
||||||
|
description='按托盘当前客户现场数据范围判断是否允许记录使用',
|
||||||
|
response_model=DataResponseModel[PalletScanResponseModel],
|
||||||
|
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
|
||||||
|
)
|
||||||
|
async def scan_business_usage_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 UsageService.scan_usage_pallet_services(query_db, qr_token, current_user)
|
||||||
|
return ResponseUtil.success(data=result)
|
||||||
|
|
||||||
|
|
||||||
|
@usage_controller.get(
|
||||||
|
'/{usage_id}',
|
||||||
|
summary='获取客户现场使用记录详情接口',
|
||||||
|
description='按客户现场数据范围获取指定使用记录',
|
||||||
|
response_model=DataResponseModel[UsageModel],
|
||||||
|
dependencies=[UserInterfaceAuthDependency('business:usage:query')],
|
||||||
|
)
|
||||||
|
async def query_detail_business_usage(
|
||||||
|
request: Request,
|
||||||
|
usage_id: Annotated[int, Path(gt=0, description='使用记录ID')],
|
||||||
|
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||||
|
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||||
|
) -> Response:
|
||||||
|
result = await UsageService.usage_detail_services(query_db, usage_id, current_user)
|
||||||
|
return ResponseUtil.success(data=result)
|
||||||
|
|
||||||
|
|
||||||
|
@usage_controller.post(
|
||||||
|
'',
|
||||||
|
summary='记录客户现场使用接口',
|
||||||
|
description='扫描托盘并原子记录客户现场使用包数',
|
||||||
|
response_model=ResponseBaseModel,
|
||||||
|
dependencies=[UserInterfaceAuthDependency('business:usage:add')],
|
||||||
|
)
|
||||||
|
@Log(title='客户现场使用', business_type=BusinessType.INSERT)
|
||||||
|
async def add_business_usage(
|
||||||
|
request: Request,
|
||||||
|
add_usage: UsageCreateModel,
|
||||||
|
query_db: Annotated[AsyncSession, DBSessionDependency()],
|
||||||
|
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
|
||||||
|
) -> Response:
|
||||||
|
result = await UsageService.record_usage_services(query_db, add_usage, current_user)
|
||||||
|
return ResponseUtil.success(msg=result.message)
|
||||||
@@ -337,8 +337,20 @@ class MasterDao:
|
|||||||
return await cls._paginate(db, statement, query_object, is_page)
|
return await cls._paginate(db, statement, query_object, is_page)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def get_line(cls, db: AsyncSession, line_id: int) -> BizProductionLine | None:
|
async def get_line(
|
||||||
return await cls._get(db, BizProductionLine, BizProductionLine.line_id, line_id)
|
cls,
|
||||||
|
db: AsyncSession,
|
||||||
|
line_id: int,
|
||||||
|
*,
|
||||||
|
for_update: bool = False,
|
||||||
|
) -> BizProductionLine | None:
|
||||||
|
return await cls._get(
|
||||||
|
db,
|
||||||
|
BizProductionLine,
|
||||||
|
BizProductionLine.line_id,
|
||||||
|
line_id,
|
||||||
|
for_update=for_update,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def find_line_by_code(
|
async def find_line_by_code(
|
||||||
|
|||||||
@@ -69,6 +69,15 @@ class OperationDao:
|
|||||||
scoped_warehouse_ids = sorted(set(warehouse_ids))
|
scoped_warehouse_ids = sorted(set(warehouse_ids))
|
||||||
return [BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
|
return [BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _usage_scope_conditions(
|
||||||
|
site_ids: list[int] | None,
|
||||||
|
) -> list[ColumnElement[bool]]:
|
||||||
|
if site_ids is None:
|
||||||
|
return []
|
||||||
|
scoped_site_ids = sorted(set(site_ids))
|
||||||
|
return [BizUsageRecord.site_id.in_(scoped_site_ids) if scoped_site_ids else false()]
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _shipment_scope_conditions(
|
def _shipment_scope_conditions(
|
||||||
warehouse_ids: list[int] | None,
|
warehouse_ids: list[int] | None,
|
||||||
@@ -363,12 +372,14 @@ class OperationDao:
|
|||||||
cls,
|
cls,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
idempotency_key: str,
|
idempotency_key: str,
|
||||||
|
site_ids: list[int] | None = None,
|
||||||
) -> BizUsageRecord | None:
|
) -> BizUsageRecord | None:
|
||||||
return await cls._get_by_key(
|
return await cls._get_by_key(
|
||||||
db,
|
db,
|
||||||
BizUsageRecord,
|
BizUsageRecord,
|
||||||
BizUsageRecord.idempotency_key,
|
BizUsageRecord.idempotency_key,
|
||||||
idempotency_key,
|
idempotency_key,
|
||||||
|
*cls._usage_scope_conditions(site_ids),
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -651,8 +662,9 @@ class OperationDao:
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
query_object: UsagePageQueryModel,
|
query_object: UsagePageQueryModel,
|
||||||
is_page: bool = True,
|
is_page: bool = True,
|
||||||
|
site_ids: list[int] | None = None,
|
||||||
) -> PageModel | list[dict[str, Any]]:
|
) -> PageModel | list[dict[str, Any]]:
|
||||||
conditions = []
|
conditions = cls._usage_scope_conditions(site_ids)
|
||||||
if query_object.usage_no:
|
if query_object.usage_no:
|
||||||
conditions.append(BizUsageRecord.usage_no.like(f'%{query_object.usage_no}%'))
|
conditions.append(BizUsageRecord.usage_no.like(f'%{query_object.usage_no}%'))
|
||||||
if query_object.site_id:
|
if query_object.site_id:
|
||||||
@@ -682,12 +694,14 @@ class OperationDao:
|
|||||||
cls,
|
cls,
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
usage_id: int,
|
usage_id: int,
|
||||||
|
site_ids: list[int] | None = None,
|
||||||
) -> BizUsageRecord | None:
|
) -> BizUsageRecord | None:
|
||||||
return await cls._get_by_id(
|
return await cls._get_by_id(
|
||||||
db,
|
db,
|
||||||
BizUsageRecord,
|
BizUsageRecord,
|
||||||
BizUsageRecord.usage_id,
|
BizUsageRecord.usage_id,
|
||||||
usage_id,
|
usage_id,
|
||||||
|
*cls._usage_scope_conditions(site_ids),
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -0,0 +1,524 @@
|
|||||||
|
"""客户现场使用服务。"""
|
||||||
|
|
||||||
|
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.operation_do import BizPalletEvent, BizUsageRecord
|
||||||
|
from module_business.entity.vo.master_vo import LinePageQueryModel
|
||||||
|
from module_business.entity.vo.operation_vo import (
|
||||||
|
BusinessOptionModel,
|
||||||
|
PalletScanResponseModel,
|
||||||
|
UsageCreateModel,
|
||||||
|
UsageModel,
|
||||||
|
UsagePageQueryModel,
|
||||||
|
)
|
||||||
|
from module_business.service.access_service import AccessService
|
||||||
|
|
||||||
|
|
||||||
|
class UsageService:
|
||||||
|
"""客户现场使用服务层。"""
|
||||||
|
|
||||||
|
@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)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _site_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_site_ids(query_db, user_id)))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _raise_pallet_not_accessible(
|
||||||
|
cls,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
admin_message: str = '托盘不存在',
|
||||||
|
) -> NoReturn:
|
||||||
|
if cls._is_admin(current_user):
|
||||||
|
raise ServiceException(message=admin_message)
|
||||||
|
raise PermissionException(data='', message='无权访问该托盘')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _assert_pallet_site_access(
|
||||||
|
cls,
|
||||||
|
query_db: AsyncSession,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
site_id: int,
|
||||||
|
*,
|
||||||
|
for_update: bool = False,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
if for_update:
|
||||||
|
await AccessService.assert_site_access(
|
||||||
|
query_db,
|
||||||
|
current_user,
|
||||||
|
site_id,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await AccessService.assert_site_access(query_db, current_user, site_id)
|
||||||
|
except PermissionException:
|
||||||
|
if cls._is_admin(current_user):
|
||||||
|
raise
|
||||||
|
cls._raise_pallet_not_accessible(current_user)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _raise_usage_not_accessible(cls, current_user: CurrentUserModel) -> NoReturn:
|
||||||
|
if cls._is_admin(current_user):
|
||||||
|
raise ServiceException(message='使用记录不存在')
|
||||||
|
raise PermissionException(data='', message='无权访问该使用记录')
|
||||||
|
|
||||||
|
@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: UsageCreateModel) -> str:
|
||||||
|
payload = json.dumps(
|
||||||
|
{
|
||||||
|
'qr_token': page_object.qr_token.strip(),
|
||||||
|
'line_id': page_object.line_id,
|
||||||
|
'used_packages': page_object.used_packages,
|
||||||
|
},
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(',', ':'),
|
||||||
|
).encode()
|
||||||
|
return hashlib.sha256(payload).hexdigest()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_exact_idempotent_result(
|
||||||
|
usage: BizUsageRecord,
|
||||||
|
page_object: UsageCreateModel,
|
||||||
|
request_hash: str,
|
||||||
|
) -> bool:
|
||||||
|
return usage.idempotency_key == page_object.idempotency_key and usage.request_hash == request_hash
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _success(usage_id: int) -> CrudResponseModel:
|
||||||
|
return CrudResponseModel(is_success=True, message='使用成功', result=usage_id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _lock_and_reauthorize_usage_site(
|
||||||
|
cls,
|
||||||
|
query_db: AsyncSession,
|
||||||
|
site_id: int,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
) -> None:
|
||||||
|
site = await MasterDao.get_site(query_db, site_id, for_update=True)
|
||||||
|
try:
|
||||||
|
await AccessService.assert_site_access(
|
||||||
|
query_db,
|
||||||
|
current_user,
|
||||||
|
site_id,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
except PermissionException:
|
||||||
|
if cls._is_admin(current_user):
|
||||||
|
raise
|
||||||
|
cls._raise_usage_not_accessible(current_user)
|
||||||
|
if site is None:
|
||||||
|
raise ServiceException(message='客户现场不存在')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def _recover_integrity_error(
|
||||||
|
cls,
|
||||||
|
query_db: AsyncSession,
|
||||||
|
page_object: UsageCreateModel,
|
||||||
|
request_hash: str,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
expected_site_id: int,
|
||||||
|
) -> CrudResponseModel:
|
||||||
|
try:
|
||||||
|
site_ids = await cls._site_scope(query_db, current_user)
|
||||||
|
if site_ids is not None and expected_site_id not in site_ids:
|
||||||
|
cls._raise_pallet_not_accessible(current_user)
|
||||||
|
recovered = await OperationDao.get_usage_by_idempotency_key(
|
||||||
|
query_db,
|
||||||
|
page_object.idempotency_key,
|
||||||
|
site_ids=site_ids,
|
||||||
|
)
|
||||||
|
if recovered is None:
|
||||||
|
raise ServiceException(message='使用失败,数据冲突')
|
||||||
|
if recovered.site_id != expected_site_id or not cls._is_exact_idempotent_result(
|
||||||
|
recovered, page_object, request_hash
|
||||||
|
):
|
||||||
|
raise ServiceException(message='使用失败,幂等键已被其他请求使用')
|
||||||
|
await cls._lock_and_reauthorize_usage_site(
|
||||||
|
query_db,
|
||||||
|
recovered.site_id,
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
return cls._success(recovered.usage_id)
|
||||||
|
finally:
|
||||||
|
await query_db.rollback()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_usage_list_services(
|
||||||
|
cls,
|
||||||
|
query_db: AsyncSession,
|
||||||
|
query_object: UsagePageQueryModel,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
is_page: bool = True,
|
||||||
|
) -> PageModel | list[UsageModel]:
|
||||||
|
site_ids = await cls._site_scope(query_db, current_user)
|
||||||
|
if site_ids is not None and query_object.site_id is not None and query_object.site_id not in site_ids:
|
||||||
|
raise PermissionException(data='', message='无权访问该客户现场')
|
||||||
|
if site_ids == []:
|
||||||
|
if is_page:
|
||||||
|
return PageModel(
|
||||||
|
rows=[],
|
||||||
|
pageNum=query_object.page_num,
|
||||||
|
pageSize=query_object.page_size,
|
||||||
|
total=0,
|
||||||
|
hasNext=False,
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
result = await OperationDao.get_usage_list(
|
||||||
|
query_db,
|
||||||
|
query_object,
|
||||||
|
is_page,
|
||||||
|
site_ids=site_ids,
|
||||||
|
)
|
||||||
|
rows = result.rows if isinstance(result, PageModel) else result
|
||||||
|
models = [UsageModel.model_validate(record) for record in rows]
|
||||||
|
if isinstance(result, PageModel):
|
||||||
|
return result.model_copy(update={'rows': models})
|
||||||
|
return models
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def usage_detail_services(
|
||||||
|
cls,
|
||||||
|
query_db: AsyncSession,
|
||||||
|
usage_id: int,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
) -> UsageModel:
|
||||||
|
site_ids = await cls._site_scope(query_db, current_user)
|
||||||
|
usage = await OperationDao.get_usage_by_id(
|
||||||
|
query_db,
|
||||||
|
usage_id,
|
||||||
|
site_ids=site_ids,
|
||||||
|
)
|
||||||
|
if usage is None:
|
||||||
|
cls._raise_usage_not_accessible(current_user)
|
||||||
|
return UsageModel.model_validate(usage)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_line_options_services(
|
||||||
|
cls,
|
||||||
|
query_db: AsyncSession,
|
||||||
|
site_id: int,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
) -> list[BusinessOptionModel]:
|
||||||
|
cls._current_identity(current_user)
|
||||||
|
await AccessService.assert_site_access(query_db, current_user, site_id)
|
||||||
|
site = await MasterDao.get_site(query_db, site_id)
|
||||||
|
if site is None:
|
||||||
|
raise ServiceException(message='客户现场不存在')
|
||||||
|
if site.status != '0':
|
||||||
|
raise ServiceException(message='客户现场已停用')
|
||||||
|
lines = await MasterDao.list_line(
|
||||||
|
query_db,
|
||||||
|
LinePageQueryModel(siteId=site_id, status='0'),
|
||||||
|
False,
|
||||||
|
)
|
||||||
|
options = [
|
||||||
|
BusinessOptionModel(
|
||||||
|
id=cls._record_value(line, 'line_id'),
|
||||||
|
code=cls._record_value(line, 'line_code'),
|
||||||
|
name=cls._record_value(line, 'line_name'),
|
||||||
|
)
|
||||||
|
for line in lines
|
||||||
|
if cls._record_value(line, 'site_id') == site_id and cls._record_value(line, 'status') == '0'
|
||||||
|
]
|
||||||
|
return sorted(options, key=lambda option: option.id)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def scan_usage_pallet_services(
|
||||||
|
cls,
|
||||||
|
query_db: AsyncSession,
|
||||||
|
qr_token: str,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
) -> PalletScanResponseModel:
|
||||||
|
normalized_qr = qr_token.strip()
|
||||||
|
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, normalized_qr)
|
||||||
|
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.site_id is None:
|
||||||
|
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
|
||||||
|
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
|
||||||
|
|
||||||
|
if pallet.qr_token != normalized_qr or pallet.pallet_id != inventory.pallet_id:
|
||||||
|
raise ServiceException(message='托盘二维码与库存不一致')
|
||||||
|
if pallet.asset_status != '0':
|
||||||
|
raise ServiceException(message='托盘资产已停用,不允许使用')
|
||||||
|
if (
|
||||||
|
inventory.flow_state
|
||||||
|
not in {
|
||||||
|
PalletFlowState.AT_SITE_UNUSED.value,
|
||||||
|
PalletFlowState.AT_SITE_PARTIAL.value,
|
||||||
|
}
|
||||||
|
or inventory.warehouse_id is not None
|
||||||
|
or inventory.active_filling_id is None
|
||||||
|
or type(inventory.remaining_packages) is not int
|
||||||
|
or inventory.remaining_packages <= 0
|
||||||
|
or inventory.remaining_packages > pallet.capacity_packages
|
||||||
|
):
|
||||||
|
raise ServiceException(message='托盘当前状态不允许使用')
|
||||||
|
filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id)
|
||||||
|
if filling is None:
|
||||||
|
raise ServiceException(message='托盘活动装填周期不存在')
|
||||||
|
if (
|
||||||
|
filling.filling_id != inventory.active_filling_id
|
||||||
|
or filling.pallet_id != pallet.pallet_id
|
||||||
|
or filling.active_slot != 'ACTIVE'
|
||||||
|
or filling.closed_at is not None
|
||||||
|
):
|
||||||
|
raise ServiceException(message='托盘活动装填周期无效')
|
||||||
|
return PalletScanResponseModel(
|
||||||
|
pallet_id=pallet.pallet_id,
|
||||||
|
pallet_code=pallet.pallet_code,
|
||||||
|
flow_state=PalletFlowState(inventory.flow_state),
|
||||||
|
warehouse_id=None,
|
||||||
|
site_id=inventory.site_id,
|
||||||
|
current_line_id=inventory.current_line_id,
|
||||||
|
active_filling_id=inventory.active_filling_id,
|
||||||
|
strain_id=filling.strain_id,
|
||||||
|
strain_code=filling.strain_code_snapshot,
|
||||||
|
strain_name=filling.strain_name_snapshot,
|
||||||
|
batch_no=filling.batch_no,
|
||||||
|
package_spec=filling.package_spec,
|
||||||
|
capacity_packages=pallet.capacity_packages,
|
||||||
|
remaining_packages=inventory.remaining_packages,
|
||||||
|
allowed_actions=['usage'],
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def record_usage_services( # noqa: PLR0912, PLR0915
|
||||||
|
cls,
|
||||||
|
query_db: AsyncSession,
|
||||||
|
page_object: UsageCreateModel,
|
||||||
|
current_user: CurrentUserModel,
|
||||||
|
) -> CrudResponseModel:
|
||||||
|
expected_site_id: int | None = None
|
||||||
|
try:
|
||||||
|
request_hash = cls._build_request_hash(page_object)
|
||||||
|
user_id, user_name = cls._current_identity(current_user)
|
||||||
|
site_ids = await cls._site_scope(query_db, current_user)
|
||||||
|
existing = await OperationDao.get_usage_by_idempotency_key(
|
||||||
|
query_db,
|
||||||
|
page_object.idempotency_key,
|
||||||
|
site_ids=site_ids,
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
if not cls._is_exact_idempotent_result(existing, page_object, request_hash):
|
||||||
|
raise ServiceException(message='使用失败,幂等键已被其他请求使用')
|
||||||
|
if site_ids is not None and existing.site_id not in site_ids:
|
||||||
|
cls._raise_usage_not_accessible(current_user)
|
||||||
|
await cls._lock_and_reauthorize_usage_site(
|
||||||
|
query_db,
|
||||||
|
existing.site_id,
|
||||||
|
current_user,
|
||||||
|
)
|
||||||
|
usage_id = existing.usage_id
|
||||||
|
await query_db.rollback()
|
||||||
|
return cls._success(usage_id)
|
||||||
|
|
||||||
|
scanned_pallet = await InventoryDao.get_pallet_by_qr_token(
|
||||||
|
query_db,
|
||||||
|
page_object.qr_token.strip(),
|
||||||
|
)
|
||||||
|
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.site_id is None:
|
||||||
|
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
|
||||||
|
expected_site_id = inventory.site_id
|
||||||
|
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
|
||||||
|
|
||||||
|
locked_pallets = await InventoryDao.get_pallets_by_ids(
|
||||||
|
query_db,
|
||||||
|
[inventory.pallet_id],
|
||||||
|
)
|
||||||
|
if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != inventory.pallet_id:
|
||||||
|
cls._raise_pallet_not_accessible(current_user)
|
||||||
|
pallet = locked_pallets[0]
|
||||||
|
|
||||||
|
if inventory.active_filling_id is None:
|
||||||
|
raise ServiceException(message='托盘不存在活动装填周期')
|
||||||
|
filling = await OperationDao.get_active_filling_for_update(
|
||||||
|
query_db,
|
||||||
|
inventory.active_filling_id,
|
||||||
|
)
|
||||||
|
if filling is None:
|
||||||
|
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
|
||||||
|
|
||||||
|
site = await MasterDao.get_site(query_db, inventory.site_id, for_update=True)
|
||||||
|
await cls._assert_pallet_site_access(
|
||||||
|
query_db,
|
||||||
|
current_user,
|
||||||
|
inventory.site_id,
|
||||||
|
for_update=True,
|
||||||
|
)
|
||||||
|
if site is None:
|
||||||
|
raise ServiceException(message='客户现场不存在')
|
||||||
|
if site.status != '0':
|
||||||
|
raise ServiceException(message='客户现场已停用')
|
||||||
|
line = await MasterDao.get_line(query_db, page_object.line_id, for_update=True)
|
||||||
|
if line is None:
|
||||||
|
raise ServiceException(message='产线不存在')
|
||||||
|
if line.status != '0':
|
||||||
|
raise ServiceException(message='产线已停用')
|
||||||
|
if line.site_id != inventory.site_id:
|
||||||
|
raise ServiceException(message='产线不属于托盘当前客户现场')
|
||||||
|
|
||||||
|
if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != page_object.qr_token.strip():
|
||||||
|
raise ServiceException(message='托盘二维码与库存不一致')
|
||||||
|
if pallet.asset_status != '0':
|
||||||
|
raise ServiceException(message='托盘资产已停用,不允许使用')
|
||||||
|
if inventory.flow_state not in {
|
||||||
|
PalletFlowState.AT_SITE_UNUSED.value,
|
||||||
|
PalletFlowState.AT_SITE_PARTIAL.value,
|
||||||
|
}:
|
||||||
|
raise ServiceException(message='托盘当前状态不允许使用')
|
||||||
|
if inventory.warehouse_id is not None:
|
||||||
|
raise ServiceException(message='托盘当前位置无效')
|
||||||
|
if type(inventory.remaining_packages) is not int or inventory.remaining_packages <= 0:
|
||||||
|
raise ServiceException(message='托盘当前余量无效')
|
||||||
|
if inventory.remaining_packages > pallet.capacity_packages:
|
||||||
|
raise ServiceException(message='托盘当前余量超过容量')
|
||||||
|
if type(inventory.version) is not int or inventory.version < 0:
|
||||||
|
raise ServiceException(message='托盘库存版本无效')
|
||||||
|
if (
|
||||||
|
filling.filling_id != inventory.active_filling_id
|
||||||
|
or filling.pallet_id != pallet.pallet_id
|
||||||
|
or filling.active_slot != 'ACTIVE'
|
||||||
|
or filling.closed_at is not None
|
||||||
|
):
|
||||||
|
raise ServiceException(message='托盘活动装填周期无效')
|
||||||
|
if inventory.current_line_id is not None and inventory.current_line_id != line.line_id:
|
||||||
|
raise ServiceException(message='托盘本次驻场周期已绑定其他产线')
|
||||||
|
if type(page_object.used_packages) is not int or page_object.used_packages <= 0:
|
||||||
|
raise ServiceException(message='使用包数必须为正整数')
|
||||||
|
if page_object.used_packages > inventory.remaining_packages:
|
||||||
|
raise ServiceException(message='使用包数不能超过当前余量')
|
||||||
|
|
||||||
|
remaining_after = inventory.remaining_packages - page_object.used_packages
|
||||||
|
target_state = PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL
|
||||||
|
PalletStateMachine.ensure_transition(
|
||||||
|
PalletFlowState(inventory.flow_state),
|
||||||
|
target_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
current_time = datetime.now()
|
||||||
|
usage = BizUsageRecord(
|
||||||
|
usage_no=f'US-{secrets.token_hex(16)}',
|
||||||
|
pallet_id=pallet.pallet_id,
|
||||||
|
active_filling_id=filling.filling_id,
|
||||||
|
site_id=inventory.site_id,
|
||||||
|
line_id=line.line_id,
|
||||||
|
used_packages=page_object.used_packages,
|
||||||
|
remaining_packages_after=remaining_after,
|
||||||
|
idempotency_key=page_object.idempotency_key,
|
||||||
|
request_hash=request_hash,
|
||||||
|
operator_id=user_id,
|
||||||
|
occurred_at=current_time,
|
||||||
|
)
|
||||||
|
usage = await OperationDao.add_usage_record(query_db, usage)
|
||||||
|
usage_id = usage.usage_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.current_line_id = line.line_id
|
||||||
|
inventory.remaining_packages = remaining_after
|
||||||
|
inventory.flow_state = target_state.value
|
||||||
|
if target_state is PalletFlowState.AT_SITE_EMPTY:
|
||||||
|
filling.active_slot = None
|
||||||
|
filling.closed_at = current_time
|
||||||
|
filling.update_by = user_name
|
||||||
|
filling.update_time = current_time
|
||||||
|
await OperationDao.update_filling_record(query_db, filling)
|
||||||
|
inventory.active_filling_id = None
|
||||||
|
inventory.version += 1
|
||||||
|
inventory.updated_at = current_time
|
||||||
|
event = BizPalletEvent(
|
||||||
|
pallet_id=inventory.pallet_id,
|
||||||
|
event_type=PalletEventType.USAGE_RECORDED.value,
|
||||||
|
source_type='USAGE',
|
||||||
|
source_id=usage_id,
|
||||||
|
before_state=before_state,
|
||||||
|
after_state=target_state.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=remaining_after,
|
||||||
|
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(usage_id)
|
||||||
|
except IntegrityError:
|
||||||
|
await query_db.rollback()
|
||||||
|
if expected_site_id is None:
|
||||||
|
raise ServiceException(message='使用失败,数据冲突') from None
|
||||||
|
return await cls._recover_integrity_error(
|
||||||
|
query_db,
|
||||||
|
page_object,
|
||||||
|
request_hash,
|
||||||
|
current_user,
|
||||||
|
expected_site_id,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await query_db.rollback()
|
||||||
|
raise
|
||||||
@@ -133,7 +133,7 @@ class TestDaoPublicContract(unittest.TestCase):
|
|||||||
'site_ids',
|
'site_ids',
|
||||||
),
|
),
|
||||||
'get_filling_by_idempotency_key': ('db', 'idempotency_key', 'warehouse_ids'),
|
'get_filling_by_idempotency_key': ('db', 'idempotency_key', 'warehouse_ids'),
|
||||||
'get_usage_by_idempotency_key': ('db', 'idempotency_key'),
|
'get_usage_by_idempotency_key': ('db', 'idempotency_key', 'site_ids'),
|
||||||
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'),
|
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'),
|
||||||
'get_return_by_confirm_idempotency_key': ('db', 'idempotency_key'),
|
'get_return_by_confirm_idempotency_key': ('db', 'idempotency_key'),
|
||||||
'get_inbound_by_id': ('db', 'inbound_id', 'warehouse_ids'),
|
'get_inbound_by_id': ('db', 'inbound_id', 'warehouse_ids'),
|
||||||
@@ -149,7 +149,7 @@ class TestDaoPublicContract(unittest.TestCase):
|
|||||||
'update_shipment': ('db', 'shipment'),
|
'update_shipment': ('db', 'shipment'),
|
||||||
'delete_shipment': ('db', 'shipment_id'),
|
'delete_shipment': ('db', 'shipment_id'),
|
||||||
'replace_shipment_items': ('db', 'shipment_id', 'items'),
|
'replace_shipment_items': ('db', 'shipment_id', 'items'),
|
||||||
'get_usage_by_id': ('db', 'usage_id'),
|
'get_usage_by_id': ('db', 'usage_id', 'site_ids'),
|
||||||
'add_usage_record': ('db', 'usage'),
|
'add_usage_record': ('db', 'usage'),
|
||||||
'get_return_by_id': ('db', 'return_id'),
|
'get_return_by_id': ('db', 'return_id'),
|
||||||
'add_return_order': ('db', 'return_order'),
|
'add_return_order': ('db', 'return_order'),
|
||||||
@@ -209,11 +209,25 @@ class TestDaoPublicContract(unittest.TestCase):
|
|||||||
self.assertIsNone(shipment_list_signature.parameters['warehouse_ids'].default)
|
self.assertIsNone(shipment_list_signature.parameters['warehouse_ids'].default)
|
||||||
self.assertIsNone(shipment_list_signature.parameters['site_ids'].default)
|
self.assertIsNone(shipment_list_signature.parameters['site_ids'].default)
|
||||||
|
|
||||||
for method_name in ('get_usage_list', 'get_return_list'):
|
usage_list_signature = inspect.signature(OperationDao.get_usage_list)
|
||||||
|
self.assertEqual(
|
||||||
|
('db', 'query_object', 'is_page', 'site_ids'),
|
||||||
|
tuple(usage_list_signature.parameters),
|
||||||
|
)
|
||||||
|
self.assertIs(True, usage_list_signature.parameters['is_page'].default)
|
||||||
|
self.assertIsNone(usage_list_signature.parameters['site_ids'].default)
|
||||||
|
|
||||||
|
for method_name in (
|
||||||
|
'get_usage_by_idempotency_key',
|
||||||
|
'get_usage_by_id',
|
||||||
|
):
|
||||||
with self.subTest(method=method_name):
|
with self.subTest(method=method_name):
|
||||||
signature = inspect.signature(getattr(OperationDao, method_name))
|
signature = inspect.signature(getattr(OperationDao, method_name))
|
||||||
self.assertEqual(('db', 'query_object', 'is_page'), tuple(signature.parameters))
|
self.assertIsNone(signature.parameters['site_ids'].default)
|
||||||
self.assertIs(True, signature.parameters['is_page'].default)
|
|
||||||
|
return_list_signature = inspect.signature(OperationDao.get_return_list)
|
||||||
|
self.assertEqual(('db', 'query_object', 'is_page'), tuple(return_list_signature.parameters))
|
||||||
|
self.assertIs(True, return_list_signature.parameters['is_page'].default)
|
||||||
|
|
||||||
def test_dao_source_never_controls_transaction_boundary(self) -> None:
|
def test_dao_source_never_controls_transaction_boundary(self) -> None:
|
||||||
for module in (inventory_dao_module, operation_dao_module):
|
for module in (inventory_dao_module, operation_dao_module):
|
||||||
@@ -864,6 +878,71 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn("biz_shipment_order.confirm_idempotency_key = 'confirm-key'", key_sql)
|
self.assertIn("biz_shipment_order.confirm_idempotency_key = 'confirm-key'", key_sql)
|
||||||
self.assertNotIn('FOR UPDATE', key_sql)
|
self.assertNotIn('FOR UPDATE', key_sql)
|
||||||
|
|
||||||
|
async def test_usage_reads_push_site_scope_and_empty_scope_is_always_false(self) -> None:
|
||||||
|
query = UsagePageQueryModel(page_num=2, page_size=7)
|
||||||
|
marker = object()
|
||||||
|
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate:
|
||||||
|
actual = await OperationDao.get_usage_list(
|
||||||
|
self.db,
|
||||||
|
query,
|
||||||
|
is_page=True,
|
||||||
|
site_ids=[11, 9, 11],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(marker, actual)
|
||||||
|
list_statement = paginate.await_args.args[1]
|
||||||
|
|
||||||
|
scalar_result = MagicMock()
|
||||||
|
scalar_result.scalar_one_or_none.return_value = None
|
||||||
|
self.db.execute.return_value = scalar_result
|
||||||
|
await OperationDao.get_usage_by_id(self.db, 15, site_ids=[11, 9, 11])
|
||||||
|
detail_statement = self.db.execute.await_args.args[0]
|
||||||
|
|
||||||
|
self.db.reset_mock()
|
||||||
|
self.db.execute.return_value = scalar_result
|
||||||
|
await OperationDao.get_usage_by_idempotency_key(
|
||||||
|
self.db,
|
||||||
|
'usage-key',
|
||||||
|
site_ids=[11, 9, 11],
|
||||||
|
)
|
||||||
|
key_statement = self.db.execute.await_args.args[0]
|
||||||
|
|
||||||
|
for dialect in (mysql.dialect(), postgresql.dialect()):
|
||||||
|
with self.subTest(dialect=dialect.name):
|
||||||
|
for statement in (list_statement, detail_statement, key_statement):
|
||||||
|
sql = compile_sql(statement, dialect)
|
||||||
|
self.assertIn('biz_usage_record.site_id IN (9, 11)', sql)
|
||||||
|
|
||||||
|
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as empty_paginate:
|
||||||
|
await OperationDao.get_usage_list(self.db, query, site_ids=[])
|
||||||
|
empty_list = empty_paginate.await_args.args[1]
|
||||||
|
|
||||||
|
self.db.reset_mock()
|
||||||
|
self.db.execute.return_value = scalar_result
|
||||||
|
await OperationDao.get_usage_by_id(self.db, 15, site_ids=[])
|
||||||
|
empty_detail = self.db.execute.await_args.args[0]
|
||||||
|
|
||||||
|
self.db.reset_mock()
|
||||||
|
self.db.execute.return_value = scalar_result
|
||||||
|
await OperationDao.get_usage_by_idempotency_key(self.db, 'usage-key', site_ids=[])
|
||||||
|
empty_key = self.db.execute.await_args.args[0]
|
||||||
|
|
||||||
|
for dialect in (mysql.dialect(), postgresql.dialect()):
|
||||||
|
with self.subTest(dialect=f'{dialect.name}-empty'):
|
||||||
|
for statement in (empty_list, empty_detail, empty_key):
|
||||||
|
sql = compile_sql(statement, dialect)
|
||||||
|
self.assertTrue('false' in sql.lower() or '0 = 1' in sql, sql)
|
||||||
|
|
||||||
|
async def test_usage_none_scope_adds_no_authorization_predicate(self) -> None:
|
||||||
|
query = UsagePageQueryModel()
|
||||||
|
marker = object()
|
||||||
|
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate:
|
||||||
|
await OperationDao.get_usage_list(self.db, query, site_ids=None)
|
||||||
|
|
||||||
|
sql = compile_sql(paginate.await_args.args[1])
|
||||||
|
self.assertNotIn('site_id IN', sql)
|
||||||
|
self.assertNotIn('WHERE false', sql)
|
||||||
|
|
||||||
def test_time_conditions_include_fractional_last_second_with_exclusive_next_day(self) -> None:
|
def test_time_conditions_include_fractional_last_second_with_exclusive_next_day(self) -> None:
|
||||||
lower, upper = OperationDao._time_conditions(
|
lower, upper = OperationDao._time_conditions(
|
||||||
BizUsageRecord.occurred_at,
|
BizUsageRecord.occurred_at,
|
||||||
|
|||||||
@@ -503,13 +503,13 @@ class TestMasterDao(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertIn(f'{table}.{id_field} = 9', sql)
|
self.assertIn(f'{table}.{id_field} = 9', sql)
|
||||||
|
|
||||||
async def test_locking_master_gets_use_current_reads_and_refresh_identity_map(self) -> None:
|
async def test_locking_master_gets_use_current_reads_and_refresh_identity_map(self) -> None:
|
||||||
strain_lock_parameter = inspect.signature(MasterDao.get_strain).parameters.get('for_update')
|
for resource in ('warehouse', 'strain', 'line'):
|
||||||
self.assertIsNotNone(strain_lock_parameter)
|
|
||||||
self.assertIs(inspect.Parameter.KEYWORD_ONLY, strain_lock_parameter.kind)
|
|
||||||
self.assertFalse(strain_lock_parameter.default)
|
|
||||||
|
|
||||||
for resource in ('warehouse', 'strain'):
|
|
||||||
with self.subTest(resource=resource):
|
with self.subTest(resource=resource):
|
||||||
|
lock_parameter = inspect.signature(getattr(MasterDao, f'get_{resource}')).parameters.get('for_update')
|
||||||
|
self.assertIsNotNone(lock_parameter)
|
||||||
|
self.assertIs(inspect.Parameter.KEYWORD_ONLY, lock_parameter.kind)
|
||||||
|
self.assertFalse(lock_parameter.default)
|
||||||
|
|
||||||
result = MagicMock()
|
result = MagicMock()
|
||||||
result.scalar_one_or_none.return_value = None
|
result.scalar_one_or_none.return_value = None
|
||||||
self.db.execute = AsyncMock(return_value=result)
|
self.db.execute = AsyncMock(return_value=result)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user