"""接收方按原发货单核对收货;不重新编托盘号、批号或装填记录。""" 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