功能:实现单托盘装填

This commit is contained in:
焦龙言
2026-07-26 17:09:17 +08:00
parent 2e616ab6e0
commit 18abe861f9
5 changed files with 1542 additions and 11 deletions
@@ -0,0 +1,125 @@
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,
FillingCreateModel,
FillingModel,
FillingPageQueryModel,
PalletScanResponseModel,
)
from module_business.service.filling_service import FillingService
from utils.response_util import ResponseUtil
filling_controller = APIRouterPro(
prefix='/business/filling',
tags=['业务管理-托盘装填'],
dependencies=[PreAuthDependency()],
)
@filling_controller.get(
'/list',
summary='获取托盘装填记录分页列表接口',
description='按当前用户仓库数据范围获取托盘装填记录分页列表',
response_model=PageResponseModel[FillingModel],
dependencies=[UserInterfaceAuthDependency('business:filling:list')],
)
async def get_business_filling_list(
request: Request,
filling_page_query: Annotated[FillingPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await FillingService.get_filling_list_services(
query_db,
filling_page_query,
current_user,
is_page=True,
)
return ResponseUtil.success(model_content=result)
@filling_controller.get(
'/strain-options',
summary='获取装填菌种选项接口',
description='获取全部启用菌种的业务选项',
response_model=DataResponseModel[list[BusinessOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
)
async def get_business_filling_strain_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await FillingService.get_strain_options_services(query_db, current_user)
return ResponseUtil.success(data=result)
@filling_controller.get(
'/scan/{qr_token}',
summary='扫描待装填托盘接口',
description='按托盘当前仓库数据范围判断空托盘是否可装填',
response_model=DataResponseModel[PalletScanResponseModel],
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
)
async def scan_business_filling_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 FillingService.scan_filling_pallet_services(
query_db,
qr_token,
current_user,
)
return ResponseUtil.success(data=result)
@filling_controller.get(
'/{filling_id}',
summary='获取托盘装填记录详情接口',
description='按仓库数据范围获取指定托盘装填记录',
response_model=DataResponseModel[FillingModel],
dependencies=[UserInterfaceAuthDependency('business:filling:query')],
)
async def query_detail_business_filling(
request: Request,
filling_id: Annotated[int, Path(gt=0, description='装填记录ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await FillingService.filling_detail_services(query_db, filling_id, current_user)
return ResponseUtil.success(data=result)
@filling_controller.post(
'',
summary='单托盘装填接口',
description='在仓库内即时确认单个空托盘装填',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:filling:add')],
)
@Log(title='托盘装填', business_type=BusinessType.INSERT)
async def add_business_filling(
request: Request,
add_filling: FillingCreateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await FillingService.fill_pallet_services(query_db, add_filling, current_user)
return ResponseUtil.success(msg=result.message)
@@ -60,6 +60,15 @@ class OperationDao:
scoped_warehouse_ids = sorted(set(warehouse_ids))
return [BizInboundOrder.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
@staticmethod
def _filling_scope_conditions(
warehouse_ids: list[int] | None,
) -> list[ColumnElement[bool]]:
if warehouse_ids is None:
return []
scoped_warehouse_ids = sorted(set(warehouse_ids))
return [BizFillingRecord.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()]
@staticmethod
async def _paginate(
db: AsyncSession,
@@ -97,8 +106,9 @@ class OperationDao:
model: type[Any],
key_column: InstrumentedAttribute,
idempotency_key: str,
*extra_conditions: ColumnElement[bool],
) -> Any | None:
result = await db.execute(select(model).where(key_column == idempotency_key))
result = await db.execute(select(model).where(key_column == idempotency_key, *extra_conditions))
return result.scalar_one_or_none()
@staticmethod
@@ -300,12 +310,14 @@ class OperationDao:
cls,
db: AsyncSession,
idempotency_key: str,
warehouse_ids: list[int] | None = None,
) -> BizFillingRecord | None:
return await cls._get_by_key(
db,
BizFillingRecord,
BizFillingRecord.idempotency_key,
idempotency_key,
*cls._filling_scope_conditions(warehouse_ids),
)
@classmethod
@@ -437,8 +449,9 @@ class OperationDao:
db: AsyncSession,
query_object: FillingPageQueryModel,
is_page: bool = True,
warehouse_ids: list[int] | None = None,
) -> PageModel | list[dict[str, Any]]:
conditions = []
conditions = cls._filling_scope_conditions(warehouse_ids)
if query_object.filling_no:
conditions.append(BizFillingRecord.filling_no.like(f'%{query_object.filling_no}%'))
if query_object.warehouse_id:
@@ -470,12 +483,14 @@ class OperationDao:
cls,
db: AsyncSession,
filling_id: int,
warehouse_ids: list[int] | None = None,
) -> BizFillingRecord | None:
return await cls._get_by_id(
db,
BizFillingRecord,
BizFillingRecord.filling_id,
filling_id,
*cls._filling_scope_conditions(warehouse_ids),
)
@classmethod
@@ -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