Files
ShiErFeng/shierfeng-fastapi-backend/module_business/service/usage_service.py
T

567 lines
23 KiB
Python

"""客户现场使用服务。"""
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,
UsageSuccessResponseModel,
)
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: BizUsageRecord) -> CrudResponseModel:
remaining_packages = usage.remaining_packages_after
flow_state = (
PalletFlowState.AT_SITE_EMPTY if remaining_packages == 0 else PalletFlowState.AT_SITE_PARTIAL
)
return CrudResponseModel(
is_success=True,
message='使用成功',
result=UsageSuccessResponseModel(
usage_id=usage.usage_id,
remaining_packages=remaining_packages,
flow_state=flow_state,
),
)
@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)
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)
preflight_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=preflight_site_ids,
)
if existing is not None:
if not cls._is_exact_idempotent_result(existing, page_object, request_hash):
raise ServiceException(message='使用失败,幂等键已被其他请求使用')
if preflight_site_ids is not None and existing.site_id not in preflight_site_ids:
cls._raise_usage_not_accessible(current_user)
await cls._lock_and_reauthorize_usage_site(
query_db,
existing.site_id,
current_user,
)
result = cls._success(existing)
await query_db.rollback()
return result
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)
scanned_pallet_id = int(scanned_pallet.pallet_id)
await query_db.rollback()
inventory = await InventoryDao.get_inventory_for_update(
query_db,
scanned_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_site_ids = await cls._site_scope(query_db, current_user)
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]
filling = (
await OperationDao.get_active_filling_for_update(
query_db,
inventory.active_filling_id,
)
if inventory.active_filling_id is not None
else None
)
locked_existing = await OperationDao.get_usage_by_idempotency_key(
query_db,
page_object.idempotency_key,
site_ids=locked_site_ids,
)
if locked_existing is not None:
if locked_existing.site_id != inventory.site_id or not cls._is_exact_idempotent_result(
locked_existing,
page_object,
request_hash,
):
raise ServiceException(message='使用失败,幂等键已被其他请求使用')
await cls._lock_and_reauthorize_usage_site(
query_db,
locked_existing.site_id,
current_user,
)
result = cls._success(locked_existing)
await query_db.rollback()
return result
if inventory.active_filling_id is None:
raise ServiceException(message='托盘不存在活动装填周期')
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
result = cls._success(usage)
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 result
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