更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
@@ -0,0 +1,264 @@
"""接收方按原发货单核对收货;不重新编托盘号、批号或装填记录。"""
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