265 lines
13 KiB
Python
265 lines
13 KiB
Python
"""接收方按原发货单核对收货;不重新编托盘号、批号或装填记录。"""
|
|
|
|
import hashlib
|
|
import json
|
|
from datetime import datetime
|
|
from typing import 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.dao.shipment_receipt_dao import ShipmentReceiptDao
|
|
from module_business.domain.enums import 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 BizPalletEvent, BizPalletInventory, BizShipmentItem, BizShipmentOrder
|
|
from module_business.entity.vo.operation_vo import ShipmentModel
|
|
from module_business.entity.vo.shipment_receipt_vo import (
|
|
ShipmentReceiptCommandModel,
|
|
ShipmentReceiptItemModel,
|
|
ShipmentReceiptModel,
|
|
ShipmentReceiptQueryModel,
|
|
)
|
|
from module_business.service.access_service import AccessService
|
|
from module_business.service.test_batch_access_service import TestBatchAccessService
|
|
|
|
|
|
class ShipmentReceiptService:
|
|
@staticmethod
|
|
async def _site_scope(db: AsyncSession, user: CurrentUserModel) -> list[int] | None:
|
|
if user.user is None or user.user.user_id is None:
|
|
raise PermissionException(data='', message='当前用户信息无效')
|
|
if user.user.admin:
|
|
return None
|
|
return sorted(set(await AccessDao.list_user_site_ids(db, user.user.user_id)))
|
|
|
|
@staticmethod
|
|
def _missing() -> NoReturn:
|
|
raise PermissionException(data='', message='无权访问该收货单或单据不存在')
|
|
|
|
@classmethod
|
|
async def list_services(
|
|
cls, db: AsyncSession, query: ShipmentReceiptQueryModel, user: CurrentUserModel, scope: TestBatchScope
|
|
) -> PageModel:
|
|
site_ids = await cls._site_scope(db, user)
|
|
if query.target_site_id and site_ids is not None and query.target_site_id not in site_ids:
|
|
cls._missing()
|
|
result = await ShipmentReceiptDao.list_orders(db, query, scope, site_ids)
|
|
return result.model_copy(update={'rows': [ShipmentModel.model_validate(row) for row in result.rows]})
|
|
|
|
@classmethod
|
|
async def _order(
|
|
cls, db: AsyncSession, shipment_id: int, user: CurrentUserModel, scope: TestBatchScope
|
|
) -> BizShipmentOrder:
|
|
site_ids = await cls._site_scope(db, user)
|
|
order = await OperationDao.get_shipment_by_id(db, shipment_id, scope, warehouse_ids=None, site_ids=site_ids)
|
|
if order is None:
|
|
cls._missing()
|
|
assert_record_in_scope(order, scope, '发货单')
|
|
await AccessService.assert_site_access(db, user, order.target_site_id)
|
|
if order.status != 'CONFIRMED':
|
|
raise ServiceException(message='发货草稿尚未出库,不能核对收货')
|
|
return order
|
|
|
|
@staticmethod
|
|
async def _item_model(db: AsyncSession, item: BizShipmentItem, scope: TestBatchScope) -> ShipmentReceiptItemModel:
|
|
assert_record_in_scope(item, scope, '发货明细')
|
|
pallet = await MasterDao.get_pallet(db, item.pallet_id, scope)
|
|
if pallet is None or item.active_filling_id is None or item.package_qty is None:
|
|
raise ServiceException(message='发货物料快照不完整')
|
|
assert_record_in_scope(pallet, scope, '托盘')
|
|
filling = await OperationDao.get_filling_by_id(db, item.active_filling_id, scope)
|
|
if filling is None:
|
|
raise ServiceException(message='原发货装填记录不存在')
|
|
assert_record_in_scope(filling, scope, '装填记录')
|
|
if filling.pallet_id != item.pallet_id:
|
|
raise ServiceException(message='原发货托盘与批次不一致')
|
|
return ShipmentReceiptItemModel.model_validate(
|
|
{
|
|
'shipment_item_id': item.shipment_item_id,
|
|
'shipment_id': item.shipment_id,
|
|
'pallet_id': item.pallet_id,
|
|
'active_filling_id': item.active_filling_id,
|
|
'package_qty': item.package_qty,
|
|
'pallet_code': pallet.pallet_code,
|
|
'qr_token': pallet.qr_token,
|
|
'strain_name': filling.strain_name_snapshot,
|
|
'batch_no': filling.batch_no,
|
|
'production_date': filling.production_date,
|
|
'expiry_date': filling.expiry_date,
|
|
}
|
|
)
|
|
|
|
@classmethod
|
|
async def detail_services(
|
|
cls, db: AsyncSession, shipment_id: int, user: CurrentUserModel, scope: TestBatchScope
|
|
) -> ShipmentReceiptModel:
|
|
order = await cls._order(db, shipment_id, user, scope)
|
|
data = ShipmentModel.model_validate(order).model_dump()
|
|
data['items'] = [await cls._item_model(db, item, scope) for item in order.items]
|
|
return ShipmentReceiptModel.model_validate(data)
|
|
|
|
@staticmethod
|
|
def _validate_inventory(
|
|
order: BizShipmentOrder,
|
|
item: BizShipmentItem,
|
|
inventory: BizPalletInventory,
|
|
pallet: BizPallet,
|
|
scope: TestBatchScope,
|
|
) -> None:
|
|
assert_record_in_scope(item, scope, '发货明细')
|
|
assert_record_in_scope(inventory, scope, '库存')
|
|
assert_record_in_scope(pallet, scope, '托盘')
|
|
if (
|
|
item.shipment_id != order.shipment_id
|
|
or item.pallet_id != pallet.pallet_id
|
|
or inventory.pallet_id != pallet.pallet_id
|
|
or pallet.asset_status != '0'
|
|
or inventory.flow_state != PalletFlowState.IN_TRANSIT.value
|
|
or inventory.site_id != order.target_site_id
|
|
or inventory.warehouse_id is not None
|
|
or inventory.tunnel_id is not None
|
|
or inventory.trace_no is not None
|
|
or inventory.current_line_id is not None
|
|
or inventory.active_filling_id != item.active_filling_id
|
|
or item.active_filling_id is None
|
|
or type(item.package_qty) is not int
|
|
or item.package_qty <= 0
|
|
or inventory.remaining_packages != item.package_qty
|
|
or type(inventory.version) is not int
|
|
or inventory.version < 0
|
|
):
|
|
raise ServiceException(message='待收货托盘状态、批次或包数与原发货单不一致')
|
|
|
|
@classmethod
|
|
async def scan_services(
|
|
cls, db: AsyncSession, shipment_id: int, identifier: str, user: CurrentUserModel, scope: TestBatchScope
|
|
) -> ShipmentReceiptItemModel:
|
|
order = await cls._order(db, shipment_id, user, scope)
|
|
if order.receipt_state != 'PENDING':
|
|
raise ServiceException(message='该发货单无需再次核对收货')
|
|
pallet = await InventoryDao.get_pallet_by_qr_token(db, identifier, scope)
|
|
if pallet is None:
|
|
raise ServiceException(message='扫码托盘不属于该发货单')
|
|
items = [item for item in order.items if item.pallet_id == pallet.pallet_id]
|
|
if len(items) != 1:
|
|
raise ServiceException(message='扫码托盘不属于该发货单')
|
|
inventory = await MasterDao.get_pallet_inventory(db, pallet.pallet_id, scope)
|
|
if inventory is None:
|
|
raise ServiceException(message='托盘库存不存在')
|
|
cls._validate_inventory(order, items[0], inventory, pallet, scope)
|
|
return await cls._item_model(db, items[0], scope)
|
|
|
|
@staticmethod
|
|
def _request_hash(shipment_id: int, command: ShipmentReceiptCommandModel) -> str:
|
|
payload = {'shipment_id': shipment_id, 'scanned_pallet_ids': sorted(command.scanned_pallet_ids)}
|
|
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
|
|
|
|
@classmethod
|
|
async def confirm_services( # noqa: PLR0912, PLR0915
|
|
cls,
|
|
db: AsyncSession,
|
|
shipment_id: int,
|
|
command: ShipmentReceiptCommandModel,
|
|
user: CurrentUserModel,
|
|
scope: TestBatchScope,
|
|
) -> CrudResponseModel:
|
|
try:
|
|
await TestBatchAccessService.lock_active_batch(db, scope)
|
|
site_ids = await cls._site_scope(db, user)
|
|
order = await OperationDao.get_shipment_for_update(
|
|
db, shipment_id, scope, warehouse_ids=None, site_ids=site_ids
|
|
)
|
|
if order is None:
|
|
cls._missing()
|
|
assert_record_in_scope(order, scope, '发货单')
|
|
# 已有发货查询权限不能替代收货方的目标现场权限;锁后再次核验。
|
|
await AccessService.assert_site_access(db, user, order.target_site_id, for_update=True)
|
|
request_hash = cls._request_hash(shipment_id, command)
|
|
if order.status != 'CONFIRMED':
|
|
raise ServiceException(message='仅已出库发货单可核对收货')
|
|
if order.receipt_state == 'RECEIVED':
|
|
if (
|
|
order.receipt_idempotency_key != command.idempotency_key
|
|
or order.receipt_request_hash != request_hash
|
|
):
|
|
raise ServiceException(message='发货单已收货,幂等信息不匹配')
|
|
await db.rollback()
|
|
return CrudResponseModel(is_success=True, message='收货核对成功', result=shipment_id)
|
|
if order.receipt_state != 'PENDING':
|
|
raise ServiceException(message='历史发货数据不重复确认收货')
|
|
items = list(order.items)
|
|
expected_ids = sorted(item.pallet_id for item in items)
|
|
if (
|
|
not expected_ids
|
|
or len(expected_ids) != len(set(expected_ids))
|
|
or expected_ids != sorted(command.scanned_pallet_ids)
|
|
):
|
|
raise ServiceException(message='核对托盘集合与原发货明细不一致')
|
|
inventories = await InventoryDao.get_inventories_for_update(db, expected_ids, scope)
|
|
pallets = await InventoryDao.get_pallets_by_ids(db, expected_ids, scope)
|
|
if (
|
|
sorted(i.pallet_id for i in inventories) != expected_ids
|
|
or sorted(p.pallet_id for p in pallets) != expected_ids
|
|
):
|
|
raise ServiceException(message='待收货托盘或库存缺失')
|
|
site = await MasterDao.get_site(db, order.target_site_id, scope, for_update=True)
|
|
if site is None:
|
|
raise ServiceException(message='收货现场不存在')
|
|
assert_record_in_scope(site, scope, '收货现场')
|
|
if site.status != '0':
|
|
raise ServiceException(message='收货现场已停用')
|
|
by_inventory = {i.pallet_id: i for i in inventories}
|
|
by_pallet = {p.pallet_id: p for p in pallets}
|
|
for item in items:
|
|
cls._validate_inventory(order, item, by_inventory[item.pallet_id], by_pallet[item.pallet_id], scope)
|
|
PalletStateMachine.ensure_transition(PalletFlowState.IN_TRANSIT, PalletFlowState.AT_SITE_UNUSED)
|
|
|
|
now = datetime.now()
|
|
order.receipt_state = 'RECEIVED'
|
|
order.received_at = now
|
|
order.received_by = user.user.user_id
|
|
order.receipt_idempotency_key = command.idempotency_key
|
|
order.receipt_request_hash = request_hash
|
|
order.update_by = user.user.user_name
|
|
order.update_time = now
|
|
for inventory in inventories:
|
|
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
|
|
inventory.site_arrived_at = now
|
|
inventory.version += 1
|
|
inventory.updated_at = now
|
|
event = BizPalletEvent(
|
|
pallet_id=inventory.pallet_id,
|
|
event_type=PalletEventType.SHIPMENT_RECEIVED.value,
|
|
source_type='SHIPMENT',
|
|
source_id=shipment_id,
|
|
before_state=PalletFlowState.IN_TRANSIT.value,
|
|
after_state=inventory.flow_state,
|
|
before_site_id=inventory.site_id,
|
|
after_site_id=inventory.site_id,
|
|
before_remaining_packages=inventory.remaining_packages,
|
|
after_remaining_packages=inventory.remaining_packages,
|
|
operator_id=user.user.user_id,
|
|
occurred_at=now,
|
|
idempotency_key=command.idempotency_key,
|
|
request_hash=request_hash,
|
|
test_batch_id=scope.batch_id,
|
|
)
|
|
await InventoryDao.add_event(db, inventory, event, scope)
|
|
await db.commit()
|
|
return CrudResponseModel(is_success=True, message='收货核对成功', result=shipment_id)
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ServiceException(message='收货确认冲突,请刷新核对单后重试') from None
|
|
except Exception:
|
|
await db.rollback()
|
|
raise
|