功能:实现单托盘装填
This commit is contained in:
@@ -0,0 +1,464 @@
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Any, NoReturn
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import CrudResponseModel, PageModel
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.entity.do.master_do import BizPallet, BizStrain
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory
|
||||
from module_business.entity.vo.master_vo import StrainPageQueryModel
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingCreateModel,
|
||||
FillingModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
|
||||
|
||||
class FillingService:
|
||||
"""仓库内单托盘即时装填服务层。"""
|
||||
|
||||
@staticmethod
|
||||
def _current_identity(current_user: CurrentUserModel) -> tuple[int, str]:
|
||||
user = current_user.user
|
||||
if user is None or user.user_id is None or user.user_id <= 0 or not user.user_name:
|
||||
raise ServiceException(message='当前用户信息无效')
|
||||
return user.user_id, user.user_name
|
||||
|
||||
@staticmethod
|
||||
def _is_admin(current_user: CurrentUserModel) -> bool:
|
||||
return bool(current_user.user and current_user.user.admin)
|
||||
|
||||
@staticmethod
|
||||
def _record_value(record: Any, field: str) -> Any:
|
||||
if not isinstance(record, dict):
|
||||
return getattr(record, field)
|
||||
if field in record:
|
||||
return record[field]
|
||||
words = field.split('_')
|
||||
alias = words[0] + ''.join(word.capitalize() for word in words[1:])
|
||||
return record.get(alias)
|
||||
|
||||
@staticmethod
|
||||
def _build_request_hash(page_object: FillingCreateModel) -> str:
|
||||
payload = json.dumps(
|
||||
{
|
||||
'qr_token': page_object.qr_token.strip(),
|
||||
'warehouse_id': page_object.warehouse_id,
|
||||
'strain_id': page_object.strain_id,
|
||||
'batch_no': page_object.batch_no.strip(),
|
||||
'package_spec': page_object.package_spec.strip(),
|
||||
'initial_packages': page_object.initial_packages,
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
).encode()
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
@classmethod
|
||||
async def _warehouse_scope(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
) -> list[int] | None:
|
||||
if cls._is_admin(current_user):
|
||||
return None
|
||||
user_id, _ = cls._current_identity(current_user)
|
||||
return sorted(set(await AccessDao.list_user_warehouse_ids(query_db, user_id)))
|
||||
|
||||
@classmethod
|
||||
def _raise_filling_not_accessible(cls, current_user: CurrentUserModel) -> NoReturn:
|
||||
if cls._is_admin(current_user):
|
||||
raise ServiceException(message='装填记录不存在')
|
||||
raise PermissionException(data='', message='无权访问该装填记录')
|
||||
|
||||
@classmethod
|
||||
def _raise_pallet_not_accessible(
|
||||
cls,
|
||||
current_user: CurrentUserModel,
|
||||
admin_message: str = '托盘不存在',
|
||||
) -> NoReturn:
|
||||
if cls._is_admin(current_user):
|
||||
raise ServiceException(message=admin_message)
|
||||
raise PermissionException(data='', message='无权访问该托盘')
|
||||
|
||||
@staticmethod
|
||||
def _validate_empty_warehouse_pallet(
|
||||
pallet: BizPallet,
|
||||
inventory: BizPalletInventory,
|
||||
warehouse_id: int,
|
||||
qr_token: str,
|
||||
) -> None:
|
||||
if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != qr_token:
|
||||
raise ServiceException(message='托盘二维码与库存不一致')
|
||||
if pallet.asset_status != '0':
|
||||
raise ServiceException(message='托盘资产已停用,不允许装填')
|
||||
if (
|
||||
inventory.flow_state != PalletFlowState.EMPTY_IN_WAREHOUSE.value
|
||||
or inventory.warehouse_id != warehouse_id
|
||||
or inventory.site_id is not None
|
||||
or inventory.current_line_id is not None
|
||||
or inventory.active_filling_id is not None
|
||||
or inventory.remaining_packages != 0
|
||||
):
|
||||
raise ServiceException(message='托盘不符合仓库空托盘装填条件')
|
||||
if type(inventory.version) is not int or inventory.version < 0:
|
||||
raise ServiceException(message='托盘库存版本无效')
|
||||
|
||||
@staticmethod
|
||||
def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None:
|
||||
if type(initial_packages) is not int or initial_packages <= 0:
|
||||
raise ServiceException(message='装填包数必须为正整数')
|
||||
if initial_packages > pallet.capacity_packages:
|
||||
raise ServiceException(message='装填包数不能超过托盘容量')
|
||||
|
||||
@staticmethod
|
||||
def _validate_active_strain(strain: BizStrain | None) -> BizStrain:
|
||||
if strain is None:
|
||||
raise ServiceException(message='菌种不存在')
|
||||
if strain.status != '0':
|
||||
raise ServiceException(message='菌种已停用')
|
||||
return strain
|
||||
|
||||
@staticmethod
|
||||
def _is_exact_idempotent_result(
|
||||
filling: BizFillingRecord,
|
||||
page_object: FillingCreateModel,
|
||||
request_hash: str,
|
||||
) -> bool:
|
||||
return (
|
||||
filling.idempotency_key == page_object.idempotency_key
|
||||
and filling.warehouse_id == page_object.warehouse_id
|
||||
and filling.request_hash == request_hash
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _success(filling_id: int) -> CrudResponseModel:
|
||||
return CrudResponseModel(is_success=True, message='装填成功', result=filling_id)
|
||||
|
||||
@classmethod
|
||||
async def get_filling_list_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
query_object: FillingPageQueryModel,
|
||||
current_user: CurrentUserModel,
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[FillingModel]:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if (
|
||||
warehouse_ids is not None
|
||||
and query_object.warehouse_id is not None
|
||||
and query_object.warehouse_id not in warehouse_ids
|
||||
):
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
result = await OperationDao.get_filling_list(
|
||||
query_db,
|
||||
query_object,
|
||||
is_page,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
rows = result.rows if isinstance(result, PageModel) else result
|
||||
models = [FillingModel.model_validate(record) for record in rows]
|
||||
if isinstance(result, PageModel):
|
||||
return result.model_copy(update={'rows': models})
|
||||
return models
|
||||
|
||||
@classmethod
|
||||
async def filling_detail_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
filling_id: int,
|
||||
current_user: CurrentUserModel,
|
||||
) -> FillingModel:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
filling = await OperationDao.get_filling_by_id(
|
||||
query_db,
|
||||
filling_id,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if filling is None:
|
||||
cls._raise_filling_not_accessible(current_user)
|
||||
return FillingModel.model_validate(filling)
|
||||
|
||||
@classmethod
|
||||
async def get_strain_options_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
current_user: CurrentUserModel,
|
||||
) -> list[BusinessOptionModel]:
|
||||
cls._current_identity(current_user)
|
||||
strains = await MasterDao.list_strain(
|
||||
query_db,
|
||||
StrainPageQueryModel(status='0'),
|
||||
False,
|
||||
)
|
||||
options = [
|
||||
BusinessOptionModel(
|
||||
id=cls._record_value(strain, 'strain_id'),
|
||||
code=cls._record_value(strain, 'strain_code'),
|
||||
name=cls._record_value(strain, 'strain_name'),
|
||||
)
|
||||
for strain in strains
|
||||
if cls._record_value(strain, 'status') == '0'
|
||||
]
|
||||
return sorted(options, key=lambda option: option.id)
|
||||
|
||||
@classmethod
|
||||
async def scan_filling_pallet_services(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
qr_token: str,
|
||||
current_user: CurrentUserModel,
|
||||
) -> PalletScanResponseModel:
|
||||
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token)
|
||||
if pallet is None:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id)
|
||||
if inventory is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
|
||||
if inventory.warehouse_id is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在仓库')
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
inventory.warehouse_id,
|
||||
)
|
||||
cls._validate_empty_warehouse_pallet(
|
||||
pallet,
|
||||
inventory,
|
||||
inventory.warehouse_id,
|
||||
qr_token,
|
||||
)
|
||||
return PalletScanResponseModel(
|
||||
pallet_id=pallet.pallet_id,
|
||||
pallet_code=pallet.pallet_code,
|
||||
flow_state=PalletFlowState.EMPTY_IN_WAREHOUSE,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
site_id=None,
|
||||
current_line_id=None,
|
||||
active_filling_id=None,
|
||||
strain_id=None,
|
||||
strain_code=None,
|
||||
strain_name=None,
|
||||
batch_no=None,
|
||||
package_spec=None,
|
||||
capacity_packages=pallet.capacity_packages,
|
||||
remaining_packages=0,
|
||||
allowed_actions=['filling'],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def _recover_integrity_error(
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: FillingCreateModel,
|
||||
request_hash: str,
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
try:
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if warehouse_ids is not None and page_object.warehouse_id not in warehouse_ids:
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
recovered = await OperationDao.get_filling_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if recovered is None:
|
||||
if not cls._is_admin(current_user):
|
||||
raise PermissionException(data='', message='无权访问该装填记录')
|
||||
raise ServiceException(message='装填失败,数据冲突')
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
recovered.warehouse_id,
|
||||
)
|
||||
if not cls._is_exact_idempotent_result(recovered, page_object, request_hash):
|
||||
raise ServiceException(message='装填失败,幂等键已被其他请求使用')
|
||||
return cls._success(recovered.filling_id)
|
||||
finally:
|
||||
await query_db.rollback()
|
||||
|
||||
@classmethod
|
||||
async def fill_pallet_services( # noqa: PLR0912, PLR0915
|
||||
cls,
|
||||
query_db: AsyncSession,
|
||||
page_object: FillingCreateModel,
|
||||
current_user: CurrentUserModel,
|
||||
) -> CrudResponseModel:
|
||||
recovery_transaction_ended = False
|
||||
try:
|
||||
request_hash = cls._build_request_hash(page_object)
|
||||
user_id, user_name = cls._current_identity(current_user)
|
||||
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
|
||||
if warehouse_ids is not None and page_object.warehouse_id not in warehouse_ids:
|
||||
raise PermissionException(data='', message='无权访问该仓库')
|
||||
|
||||
existing = await OperationDao.get_filling_by_idempotency_key(
|
||||
query_db,
|
||||
page_object.idempotency_key,
|
||||
warehouse_ids=warehouse_ids,
|
||||
)
|
||||
if existing is not None:
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
existing.warehouse_id,
|
||||
)
|
||||
if not cls._is_exact_idempotent_result(existing, page_object, request_hash):
|
||||
raise ServiceException(message='装填失败,幂等键已被其他请求使用')
|
||||
filling_id = existing.filling_id
|
||||
await query_db.rollback()
|
||||
return cls._success(filling_id)
|
||||
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
page_object.warehouse_id,
|
||||
)
|
||||
warehouse = await MasterDao.get_warehouse(query_db, page_object.warehouse_id)
|
||||
if warehouse is None:
|
||||
raise ServiceException(message='仓库不存在')
|
||||
if warehouse.status != '0':
|
||||
raise ServiceException(message='仓库已停用')
|
||||
|
||||
scanned_pallet = await InventoryDao.get_pallet_by_qr_token(
|
||||
query_db,
|
||||
page_object.qr_token,
|
||||
)
|
||||
if scanned_pallet is None:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
|
||||
inventory = await InventoryDao.get_inventory_for_update(
|
||||
query_db,
|
||||
scanned_pallet.pallet_id,
|
||||
)
|
||||
if inventory is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
|
||||
if inventory.warehouse_id is None:
|
||||
cls._raise_pallet_not_accessible(current_user, '托盘不在仓库')
|
||||
await AccessService.assert_warehouse_access(
|
||||
query_db,
|
||||
current_user,
|
||||
inventory.warehouse_id,
|
||||
)
|
||||
locked_pallets = await InventoryDao.get_pallets_by_ids(
|
||||
query_db,
|
||||
[scanned_pallet.pallet_id],
|
||||
)
|
||||
if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != scanned_pallet.pallet_id:
|
||||
cls._raise_pallet_not_accessible(current_user)
|
||||
pallet = locked_pallets[0]
|
||||
|
||||
if inventory.active_filling_id is not None:
|
||||
concurrent = await OperationDao.get_active_filling_for_update(
|
||||
query_db,
|
||||
inventory.active_filling_id,
|
||||
)
|
||||
if concurrent is not None and concurrent.idempotency_key == page_object.idempotency_key:
|
||||
# 结束首次非锁定读取建立的 RR 快照,再按最新数据范围恢复并发成功结果。
|
||||
await query_db.rollback()
|
||||
recovery_transaction_ended = True
|
||||
return await cls._recover_integrity_error(
|
||||
query_db,
|
||||
page_object,
|
||||
request_hash,
|
||||
current_user,
|
||||
)
|
||||
|
||||
cls._validate_empty_warehouse_pallet(
|
||||
pallet,
|
||||
inventory,
|
||||
page_object.warehouse_id,
|
||||
page_object.qr_token,
|
||||
)
|
||||
cls._validate_package_count(page_object.initial_packages, pallet)
|
||||
PalletStateMachine.ensure_transition(
|
||||
PalletFlowState(inventory.flow_state),
|
||||
PalletFlowState.FILLED_IN_WAREHOUSE,
|
||||
)
|
||||
strain = cls._validate_active_strain(await MasterDao.get_strain(query_db, page_object.strain_id))
|
||||
|
||||
current_time = datetime.now()
|
||||
filling = BizFillingRecord(
|
||||
filling_no=f'FL-{secrets.token_hex(16)}',
|
||||
pallet_id=pallet.pallet_id,
|
||||
warehouse_id=inventory.warehouse_id,
|
||||
strain_id=strain.strain_id,
|
||||
strain_code_snapshot=strain.strain_code,
|
||||
strain_name_snapshot=strain.strain_name,
|
||||
batch_no=page_object.batch_no.strip(),
|
||||
package_spec=page_object.package_spec.strip(),
|
||||
initial_packages=page_object.initial_packages,
|
||||
active_slot='ACTIVE',
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
request_hash=request_hash,
|
||||
operator_id=user_id,
|
||||
operated_at=current_time,
|
||||
create_by=user_name,
|
||||
create_time=current_time,
|
||||
update_by=user_name,
|
||||
update_time=current_time,
|
||||
)
|
||||
filling = await OperationDao.add_filling_record(query_db, filling)
|
||||
filling_id = filling.filling_id
|
||||
|
||||
before_state = inventory.flow_state
|
||||
before_warehouse_id = inventory.warehouse_id
|
||||
before_site_id = inventory.site_id
|
||||
before_line_id = inventory.current_line_id
|
||||
before_remaining_packages = inventory.remaining_packages
|
||||
inventory.active_filling_id = filling_id
|
||||
inventory.remaining_packages = page_object.initial_packages
|
||||
inventory.flow_state = PalletFlowState.FILLED_IN_WAREHOUSE.value
|
||||
inventory.version += 1
|
||||
inventory.updated_at = current_time
|
||||
event = BizPalletEvent(
|
||||
pallet_id=inventory.pallet_id,
|
||||
event_type=PalletEventType.FILLING_CONFIRMED.value,
|
||||
source_type='FILLING',
|
||||
source_id=filling_id,
|
||||
before_state=before_state,
|
||||
after_state=PalletFlowState.FILLED_IN_WAREHOUSE.value,
|
||||
before_warehouse_id=before_warehouse_id,
|
||||
before_site_id=before_site_id,
|
||||
before_line_id=before_line_id,
|
||||
after_warehouse_id=inventory.warehouse_id,
|
||||
after_site_id=inventory.site_id,
|
||||
after_line_id=inventory.current_line_id,
|
||||
before_remaining_packages=before_remaining_packages,
|
||||
after_remaining_packages=page_object.initial_packages,
|
||||
operator_id=user_id,
|
||||
occurred_at=current_time,
|
||||
idempotency_key=page_object.idempotency_key,
|
||||
request_hash=request_hash,
|
||||
)
|
||||
await InventoryDao.add_event(query_db, inventory, event)
|
||||
await query_db.commit()
|
||||
return cls._success(filling_id)
|
||||
except IntegrityError:
|
||||
await query_db.rollback()
|
||||
return await cls._recover_integrity_error(
|
||||
query_db,
|
||||
page_object,
|
||||
request_hash,
|
||||
current_user,
|
||||
)
|
||||
except Exception:
|
||||
if not recovery_transaction_ended:
|
||||
await query_db.rollback()
|
||||
raise
|
||||
Reference in New Issue
Block a user