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

725 lines
28 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, ServiceWarning
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 DocumentStatus, PalletEventType, PalletFlowState
from module_business.domain.pallet_state_machine import PalletStateMachine
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import (
BizInboundItem,
BizInboundOrder,
BizPalletEvent,
BizPalletInventory,
)
from module_business.entity.vo.master_vo import WarehousePageQueryModel
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
ConfirmCommandModel,
InboundCreateModel,
InboundModel,
InboundPageQueryModel,
InboundScanQueryModel,
InboundUpdateModel,
PalletScanResponseModel,
)
from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService
class InboundService:
"""空托盘首次入库服务层。"""
@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_confirm_request_hash(inbound_id: int) -> str:
payload = json.dumps(
{'inbound_id': inbound_id},
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_inbound_not_accessible(
cls,
current_user: CurrentUserModel,
) -> NoReturn:
if cls._is_admin(current_user):
raise ServiceException(message='入库单不存在')
raise PermissionException(data='', message='无权访问该入库单')
@staticmethod
async def _validate_active_warehouse(
query_db: AsyncSession,
warehouse_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> Any:
await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id)
warehouse = await MasterDao.get_warehouse(query_db, warehouse_id, test_scope)
if warehouse is None:
raise ServiceException(message='目标仓库不存在')
assert_record_in_scope(warehouse, test_scope, '仓库')
if warehouse.status != '0':
raise ServiceException(message='目标仓库已停用')
return warehouse
@classmethod
async def _lock_and_authorize_warehouses(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
*,
warehouse_ids: set[int],
hidden_warehouse_ids: set[int],
require_active: bool,
test_scope: TestBatchScope,
) -> None:
"""锁定仓库及授权映射,并基于锁后最新状态重新校验写权限。"""
warehouses: dict[int, Any] = {}
permission_errors: dict[int, PermissionException] = {}
for warehouse_id in sorted(warehouse_ids):
warehouses[warehouse_id] = await MasterDao.get_warehouse(
query_db,
warehouse_id,
test_scope,
for_update=True,
)
if warehouses[warehouse_id] is not None:
assert_record_in_scope(warehouses[warehouse_id], test_scope, '仓库')
try:
await AccessService.assert_warehouse_access(
query_db,
current_user,
warehouse_id,
for_update=True,
)
except PermissionException as exc:
permission_errors[warehouse_id] = exc
if hidden_warehouse_ids & permission_errors.keys():
cls._raise_inbound_not_accessible(current_user)
if permission_errors:
raise permission_errors[min(permission_errors)]
for warehouse_id in sorted(warehouses):
warehouse = warehouses[warehouse_id]
if warehouse is None:
raise ServiceException(message='目标仓库不存在')
if require_active and warehouse.status != '0':
raise ServiceException(message='目标仓库已停用')
@classmethod
async def _lock_and_reauthorize_warehouse(
cls,
query_db: AsyncSession,
inbound: BizInboundOrder,
current_user: CurrentUserModel,
*,
test_scope: TestBatchScope,
require_active: bool = True,
) -> None:
await cls._lock_and_authorize_warehouses(
query_db,
current_user,
warehouse_ids={inbound.warehouse_id},
hidden_warehouse_ids={inbound.warehouse_id},
require_active=require_active,
test_scope=test_scope,
)
@staticmethod
def _validate_pallet_pair(pallet: BizPallet, inventory: BizPalletInventory) -> None:
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许首次入库')
if (
inventory.flow_state != PalletFlowState.UNSTOCKED.value
or inventory.warehouse_id is not None
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 not isinstance(inventory.version, int) or inventory.version < 0:
raise ServiceException(message='托盘库存版本无效')
@classmethod
async def _lock_and_validate_pallets(
cls,
query_db: AsyncSession,
pallet_ids: list[int],
test_scope: TestBatchScope,
) -> tuple[list[BizPalletInventory], list[BizPallet]]:
ordered_ids = sorted(pallet_ids)
inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids, test_scope)
pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids, test_scope)
for inventory in inventories:
assert_record_in_scope(inventory, test_scope, '库存')
for pallet in pallets:
assert_record_in_scope(pallet, test_scope, '托盘')
expected_ids = set(ordered_ids)
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets}
if set(inventory_by_id) != expected_ids or set(pallet_by_id) != expected_ids:
raise ServiceException(message='存在不存在或库存缺失的托盘')
ordered_inventories = [inventory_by_id[pallet_id] for pallet_id in ordered_ids]
ordered_pallets = [pallet_by_id[pallet_id] for pallet_id in ordered_ids]
for pallet, inventory in zip(ordered_pallets, ordered_inventories, strict=True):
cls._validate_pallet_pair(pallet, inventory)
return ordered_inventories, ordered_pallets
@staticmethod
def _build_items(
inbound_id: int,
pallet_ids: list[int],
user_name: str,
current_time: datetime,
test_batch_id: int | None,
) -> list[BizInboundItem]:
return [
BizInboundItem(
inbound_id=inbound_id,
pallet_id=pallet_id,
create_by=user_name,
create_time=current_time,
test_batch_id=test_batch_id,
)
for pallet_id in sorted(pallet_ids)
]
@staticmethod
def _success(message: str) -> CrudResponseModel:
return CrudResponseModel(is_success=True, message=message)
@classmethod
async def get_inbound_list_services(
cls,
query_db: AsyncSession,
query_object: InboundPageQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[InboundModel]:
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_inbound_list(
query_db,
query_object,
warehouse_ids=warehouse_ids,
scope=test_scope,
is_page=is_page,
)
rows = result.rows if isinstance(result, PageModel) else result
models = [InboundModel.model_validate(record) for record in rows]
if isinstance(result, PageModel):
return result.model_copy(update={'rows': models})
return models
@classmethod
async def inbound_detail_services(
cls,
query_db: AsyncSession,
inbound_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> InboundModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
inbound = await OperationDao.get_inbound_by_id(
query_db,
inbound_id,
test_scope,
warehouse_ids=warehouse_ids,
)
if inbound is None:
cls._raise_inbound_not_accessible(current_user)
return InboundModel.model_validate(inbound)
@classmethod
async def get_warehouse_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[BusinessOptionModel]:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
allowed_ids = None if warehouse_ids is None else set(warehouse_ids)
warehouses = await MasterDao.list_warehouse(
query_db,
WarehousePageQueryModel(status='0'),
test_scope,
False,
)
options = []
for warehouse in warehouses:
warehouse_id = cls._record_value(warehouse, 'warehouse_id')
if cls._record_value(warehouse, 'status') != '0':
continue
if allowed_ids is not None and warehouse_id not in allowed_ids:
continue
options.append(
BusinessOptionModel(
id=warehouse_id,
code=cls._record_value(warehouse, 'warehouse_code'),
name=cls._record_value(warehouse, 'warehouse_name'),
)
)
return sorted(options, key=lambda option: option.id)
@classmethod
async def scan_inbound_pallet_services(
cls,
query_db: AsyncSession,
qr_token: str,
query_object: InboundScanQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PalletScanResponseModel:
await AccessService.assert_warehouse_access(
query_db,
current_user,
query_object.warehouse_id,
)
warehouse = await MasterDao.get_warehouse(query_db, query_object.warehouse_id, test_scope)
if warehouse is None:
raise ServiceException(message='目标仓库不存在')
assert_record_in_scope(warehouse, test_scope, '仓库')
if warehouse.status != '0':
raise ServiceException(message='目标仓库已停用')
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope)
if pallet is None:
raise ServiceException(message='托盘不存在')
assert_record_in_scope(pallet, test_scope, '托盘')
inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope)
if inventory is None:
raise ServiceException(message='托盘库存不存在')
assert_record_in_scope(inventory, test_scope, '库存')
cls._validate_pallet_pair(pallet, inventory)
return PalletScanResponseModel(
pallet_id=pallet.pallet_id,
pallet_code=pallet.pallet_code,
flow_state=PalletFlowState.UNSTOCKED,
warehouse_id=None,
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=['inbound'],
)
@classmethod
async def add_inbound_services(
cls,
query_db: AsyncSession,
page_object: InboundCreateModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
_, user_name = cls._current_identity(current_user)
await cls._validate_active_warehouse(
query_db,
page_object.warehouse_id,
current_user,
test_scope,
)
ordered_ids = sorted(page_object.pallet_ids)
await cls._lock_and_validate_pallets(query_db, ordered_ids, test_scope)
await cls._lock_and_authorize_warehouses(
query_db,
current_user,
warehouse_ids={page_object.warehouse_id},
hidden_warehouse_ids=set(),
require_active=True,
test_scope=test_scope,
)
current_time = datetime.now()
inbound = BizInboundOrder(
inbound_no=f'IN-{secrets.token_hex(16)}',
warehouse_id=page_object.warehouse_id,
status=DocumentStatus.DRAFT.value,
create_by=user_name,
create_time=current_time,
update_by=user_name,
update_time=current_time,
test_batch_id=test_scope.batch_id,
)
inbound = await OperationDao.add_inbound(query_db, inbound)
await OperationDao.replace_inbound_items(
query_db,
inbound.inbound_id,
cls._build_items(inbound.inbound_id, ordered_ids, user_name, current_time, test_scope.batch_id),
)
await query_db.commit()
return cls._success('新增成功')
except IntegrityError:
await query_db.rollback()
raise ServiceException(message='新增入库单失败,入库单号或明细已存在') from None
except Exception:
await query_db.rollback()
raise
@classmethod
async def edit_inbound_services(
cls,
query_db: AsyncSession,
page_object: InboundUpdateModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
_, user_name = cls._current_identity(current_user)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
inbound = await OperationDao.get_inbound_for_update(
query_db,
page_object.inbound_id,
test_scope,
warehouse_ids=warehouse_ids,
)
if inbound is None:
cls._raise_inbound_not_accessible(current_user)
if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许编辑')
await cls._validate_active_warehouse(
query_db,
page_object.warehouse_id,
current_user,
test_scope,
)
ordered_ids = sorted(page_object.pallet_ids)
await cls._lock_and_validate_pallets(query_db, ordered_ids, test_scope)
await cls._lock_and_authorize_warehouses(
query_db,
current_user,
warehouse_ids={inbound.warehouse_id, page_object.warehouse_id},
hidden_warehouse_ids={inbound.warehouse_id},
require_active=True,
test_scope=test_scope,
)
current_time = datetime.now()
inbound.warehouse_id = page_object.warehouse_id
inbound.update_by = user_name
inbound.update_time = current_time
await OperationDao.update_inbound(query_db, inbound)
await OperationDao.replace_inbound_items(
query_db,
inbound.inbound_id,
cls._build_items(inbound.inbound_id, ordered_ids, user_name, current_time, test_scope.batch_id),
)
await query_db.commit()
return cls._success('修改成功')
except IntegrityError:
await query_db.rollback()
raise ServiceException(message='修改入库单失败,单据或明细状态已变化') from None
except Exception:
await query_db.rollback()
raise
@classmethod
async def delete_inbound_services(
cls,
query_db: AsyncSession,
inbound_ids: list[int],
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
raise ServiceWarning(message='不支持单条物理删除,请通过测试数据批次清理')
@staticmethod
def _is_exact_confirmed(
inbound: BizInboundOrder,
inbound_id: int,
idempotency_key: str,
request_hash: str,
) -> bool:
return (
inbound.inbound_id == inbound_id
and inbound.status == DocumentStatus.CONFIRMED.value
and inbound.confirm_idempotency_key == idempotency_key
and inbound.confirm_request_hash == request_hash
)
@classmethod
async def _recover_confirm_integrity_error(
cls,
query_db: AsyncSession,
inbound_id: int,
idempotency_key: str,
request_hash: str,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
current = await OperationDao.get_inbound_by_id(
query_db,
inbound_id,
test_scope,
warehouse_ids=warehouse_ids,
)
if current is None:
cls._raise_inbound_not_accessible(current_user)
recovered = await OperationDao.get_inbound_by_confirm_idempotency_key(
query_db,
idempotency_key,
test_scope,
warehouse_ids=[current.warehouse_id],
)
if recovered is None:
raise ServiceException(message='确认入库失败,幂等冲突')
if not cls._is_exact_confirmed(
recovered,
inbound_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用')
locked_recovered = await OperationDao.get_inbound_for_update(
query_db,
inbound_id,
test_scope,
warehouse_ids=warehouse_ids,
)
if locked_recovered is None:
cls._raise_inbound_not_accessible(current_user)
if not cls._is_exact_confirmed(
locked_recovered,
inbound_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用')
await cls._lock_and_reauthorize_warehouse(
query_db,
locked_recovered,
current_user,
test_scope=test_scope,
require_active=False,
)
return cls._success('确认成功')
finally:
await query_db.rollback()
@classmethod
async def confirm_inbound_services( # noqa: PLR0912, PLR0915
cls,
query_db: AsyncSession,
inbound_id: int,
page_object: ConfirmCommandModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
user_id, user_name = cls._current_identity(current_user)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
inbound = await OperationDao.get_inbound_by_id(
query_db,
inbound_id,
test_scope,
warehouse_ids=warehouse_ids,
)
if inbound is None:
cls._raise_inbound_not_accessible(current_user)
request_hash = cls._build_confirm_request_hash(inbound_id)
idempotency_key = page_object.idempotency_key
existing = await OperationDao.get_inbound_by_confirm_idempotency_key(
query_db,
idempotency_key,
test_scope,
warehouse_ids=[inbound.warehouse_id],
)
if existing is not None:
await AccessService.assert_warehouse_access(
query_db,
current_user,
existing.warehouse_id,
)
if not cls._is_exact_confirmed(
existing,
inbound_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用')
locked_inbound = await OperationDao.get_inbound_for_update(
query_db,
inbound_id,
test_scope,
warehouse_ids=warehouse_ids,
)
if locked_inbound is None:
cls._raise_inbound_not_accessible(current_user)
if locked_inbound.status == DocumentStatus.CONFIRMED.value:
if cls._is_exact_confirmed(
locked_inbound,
inbound_id,
idempotency_key,
request_hash,
):
await cls._lock_and_reauthorize_warehouse(
query_db,
locked_inbound,
current_user,
test_scope=test_scope,
require_active=False,
)
await query_db.rollback()
return cls._success('确认成功')
raise ServiceException(message='入库单已确认,幂等信息不匹配')
if locked_inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='当前入库单状态不允许确认')
if existing is not None:
raise ServiceException(message='确认入库失败,并发状态不一致')
if locked_inbound.confirm_idempotency_key is not None or locked_inbound.confirm_request_hash is not None:
raise ServiceException(message='入库单确认信息不完整')
pallet_ids = [item.pallet_id for item in locked_inbound.items]
if not pallet_ids:
raise ServiceException(message='入库单至少需要一个托盘')
if len(pallet_ids) != len(set(pallet_ids)):
raise ServiceException(message='入库单托盘不能重复')
ordered_ids = sorted(pallet_ids)
inventories, _ = await cls._lock_and_validate_pallets(query_db, ordered_ids, test_scope)
await cls._lock_and_reauthorize_warehouse(
query_db,
locked_inbound,
current_user,
test_scope=test_scope,
)
for inventory in inventories:
PalletStateMachine.ensure_transition(
PalletFlowState(inventory.flow_state),
PalletFlowState.EMPTY_IN_WAREHOUSE,
)
current_time = datetime.now()
locked_inbound.status = DocumentStatus.CONFIRMED.value
locked_inbound.confirm_idempotency_key = idempotency_key
locked_inbound.confirm_request_hash = request_hash
locked_inbound.confirmed_by = user_id
locked_inbound.confirmed_at = current_time
locked_inbound.update_by = user_name
locked_inbound.update_time = current_time
await OperationDao.update_inbound(query_db, locked_inbound)
for inventory in inventories:
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.flow_state = PalletFlowState.EMPTY_IN_WAREHOUSE.value
inventory.warehouse_id = locked_inbound.warehouse_id
inventory.site_id = None
inventory.current_line_id = None
inventory.active_filling_id = None
inventory.remaining_packages = 0
inventory.version += 1
inventory.updated_at = current_time
event = BizPalletEvent(
pallet_id=inventory.pallet_id,
event_type=PalletEventType.INBOUND_CONFIRMED.value,
source_type='INBOUND',
source_id=inbound_id,
before_state=before_state,
after_state=PalletFlowState.EMPTY_IN_WAREHOUSE.value,
before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id,
before_line_id=before_line_id,
after_warehouse_id=locked_inbound.warehouse_id,
after_site_id=None,
after_line_id=None,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=0,
operator_id=user_id,
occurred_at=current_time,
idempotency_key=idempotency_key,
request_hash=request_hash,
test_batch_id=test_scope.batch_id,
)
await InventoryDao.add_event(query_db, inventory, event, test_scope)
await query_db.commit()
return cls._success('确认成功')
except IntegrityError:
await query_db.rollback()
return await cls._recover_confirm_integrity_error(
query_db,
inbound_id,
page_object.idempotency_key,
cls._build_confirm_request_hash(inbound_id),
current_user,
test_scope,
)
except Exception:
await query_db.rollback()
raise