Files
ShiErFeng/shierfeng-fastapi-backend/module_business/service/shipment_service.py
T
yujiangandClaude Opus 5.5 3050ccdc4e Feat: 后端新增基础资料与业务管理只读汇总接口
为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情
返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。

- 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转,
  托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级
  资料选项接口;Excel 导入支持 preview 只校验不写入
- 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和
  菌种构成;详情明细补充托盘号、菌种、批次
- 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary
- 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道;
  新增 /summary、/site-options
- 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、
  /batches、/batches/detail
- 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号;
  新增 /site-options、/site-board 以及 usage-fifo 的 /queue
- 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致
- 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:10 -07:00

1020 lines
42 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.dao.shipment_label_dao import ShipmentLabelDao
from module_business.dao.shipment_summary_dao import ShipmentSummaryDao
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 (
BizFillingRecord,
BizPalletEvent,
BizPalletInventory,
BizShipmentItem,
BizShipmentOrder,
)
from module_business.entity.vo.master_vo import SitePageQueryModel, WarehousePageQueryModel
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
ConfirmCommandModel,
PalletScanResponseModel,
ShipmentCreateModel,
ShipmentListItemModel,
ShipmentModel,
ShipmentPageQueryModel,
ShipmentScanQueryModel,
ShipmentUpdateModel,
)
from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService
class ShipmentService:
"""多托盘整托发货服务层。"""
@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)
@classmethod
def _raise_shipment_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='无权访问该托盘')
@classmethod
async def _scopes(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
) -> tuple[list[int] | None, list[int] | None]:
if cls._is_admin(current_user):
return None, None
user_id, _ = cls._current_identity(current_user)
warehouse_ids = sorted(set(await AccessDao.list_user_warehouse_ids(query_db, user_id)))
site_ids = sorted(set(await AccessDao.list_user_site_ids(query_db, user_id)))
return warehouse_ids, site_ids
@staticmethod
async def _validate_active_route(
query_db: AsyncSession,
source_warehouse_id: int,
target_site_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> tuple[Any, Any]:
await AccessService.assert_warehouse_access(query_db, current_user, source_warehouse_id)
warehouse = await MasterDao.get_warehouse(query_db, source_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='来源仓库已停用')
await AccessService.assert_site_access(query_db, current_user, target_site_id)
site = await MasterDao.get_site(query_db, target_site_id, test_scope)
if site is None:
raise ServiceException(message='目标现场不存在')
assert_record_in_scope(site, test_scope, '现场')
if site.status != '0':
raise ServiceException(message='目标现场已停用')
return warehouse, site
@staticmethod
def _validate_pallet_ids(pallet_ids: list[int]) -> list[int]:
if not pallet_ids or any(type(pallet_id) is not int or pallet_id <= 0 for pallet_id in pallet_ids):
raise ServiceException(message='托盘ID必须为正整数')
if len(pallet_ids) != len(set(pallet_ids)):
raise ServiceException(message='托盘ID不能重复')
return sorted(pallet_ids)
@staticmethod
def _validate_locked_pallet(
pallet: BizPallet,
inventory: BizPalletInventory,
filling: BizFillingRecord,
source_warehouse_id: int,
) -> None:
if pallet.pallet_id != inventory.pallet_id:
raise ServiceException(message='托盘资产与库存不一致')
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许发货')
if (
inventory.flow_state != PalletFlowState.FILLED_IN_WAREHOUSE.value
or inventory.warehouse_id != source_warehouse_id
or inventory.site_id is not None
or inventory.current_line_id is not None
or inventory.active_filling_id is 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'
):
raise ServiceException(message='托盘活动装填周期无效')
@classmethod
async def _lock_and_validate_pallets(
cls,
query_db: AsyncSession,
pallet_ids: list[int],
source_warehouse_id: int,
test_scope: TestBatchScope,
) -> tuple[list[BizPalletInventory], list[BizPallet], list[BizFillingRecord]]:
ordered_ids = cls._validate_pallet_ids(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, '托盘')
if (
len(inventories) != len(ordered_ids)
or len(pallets) != len(ordered_ids)
or {inventory.pallet_id for inventory in inventories} != set(ordered_ids)
or {pallet.pallet_id for pallet in pallets} != set(ordered_ids)
):
raise ServiceException(message='存在不存在或库存缺失的托盘')
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
pallet_by_id = {pallet.pallet_id: pallet for pallet in pallets}
active_filling_ids = []
for pallet_id in ordered_ids:
active_filling_id = inventory_by_id[pallet_id].active_filling_id
if active_filling_id is None:
raise ServiceException(message='托盘不存在活动装填周期')
active_filling_ids.append(active_filling_id)
fillings = []
for filling_id in sorted(set(active_filling_ids)):
filling = await OperationDao.get_active_filling_for_update(query_db, filling_id, test_scope)
if filling is None:
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
assert_record_in_scope(filling, test_scope, '活动装填')
fillings.append(filling)
if len(fillings) != len(ordered_ids):
raise ServiceException(message='托盘活动装填周期不完整')
filling_by_id = {filling.filling_id: filling for filling in fillings}
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]
ordered_fillings = [filling_by_id[inventory.active_filling_id] for inventory in ordered_inventories]
for pallet, inventory, filling in zip(
ordered_pallets,
ordered_inventories,
ordered_fillings,
strict=True,
):
cls._validate_locked_pallet(pallet, inventory, filling, source_warehouse_id)
return ordered_inventories, ordered_pallets, ordered_fillings
@staticmethod
def _build_items(
shipment_id: int,
pallet_ids: list[int],
user_name: str,
current_time: datetime,
test_batch_id: int | None,
) -> list[BizShipmentItem]:
return [
BizShipmentItem(
shipment_id=shipment_id,
pallet_id=pallet_id,
active_filling_id=None,
package_qty=None,
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)
@staticmethod
def _build_confirm_request_hash(shipment_id: int) -> str:
payload = json.dumps(
{'shipment_id': shipment_id},
sort_keys=True,
separators=(',', ':'),
).encode()
return hashlib.sha256(payload).hexdigest()
@classmethod
async def get_shipment_list_services(
cls,
query_db: AsyncSession,
query_object: ShipmentPageQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[ShipmentListItemModel]:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
if (
warehouse_ids is not None
and query_object.source_warehouse_id is not None
and query_object.source_warehouse_id not in warehouse_ids
):
raise PermissionException(data='', message='无权访问该仓库')
if (
site_ids is not None
and query_object.target_site_id is not None
and query_object.target_site_id not in site_ids
):
raise PermissionException(data='', message='无权访问该客户现场')
if warehouse_ids == [] or 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_shipment_list(
query_db,
query_object,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
scope=test_scope,
is_page=is_page,
)
rows = result.rows if isinstance(result, PageModel) else result
records = [ShipmentModel.model_validate(record) for record in rows]
# 仅对本页已按数据范围筛出的单据补充名称与汇总
summaries = await ShipmentSummaryDao.get_list_summaries(
query_db,
[record.shipment_id for record in records],
test_scope,
)
models = [
ShipmentListItemModel.model_validate({**record.model_dump(), **summaries.get(record.shipment_id, {})})
for record in records
]
if isinstance(result, PageModel):
return result.model_copy(update={'rows': models})
return models
@classmethod
async def shipment_detail_services(
cls,
query_db: AsyncSession,
shipment_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> ShipmentModel:
warehouse_ids, site_ids = await cls._scopes(query_db, current_user)
shipment = await OperationDao.get_shipment_by_id(
query_db,
shipment_id,
test_scope,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if shipment is None:
cls._raise_shipment_not_accessible(current_user)
model = ShipmentModel.model_validate(shipment)
identities = await cls._item_identities(query_db, shipment, test_scope)
if not identities:
return model
return model.model_copy(
update={
'items': [
item.model_copy(update=identities.get(item.shipment_item_id, {}))
for item in model.items
]
}
)
@staticmethod
async def _item_identities(
query_db: AsyncSession,
shipment: BizShipmentOrder,
test_scope: TestBatchScope,
) -> dict[int, dict[str, str | None]]:
"""为详情明细补充托盘编号与菌种批次,只读展示,不参与任何写入或数量计算。"""
identities: dict[int, dict[str, str | None]] = {}
for item, pallet, filling, _quantity in await ShipmentLabelDao.get_label_sources(query_db, shipment, test_scope):
identities[item.shipment_item_id] = {
'pallet_code': getattr(pallet, 'pallet_code', None),
'strain_code': getattr(filling, 'strain_code_snapshot', None),
'strain_name': getattr(filling, 'strain_name_snapshot', None),
'batch_no': getattr(filling, 'batch_no', None),
}
return identities
@classmethod
async def get_warehouse_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[BusinessOptionModel]:
warehouse_ids, _ = await cls._scopes(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 get_site_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[BusinessOptionModel]:
_, site_ids = await cls._scopes(query_db, current_user)
allowed_ids = None if site_ids is None else set(site_ids)
sites = await MasterDao.list_site(
query_db,
SitePageQueryModel(status='0'),
test_scope,
False,
)
options = []
for site in sites:
site_id = cls._record_value(site, 'site_id')
if cls._record_value(site, 'status') != '0':
continue
if allowed_ids is not None and site_id not in allowed_ids:
continue
options.append(
BusinessOptionModel(
id=site_id,
code=cls._record_value(site, 'site_code'),
name=cls._record_value(site, 'site_name'),
)
)
return sorted(options, key=lambda option: option.id)
@staticmethod
def _validate_scan_pallet(
pallet: BizPallet,
inventory: BizPalletInventory,
filling: BizFillingRecord,
source_warehouse_id: int,
) -> None:
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许发货')
if (
inventory.flow_state != PalletFlowState.FILLED_IN_WAREHOUSE.value
or inventory.warehouse_id != source_warehouse_id
or inventory.site_id is not None
or inventory.current_line_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='托盘不符合整托发货条件')
if (
filling.filling_id != inventory.active_filling_id
or filling.pallet_id != pallet.pallet_id
or filling.active_slot != 'ACTIVE'
):
raise ServiceException(message='托盘活动装填周期无效')
@classmethod
async def scan_shipment_pallet_services(
cls,
query_db: AsyncSession,
qr_token: str,
query_object: ShipmentScanQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PalletScanResponseModel:
await AccessService.assert_warehouse_access(
query_db,
current_user,
query_object.source_warehouse_id,
)
warehouse = await MasterDao.get_warehouse(query_db, query_object.source_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='来源仓库已停用')
await AccessService.assert_site_access(
query_db,
current_user,
query_object.target_site_id,
)
site = await MasterDao.get_site(query_db, query_object.target_site_id, test_scope)
if site is None:
raise ServiceException(message='目标现场不存在')
assert_record_in_scope(site, test_scope, '现场')
if site.status != '0':
raise ServiceException(message='目标现场已停用')
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, test_scope)
if pallet is None:
cls._raise_pallet_not_accessible(current_user)
assert_record_in_scope(pallet, test_scope, '托盘')
inventory = await MasterDao.get_pallet_inventory(query_db, pallet.pallet_id, test_scope)
if inventory is None:
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
assert_record_in_scope(inventory, test_scope, '库存')
if inventory.pallet_id != pallet.pallet_id or inventory.warehouse_id != query_object.source_warehouse_id:
cls._raise_pallet_not_accessible(current_user, '托盘不在来源仓库')
if inventory.site_id is not None or inventory.current_line_id is not None:
cls._raise_pallet_not_accessible(current_user, '托盘已在客户现场')
if inventory.active_filling_id is None:
raise ServiceException(message='托盘不存在活动装填周期')
filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope)
if filling is None:
raise ServiceException(message='托盘活动装填周期不存在')
assert_record_in_scope(filling, test_scope, '活动装填')
cls._validate_scan_pallet(
pallet,
inventory,
filling,
query_object.source_warehouse_id,
)
return PalletScanResponseModel(
pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code,
flow_state=PalletFlowState.FILLED_IN_WAREHOUSE,
warehouse_id=inventory.warehouse_id,
site_id=None,
current_line_id=None,
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,
production_date=filling.production_date,
expiry_date=filling.expiry_date,
package_spec=filling.package_spec,
capacity_packages=pallet.capacity_packages,
remaining_packages=inventory.remaining_packages,
allowed_actions=['shipment'],
)
@classmethod
async def add_shipment_services(
cls,
query_db: AsyncSession,
page_object: ShipmentCreateModel,
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_route(
query_db,
page_object.source_warehouse_id,
page_object.target_site_id,
current_user,
test_scope,
)
ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids)
await cls._lock_and_validate_pallets(
query_db,
ordered_ids,
page_object.source_warehouse_id,
test_scope,
)
await cls._lock_and_authorize_routes(
query_db,
current_user,
warehouse_ids={page_object.source_warehouse_id},
site_ids={page_object.target_site_id},
hidden_warehouse_ids=set(),
hidden_site_ids=set(),
require_active=True,
test_scope=test_scope,
)
current_time = datetime.now()
shipment = BizShipmentOrder(
shipment_no=f'SH-{secrets.token_hex(16)}',
source_warehouse_id=page_object.source_warehouse_id,
target_site_id=page_object.target_site_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,
)
shipment = await OperationDao.add_shipment(query_db, shipment)
await OperationDao.replace_shipment_items(
query_db,
shipment.shipment_id,
cls._build_items(shipment.shipment_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_shipment_services(
cls,
query_db: AsyncSession,
page_object: ShipmentUpdateModel,
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, site_ids = await cls._scopes(query_db, current_user)
shipment = await OperationDao.get_shipment_for_update(
query_db,
page_object.shipment_id,
test_scope,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if shipment is None:
cls._raise_shipment_not_accessible(current_user)
if shipment.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='已确认发货单不允许编辑')
if any(item.active_filling_id is not None or item.package_qty is not None for item in shipment.items):
raise ServiceException(message='发货草稿明细快照异常')
await cls._validate_active_route(
query_db,
page_object.source_warehouse_id,
page_object.target_site_id,
current_user,
test_scope,
)
ordered_ids = cls._validate_pallet_ids(page_object.pallet_ids)
await cls._lock_and_validate_pallets(
query_db,
ordered_ids,
page_object.source_warehouse_id,
test_scope,
)
await cls._lock_and_authorize_routes(
query_db,
current_user,
warehouse_ids={
shipment.source_warehouse_id,
page_object.source_warehouse_id,
},
site_ids={
shipment.target_site_id,
page_object.target_site_id,
},
hidden_warehouse_ids={shipment.source_warehouse_id},
hidden_site_ids={shipment.target_site_id},
require_active=True,
test_scope=test_scope,
)
current_time = datetime.now()
shipment.source_warehouse_id = page_object.source_warehouse_id
shipment.target_site_id = page_object.target_site_id
shipment.update_by = user_name
shipment.update_time = current_time
await OperationDao.update_shipment(query_db, shipment)
await OperationDao.replace_shipment_items(
query_db,
shipment.shipment_id,
cls._build_items(shipment.shipment_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_shipment_services(
cls,
query_db: AsyncSession,
shipment_ids: list[int],
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
raise ServiceWarning(message='不支持单条物理删除,请通过测试数据批次清理')
@staticmethod
def _is_exact_confirmed(
shipment: BizShipmentOrder,
shipment_id: int,
idempotency_key: str,
request_hash: str,
) -> bool:
return (
shipment.shipment_id == shipment_id
and shipment.status == DocumentStatus.CONFIRMED.value
and shipment.confirm_idempotency_key == idempotency_key
and shipment.confirm_request_hash == request_hash
)
@classmethod
async def _lock_and_authorize_routes( # noqa: PLR0912
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
*,
warehouse_ids: set[int],
site_ids: set[int],
hidden_warehouse_ids: set[int],
hidden_site_ids: set[int],
require_active: bool,
test_scope: TestBatchScope,
) -> None:
warehouses: dict[int, Any] = {}
warehouse_permission_errors: dict[int, PermissionException] = {}
for warehouse_id in sorted(warehouse_ids):
warehouse = await MasterDao.get_warehouse(
query_db,
warehouse_id,
test_scope,
for_update=True,
)
warehouses[warehouse_id] = warehouse
if warehouse is not None:
assert_record_in_scope(warehouse, test_scope, '仓库')
try:
await AccessService.assert_warehouse_access(
query_db,
current_user,
warehouse_id,
for_update=True,
)
except PermissionException as exc:
warehouse_permission_errors[warehouse_id] = exc
sites: dict[int, Any] = {}
site_permission_errors: dict[int, PermissionException] = {}
for site_id in sorted(site_ids):
site = await MasterDao.get_site(
query_db,
site_id,
test_scope,
for_update=True,
)
sites[site_id] = site
if site is not None:
assert_record_in_scope(site, test_scope, '现场')
try:
await AccessService.assert_site_access(
query_db,
current_user,
site_id,
for_update=True,
)
except PermissionException as exc:
site_permission_errors[site_id] = exc
if hidden_warehouse_ids & warehouse_permission_errors.keys() or (
hidden_site_ids & site_permission_errors.keys()
):
cls._raise_shipment_not_accessible(current_user)
explicit_permission_errors = [
warehouse_permission_errors[warehouse_id] for warehouse_id in sorted(warehouse_permission_errors)
]
explicit_permission_errors.extend(site_permission_errors[site_id] for site_id in sorted(site_permission_errors))
if explicit_permission_errors:
raise explicit_permission_errors[0]
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='来源仓库已停用')
for site_id in sorted(sites):
site = sites[site_id]
if site is None:
raise ServiceException(message='目标现场不存在')
if require_active and site.status != '0':
raise ServiceException(message='目标现场已停用')
@classmethod
async def _lock_and_reauthorize_route(
cls,
query_db: AsyncSession,
shipment: BizShipmentOrder,
current_user: CurrentUserModel,
*,
test_scope: TestBatchScope,
require_active: bool = True,
) -> None:
await cls._lock_and_authorize_routes(
query_db,
current_user,
warehouse_ids={shipment.source_warehouse_id},
site_ids={shipment.target_site_id},
hidden_warehouse_ids={shipment.source_warehouse_id},
hidden_site_ids={shipment.target_site_id},
require_active=require_active,
test_scope=test_scope,
)
@classmethod
async def _recover_confirm_integrity_error(
cls,
query_db: AsyncSession,
shipment_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, site_ids = await cls._scopes(query_db, current_user)
current = await OperationDao.get_shipment_by_id(
query_db,
shipment_id,
test_scope,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if current is None:
cls._raise_shipment_not_accessible(current_user)
assert_record_in_scope(current, test_scope, '发货单')
recovered = await OperationDao.get_shipment_by_confirm_idempotency_key(
query_db,
idempotency_key,
test_scope,
warehouse_ids=[current.source_warehouse_id],
site_ids=[current.target_site_id],
)
if recovered is None:
raise ServiceException(message='确认发货失败,幂等冲突')
assert_record_in_scope(recovered, test_scope, '发货单')
if not cls._is_exact_confirmed(
recovered,
shipment_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认发货失败,幂等键已被其他请求使用')
locked_recovered = await OperationDao.get_shipment_for_update(
query_db,
shipment_id,
test_scope,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if locked_recovered is None:
cls._raise_shipment_not_accessible(current_user)
assert_record_in_scope(locked_recovered, test_scope, '发货单')
if not cls._is_exact_confirmed(
locked_recovered,
shipment_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认发货失败,幂等键已被其他请求使用')
await cls._lock_and_reauthorize_route(
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_shipment_services( # noqa: PLR0912, PLR0915
cls,
query_db: AsyncSession,
shipment_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, site_ids = await cls._scopes(query_db, current_user)
shipment = await OperationDao.get_shipment_by_id(
query_db,
shipment_id,
test_scope,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if shipment is None:
cls._raise_shipment_not_accessible(current_user)
assert_record_in_scope(shipment, test_scope, '发货单')
request_hash = cls._build_confirm_request_hash(shipment_id)
idempotency_key = page_object.idempotency_key
existing = await OperationDao.get_shipment_by_confirm_idempotency_key(
query_db,
idempotency_key,
test_scope,
warehouse_ids=[shipment.source_warehouse_id],
site_ids=[shipment.target_site_id],
)
if existing is not None and not cls._is_exact_confirmed(
existing,
shipment_id,
idempotency_key,
request_hash,
):
raise ServiceException(message='确认发货失败,幂等键已被其他请求使用')
locked_shipment = await OperationDao.get_shipment_for_update(
query_db,
shipment_id,
test_scope,
warehouse_ids=warehouse_ids,
site_ids=site_ids,
)
if locked_shipment is None:
cls._raise_shipment_not_accessible(current_user)
assert_record_in_scope(locked_shipment, test_scope, '发货单')
if locked_shipment.status == DocumentStatus.CONFIRMED.value:
if cls._is_exact_confirmed(
locked_shipment,
shipment_id,
idempotency_key,
request_hash,
):
await cls._lock_and_reauthorize_route(
query_db,
locked_shipment,
current_user,
test_scope=test_scope,
require_active=False,
)
await query_db.rollback()
return cls._success('确认成功')
raise ServiceException(message='发货单已确认,幂等信息不匹配')
if locked_shipment.status != DocumentStatus.DRAFT.value:
raise ServiceException(message='当前发货单状态不允许确认')
if existing is not None:
raise ServiceException(message='确认发货失败,并发状态不一致')
if locked_shipment.confirm_idempotency_key is not None or locked_shipment.confirm_request_hash is not None:
raise ServiceException(message='发货单确认信息不完整')
items = list(locked_shipment.items)
pallet_ids = [item.pallet_id for item in items]
ordered_ids = cls._validate_pallet_ids(pallet_ids)
if any(item.active_filling_id is not None or item.package_qty is not None for item in items):
raise ServiceException(message='发货草稿明细快照异常')
inventories, _, _ = await cls._lock_and_validate_pallets(
query_db,
ordered_ids,
locked_shipment.source_warehouse_id,
test_scope,
)
await cls._lock_and_reauthorize_route(query_db, locked_shipment, current_user, test_scope=test_scope)
for inventory in inventories:
PalletStateMachine.ensure_transition(
PalletFlowState(inventory.flow_state),
PalletFlowState.IN_TRANSIT,
)
inventory_by_id = {inventory.pallet_id: inventory for inventory in inventories}
current_time = datetime.now()
locked_shipment.status = DocumentStatus.CONFIRMED.value
locked_shipment.receipt_state = 'PENDING'
locked_shipment.confirm_idempotency_key = idempotency_key
locked_shipment.confirm_request_hash = request_hash
locked_shipment.confirmed_by = user_id
locked_shipment.confirmed_at = current_time
locked_shipment.update_by = user_name
locked_shipment.update_time = current_time
for item in items:
inventory = inventory_by_id[item.pallet_id]
item.active_filling_id = inventory.active_filling_id
item.package_qty = inventory.remaining_packages
await OperationDao.update_shipment(query_db, locked_shipment)
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.IN_TRANSIT.value
inventory.site_arrived_at = None
inventory.warehouse_id = None
inventory.site_id = locked_shipment.target_site_id
inventory.current_line_id = None
inventory.version += 1
inventory.updated_at = current_time
event = BizPalletEvent(
pallet_id=inventory.pallet_id,
event_type=PalletEventType.SHIPMENT_CONFIRMED.value,
source_type='SHIPMENT',
source_id=shipment_id,
before_state=before_state,
after_state=PalletFlowState.IN_TRANSIT.value,
before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id,
before_line_id=before_line_id,
after_warehouse_id=None,
after_site_id=locked_shipment.target_site_id,
after_line_id=None,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=inventory.remaining_packages,
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,
shipment_id,
page_object.idempotency_key,
cls._build_confirm_request_hash(shipment_id),
current_user,
test_scope,
)
except Exception:
await query_db.rollback()
raise