功能:实现客户现场使用记录

This commit is contained in:
焦龙言
2026-07-26 21:14:01 +08:00
parent 3076194328
commit bc774c74e6
7 changed files with 1968 additions and 14 deletions
@@ -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