功能:实现空托盘首次入库

This commit is contained in:
焦龙言
2026-07-26 15:19:25 +08:00
parent 317a0fbf68
commit 6f22cf0282
5 changed files with 2106 additions and 10 deletions
@@ -0,0 +1,197 @@
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,
ConfirmCommandModel,
InboundCreateModel,
InboundModel,
InboundPageQueryModel,
InboundScanQueryModel,
InboundUpdateModel,
PalletScanResponseModel,
)
from module_business.service.inbound_service import InboundService
from utils.response_util import ResponseUtil
inbound_controller = APIRouterPro(
prefix='/business/inbound',
tags=['业务管理-仓库入库'],
dependencies=[PreAuthDependency()],
)
@inbound_controller.get(
'/list',
summary='获取首次入库单分页列表接口',
description='按当前用户仓库数据范围获取首次入库单分页列表',
response_model=PageResponseModel[InboundModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:list')],
)
async def get_business_inbound_list(
request: Request,
inbound_page_query: Annotated[InboundPageQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await InboundService.get_inbound_list_services(
query_db,
inbound_page_query,
current_user,
is_page=True,
)
return ResponseUtil.success(model_content=result)
@inbound_controller.get(
'/warehouse-options',
summary='获取首次入库仓库选项接口',
description='仅返回当前用户有权访问的启用仓库',
response_model=DataResponseModel[list[BusinessOptionModel]],
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
)
async def get_business_inbound_warehouse_options(
request: Request,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await InboundService.get_warehouse_options_services(query_db, current_user)
return ResponseUtil.success(data=result)
@inbound_controller.get(
'/scan/{qr_token}',
summary='扫描首次入库托盘接口',
description='查询托盘是否可加入所选仓库的首次入库单',
response_model=DataResponseModel[PalletScanResponseModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
)
async def scan_business_inbound_pallet(
request: Request,
qr_token: Annotated[
str,
Path(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识'),
],
inbound_scan_query: Annotated[InboundScanQueryModel, Query()],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await InboundService.scan_inbound_pallet_services(
query_db,
qr_token,
inbound_scan_query,
current_user,
)
return ResponseUtil.success(data=result)
@inbound_controller.get(
'/{inbound_id}',
summary='获取首次入库单详情接口',
description='按仓库数据范围获取指定首次入库单详情',
response_model=DataResponseModel[InboundModel],
dependencies=[UserInterfaceAuthDependency('business:inbound:query')],
)
async def query_detail_business_inbound(
request: Request,
inbound_id: Annotated[int, Path(gt=0, description='入库单ID')],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await InboundService.inbound_detail_services(query_db, inbound_id, current_user)
return ResponseUtil.success(data=result)
@inbound_controller.post(
'',
summary='新增首次入库草稿接口',
description='新增空托盘首次入库草稿',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:inbound:add')],
)
@Log(title='仓库入库', business_type=BusinessType.INSERT)
async def add_business_inbound(
request: Request,
add_inbound: InboundCreateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await InboundService.add_inbound_services(query_db, add_inbound, current_user)
return ResponseUtil.success(msg=result.message)
@inbound_controller.put(
'',
summary='编辑首次入库草稿接口',
description='编辑尚未确认的首次入库草稿',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:inbound:edit')],
)
@Log(title='仓库入库', business_type=BusinessType.UPDATE)
async def edit_business_inbound(
request: Request,
edit_inbound: InboundUpdateModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await InboundService.edit_inbound_services(query_db, edit_inbound, current_user)
return ResponseUtil.success(msg=result.message)
@inbound_controller.delete(
'/{inbound_ids}',
summary='删除首次入库草稿接口',
description='原子删除一个或多个尚未确认的首次入库草稿',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:inbound:remove')],
)
@Log(title='仓库入库', business_type=BusinessType.DELETE)
async def delete_business_inbound(
request: Request,
inbound_ids: Annotated[
str,
Path(pattern=r'^[1-9]\d*(,[1-9]\d*)*$', description='需要删除的入库单ID'),
],
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await InboundService.delete_inbound_services(
query_db,
[int(inbound_id) for inbound_id in inbound_ids.split(',')],
current_user,
)
return ResponseUtil.success(msg=result.message)
@inbound_controller.put(
'/{inbound_id}/confirm',
summary='确认首次入库单接口',
description='原子确认整张首次入库单并追加托盘流转事件',
response_model=ResponseBaseModel,
dependencies=[UserInterfaceAuthDependency('business:inbound:confirm')],
)
@Log(title='仓库入库', business_type=BusinessType.UPDATE)
async def confirm_business_inbound(
request: Request,
inbound_id: Annotated[int, Path(gt=0, description='入库单ID')],
confirm_command: ConfirmCommandModel,
query_db: Annotated[AsyncSession, DBSessionDependency()],
current_user: Annotated[CurrentUserModel, CurrentUserDependency()],
) -> Response:
result = await InboundService.confirm_inbound_services(
query_db,
inbound_id,
confirm_command,
current_user,
)
return ResponseUtil.success(msg=result.message)
@@ -1,7 +1,7 @@
from datetime import datetime, time, timedelta
from typing import Any
from sqlalchemy import ColumnElement, Select, delete, select, update
from sqlalchemy import ColumnElement, Select, delete, false, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
@@ -186,12 +186,28 @@ class OperationDao:
db: AsyncSession,
inbound_id: int,
) -> BizInboundOrder | None:
return await cls._get_for_update(
inbound = await cls._get_for_update(
db,
BizInboundOrder,
BizInboundOrder.inbound_id,
inbound_id,
)
if inbound is None:
return None
# 父单锁定后用 locking read 获取最新明细,避免 MySQL RR 继续读取确认前的旧快照。
item_result = await db.execute(
select(BizInboundItem)
.where(BizInboundItem.inbound_id == inbound_id)
.order_by(BizInboundItem.pallet_id, BizInboundItem.inbound_item_id)
.with_for_update()
.execution_options(populate_existing=True)
)
inbound.items = sorted(
item_result.scalars().all(),
key=lambda item: (item.pallet_id, item.inbound_item_id),
)
return inbound
@classmethod
async def get_shipment_for_update(
@@ -318,8 +334,14 @@ class OperationDao:
db: AsyncSession,
query_object: InboundPageQueryModel,
is_page: bool = True,
warehouse_ids: list[int] | None = None,
) -> PageModel | list[dict[str, Any]]:
conditions = []
if warehouse_ids is not None:
scoped_warehouse_ids = sorted(set(warehouse_ids))
conditions.append(
BizInboundOrder.warehouse_id.in_(scoped_warehouse_ids) if scoped_warehouse_ids else false()
)
if query_object.inbound_no:
conditions.append(BizInboundOrder.inbound_no.like(f'%{query_object.inbound_no}%'))
if query_object.warehouse_id:
@@ -0,0 +1,579 @@
import hashlib
import json
import secrets
from datetime import datetime
from typing import Any
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 DocumentStatus, PalletEventType, PalletFlowState
from module_business.domain.pallet_state_machine import PalletStateMachine
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
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)))
@staticmethod
async def _validate_active_warehouse(
query_db: AsyncSession,
warehouse_id: int,
current_user: CurrentUserModel,
) -> Any:
warehouse = await MasterDao.get_warehouse(query_db, warehouse_id)
if warehouse is None:
raise ServiceException(message='目标仓库不存在')
if warehouse.status != '0':
raise ServiceException(message='目标仓库已停用')
await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id)
return warehouse
@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],
) -> tuple[list[BizPalletInventory], list[BizPallet]]:
ordered_ids = sorted(pallet_ids)
inventories = await InventoryDao.get_inventories_for_update(query_db, ordered_ids)
pallets = await InventoryDao.get_pallets_by_ids(query_db, ordered_ids)
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,
) -> list[BizInboundItem]:
return [
BizInboundItem(
inbound_id=inbound_id,
pallet_id=pallet_id,
create_by=user_name,
create_time=current_time,
)
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,
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,
is_page,
warehouse_ids=warehouse_ids,
)
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,
) -> InboundModel:
inbound = await OperationDao.get_inbound_by_id(query_db, inbound_id)
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(query_db, current_user, inbound.warehouse_id)
return InboundModel.model_validate(inbound)
@classmethod
async def get_warehouse_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
) -> 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'),
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,
) -> 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)
if warehouse is None:
raise ServiceException(message='目标仓库不存在')
if warehouse.status != '0':
raise ServiceException(message='目标仓库已停用')
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token)
if pallet is None:
raise ServiceException(message='托盘不存在')
inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id)
if inventory is None:
raise ServiceException(message='托盘库存不存在')
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,
) -> CrudResponseModel:
try:
_, user_name = cls._current_identity(current_user)
await cls._validate_active_warehouse(
query_db,
page_object.warehouse_id,
current_user,
)
ordered_ids = sorted(page_object.pallet_ids)
await cls._lock_and_validate_pallets(query_db, ordered_ids)
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,
)
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),
)
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,
) -> CrudResponseModel:
try:
_, user_name = cls._current_identity(current_user)
inbound = await OperationDao.get_inbound_for_update(query_db, page_object.inbound_id)
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
query_db,
current_user,
inbound.warehouse_id,
)
if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许编辑')
await cls._validate_active_warehouse(
query_db,
page_object.warehouse_id,
current_user,
)
ordered_ids = sorted(page_object.pallet_ids)
await cls._lock_and_validate_pallets(query_db, ordered_ids)
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),
)
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,
) -> CrudResponseModel:
try:
if not inbound_ids or any(inbound_id <= 0 for inbound_id in inbound_ids):
raise ServiceException(message='入库单ID必须为正整数')
if len(inbound_ids) != len(set(inbound_ids)):
raise ServiceException(message='入库单ID不能重复')
ordered_ids = sorted(inbound_ids)
for inbound_id in ordered_ids:
inbound = await OperationDao.get_inbound_for_update(query_db, inbound_id)
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
query_db,
current_user,
inbound.warehouse_id,
)
if inbound.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认入库单不允许删除')
for inbound_id in ordered_ids:
await OperationDao.delete_inbound(query_db, inbound_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
@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,
) -> CrudResponseModel:
try:
recovered = await OperationDao.get_inbound_by_confirm_idempotency_key(
query_db,
idempotency_key,
)
if recovered is None:
raise ServiceException(message='确认入库失败,幂等冲突')
await AccessService.assert_warehouse_access(
query_db,
current_user,
recovered.warehouse_id,
)
if not cls._is_exact_confirmed(
recovered,
inbound_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认入库失败,幂等键已被其他请求使用')
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,
) -> CrudResponseModel:
try:
user_id, user_name = cls._current_identity(current_user)
inbound = await OperationDao.get_inbound_by_id(query_db, inbound_id)
if inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
query_db,
current_user,
inbound.warehouse_id,
)
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,
)
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)
if locked_inbound is None:
raise ServiceException(message='入库单不存在')
await AccessService.assert_warehouse_access(
query_db,
current_user,
locked_inbound.warehouse_id,
)
if locked_inbound.status == DocumentStatus.CONFIRMED.value:
if cls._is_exact_confirmed(
locked_inbound,
inbound_id,
idempotency_key,
request_hash,
):
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)
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,
)
await InventoryDao.add_event(query_db, inventory, event)
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,
)
except Exception:
await query_db.rollback()
raise
File diff suppressed because it is too large Load Diff
@@ -156,8 +156,15 @@ class TestDaoPublicContract(unittest.TestCase):
signature = inspect.signature(getattr(OperationDao, method_name))
self.assertEqual(parameter_names, tuple(signature.parameters))
inbound_list_signature = inspect.signature(OperationDao.get_inbound_list)
self.assertEqual(
('db', 'query_object', 'is_page', 'warehouse_ids'),
tuple(inbound_list_signature.parameters),
)
self.assertIs(True, inbound_list_signature.parameters['is_page'].default)
self.assertIsNone(inbound_list_signature.parameters['warehouse_ids'].default)
for method_name in (
'get_inbound_list',
'get_filling_list',
'get_shipment_list',
'get_usage_list',
@@ -360,13 +367,6 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
async def test_for_update_queries_target_primary_keys_and_lock_rows(self) -> None:
cases = (
(
OperationDao.get_inbound_for_update,
BizInboundOrder,
'inbound_id',
11,
(),
),
(
OperationDao.get_shipment_for_update,
BizShipmentOrder,
@@ -410,6 +410,42 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
for fragment in extra_fragments:
self.assertIn(fragment, sql)
async def test_inbound_for_update_locks_parent_then_current_items(self) -> None:
inbound = SimpleNamespace(inbound_id=11)
items = [
SimpleNamespace(inbound_item_id=2, inbound_id=11, pallet_id=2),
SimpleNamespace(inbound_item_id=5, inbound_id=11, pallet_id=5),
]
inbound_result = MagicMock()
inbound_result.scalar_one_or_none.return_value = inbound
item_result = MagicMock()
item_result.scalars.return_value.all.return_value = items
self.db.execute.side_effect = [inbound_result, item_result]
actual = await OperationDao.get_inbound_for_update(self.db, inbound_id=11)
self.assertIs(inbound, actual)
self.assertEqual(items, actual.items)
self.assertEqual(2, self.db.execute.await_count)
parent_statement = self.db.execute.await_args_list[0].args[0]
item_statement = self.db.execute.await_args_list[1].args[0]
self.assertTrue(parent_statement.get_final_froms()[0].compare(BizInboundOrder.__table__))
self.assertTrue(item_statement.get_final_froms()[0].compare(BizInboundItem.__table__))
self.assertIs(True, parent_statement.get_execution_options().get('populate_existing'))
self.assertIs(True, item_statement.get_execution_options().get('populate_existing'))
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
parent_sql = compile_sql(parent_statement, dialect)
item_sql = compile_sql(item_statement, dialect)
self.assertIn('biz_inbound_order.inbound_id = 11', parent_sql)
self.assertIn('FOR UPDATE', parent_sql)
self.assertIn('biz_inbound_item.inbound_id = 11', item_sql)
self.assertIn(
'ORDER BY biz_inbound_item.pallet_id, biz_inbound_item.inbound_item_id',
item_sql,
)
self.assertIn('FOR UPDATE', item_sql)
async def test_idempotency_queries_use_the_exact_declared_column(self) -> None:
cases = (
(
@@ -588,6 +624,58 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
for fragment in fragments:
self.assertIn(fragment, sql)
async def test_inbound_list_applies_warehouse_scope_inside_paginated_statement(self) -> None:
query = InboundPageQueryModel(page_num=2, page_size=7)
marker = object()
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate:
actual = await OperationDao.get_inbound_list(
self.db,
query,
is_page=True,
warehouse_ids=[5, 2, 5],
)
self.assertIs(marker, actual)
statement = paginate.await_args.args[1]
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
sql = compile_sql(statement, dialect)
self.assertIn('biz_inbound_order.warehouse_id IN (2, 5)', sql)
self.assertIn('ORDER BY biz_inbound_order.inbound_id', sql)
async def test_inbound_list_empty_scope_compiles_to_false_for_both_dialects(self) -> None:
query = InboundPageQueryModel(page_num=4, page_size=6)
marker = object()
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate:
actual = await OperationDao.get_inbound_list(
self.db,
query,
is_page=True,
warehouse_ids=[],
)
self.assertIs(marker, actual)
statement = paginate.await_args.args[1]
mysql_sql = compile_sql(statement, mysql.dialect())
postgresql_sql = compile_sql(statement, postgresql.dialect())
self.assertIn('WHERE false = 1', mysql_sql)
self.assertIn('WHERE false', postgresql_sql)
self.assertEqual((query.page_num, query.page_size, True), paginate.await_args.args[2:])
async def test_inbound_list_none_scope_has_no_authorization_predicate(self) -> None:
query = InboundPageQueryModel()
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
await OperationDao.get_inbound_list(
self.db,
query,
is_page=True,
warehouse_ids=None,
)
sql = compile_sql(paginate.await_args.args[1])
self.assertNotIn('warehouse_id IN', sql)
self.assertNotIn('WHERE false', sql)
def test_time_conditions_include_fractional_last_second_with_exclusive_next_day(self) -> None:
lower, upper = OperationDao._time_conditions(
BizUsageRecord.occurred_at,