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

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
@@ -63,6 +63,8 @@ class FillingService:
'warehouse_id': page_object.warehouse_id,
'strain_id': page_object.strain_id,
'batch_no': page_object.batch_no.strip(),
'production_date': page_object.production_date.isoformat(),
'expiry_date': page_object.expiry_date.isoformat(),
'package_spec': page_object.package_spec.strip(),
'initial_packages': page_object.initial_packages,
},
@@ -147,10 +149,11 @@ class FillingService:
@staticmethod
def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None:
if type(initial_packages) is not int or initial_packages <= 0:
raise ServiceException(message='装填包数必须为正整数')
if initial_packages > pallet.capacity_packages:
raise ServiceException(message='装填包数不能超过托盘容量')
standard_packages = 100
if type(initial_packages) is not int or initial_packages != standard_packages:
raise ServiceException(message='新装填包数固定为 100 包')
if pallet.capacity_packages != standard_packages:
raise ServiceException(message='历史非 100 包托盘不允许新增装填')
@staticmethod
def _validate_active_strain(strain: BizStrain | None) -> BizStrain:
@@ -276,10 +279,11 @@ class FillingService:
pallet,
inventory,
inventory.warehouse_id,
qr_token,
pallet.qr_token,
)
return PalletScanResponseModel(
pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code,
flow_state=PalletFlowState.EMPTY_IN_WAREHOUSE,
warehouse_id=inventory.warehouse_id,
@@ -472,6 +476,8 @@ class FillingService:
strain_code_snapshot=strain.strain_code,
strain_name_snapshot=strain.strain_name,
batch_no=page_object.batch_no.strip(),
production_date=page_object.production_date,
expiry_date=page_object.expiry_date,
package_spec=page_object.package_spec.strip(),
initial_packages=page_object.initial_packages,
active_slot='ACTIVE',
@@ -349,6 +349,7 @@ class InboundService:
cls._validate_pallet_pair(pallet, inventory)
return PalletScanResponseModel(
pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code,
flow_state=PalletFlowState.UNSTOCKED,
warehouse_id=None,
@@ -16,6 +16,7 @@ from module_business.entity.vo.master_vo import (
DeleteLineModel,
DeleteSiteModel,
DeleteStrainModel,
DeleteTunnelModel,
DeleteWarehouseModel,
LineModel,
LinePageQueryModel,
@@ -23,12 +24,14 @@ from module_business.entity.vo.master_vo import (
SitePageQueryModel,
StrainModel,
StrainPageQueryModel,
TunnelModel,
TunnelPageQueryModel,
WarehouseModel,
WarehousePageQueryModel,
)
from module_business.service.test_batch_access_service import TestBatchAccessService
MasterResource = Literal['strain', 'customer', 'site', 'line', 'warehouse']
MasterResource = Literal['strain', 'customer', 'site', 'line', 'tunnel', 'warehouse']
_RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = {
@@ -60,6 +63,13 @@ _RESOURCE_CONFIG: dict[MasterResource, dict[str, Any]] = {
'ids_field': 'line_ids',
'vo': LineModel,
},
'tunnel': {
'label': '隧道',
'id_field': 'tunnel_id',
'code_field': 'tunnel_code',
'ids_field': 'tunnel_ids',
'vo': TunnelModel,
},
'warehouse': {
'label': '仓库',
'id_field': 'warehouse_id',
@@ -105,6 +115,13 @@ class MasterService:
assert_record_in_scope(site, scope, '目标客户现场')
if site.status != '0':
raise ServiceException(message='目标客户现场已停用')
elif resource == 'tunnel':
site = await MasterDao.get_site(db, page_object.site_id, scope)
if site is None:
raise ServiceException(message='目标客户现场不存在')
assert_record_in_scope(site, scope, '目标客户现场')
if site.status != '0':
raise ServiceException(message='目标客户现场已停用')
@staticmethod
async def _find_duplicate(
@@ -124,6 +141,12 @@ class MasterService:
page_object.line_code,
page_object.site_id,
)
if resource == 'tunnel':
return await MasterDao.find_tunnel_by_code(
db,
page_object.tunnel_code,
page_object.site_id,
)
code_field = _RESOURCE_CONFIG[resource]['code_field']
return await getattr(MasterDao, f'find_{resource}_by_code')(
db,
@@ -413,6 +436,48 @@ class MasterService:
) -> NoReturn:
return await cls.reject_physical_delete()
@classmethod
async def list_tunnel(
cls,
db: AsyncSession,
query_object: TunnelPageQueryModel,
scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[TunnelModel]:
return await cls._list(db, 'tunnel', query_object, scope, is_page)
@classmethod
async def detail_tunnel(cls, db: AsyncSession, tunnel_id: int, scope: TestBatchScope) -> TunnelModel:
return await cls._detail(db, 'tunnel', tunnel_id, scope)
@classmethod
async def add_tunnel(
cls,
db: AsyncSession,
tunnel: TunnelModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._add(db, 'tunnel', tunnel, current_user, scope)
@classmethod
async def edit_tunnel(
cls,
db: AsyncSession,
tunnel: TunnelModel,
current_user: CurrentUserModel,
scope: TestBatchScope,
) -> CrudResponseModel:
return await cls._edit(db, 'tunnel', tunnel, current_user, scope)
@classmethod
async def remove_tunnel(
cls,
db: AsyncSession,
value: int | DeleteTunnelModel,
) -> NoReturn:
return await cls.reject_physical_delete()
@classmethod
async def list_warehouse(
cls,
@@ -116,6 +116,8 @@ class PalletService:
) -> CrudResponseModel:
try:
await cls._prepare_test_write(db, current_user, scope)
if pallet.capacity_packages != 100:
raise ServiceException(message='新托盘容量固定为 100 包')
if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None:
raise ServiceException(message='托盘编号已存在')
@@ -162,7 +164,11 @@ class PalletService:
) -> CrudResponseModel:
try:
await cls._prepare_test_write(db, current_user, scope)
_, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope)
current_pallet, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id, scope)
if current_pallet.capacity_packages == 100 and pallet.capacity_packages != 100:
raise ServiceException(message='新规格托盘容量固定为 100 包')
if current_pallet.capacity_packages != pallet.capacity_packages:
raise ServiceException(message='历史托盘容量不允许修改')
if pallet.capacity_packages < inventory.remaining_packages:
raise ServiceException(message='托盘容量不能小于当前剩余包数')
await MasterDao.update_pallet(
@@ -19,7 +19,7 @@ from module_business.dao.operation_dao import OperationDao
from module_business.domain.enums import PalletEventType, PalletFlowState, ReturnStatus
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.master_do import BizPallet, BizTunnel
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletEvent,
@@ -421,16 +421,21 @@ class ReturnService:
) -> PalletScanResponseModel:
return PalletScanResponseModel(
pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code,
flow_state=PalletFlowState(inventory.flow_state),
warehouse_id=inventory.warehouse_id,
site_id=inventory.site_id,
tunnel_id=inventory.tunnel_id,
trace_no=inventory.trace_no,
current_line_id=inventory.current_line_id,
active_filling_id=inventory.active_filling_id,
strain_id=filling.strain_id if filling is not None else None,
strain_code=filling.strain_code_snapshot if filling is not None else None,
strain_name=filling.strain_name_snapshot if filling is not None else None,
batch_no=filling.batch_no if filling is not None else None,
production_date=filling.production_date if filling is not None else None,
expiry_date=filling.expiry_date if filling is not None else None,
package_spec=filling.package_spec if filling is not None else None,
capacity_packages=pallet.capacity_packages,
remaining_packages=inventory.remaining_packages,
@@ -543,12 +548,22 @@ class ReturnService:
user_name: str,
current_time: datetime,
test_batch_id: int | None,
filling_by_id: dict[int, BizFillingRecord | None] | None = None,
) -> list[BizReturnItem]:
return [
BizReturnItem(
return_id=return_id,
pallet_id=inventory.pallet_id,
active_filling_id_snapshot=inventory.active_filling_id,
tunnel_id_snapshot=inventory.tunnel_id,
trace_no_snapshot=inventory.trace_no,
batch_no_snapshot=(
filling_by_id.get(inventory.active_filling_id).batch_no
if filling_by_id
and inventory.active_filling_id is not None
and filling_by_id.get(inventory.active_filling_id) is not None
else None
),
remaining_packages_snapshot=inventory.remaining_packages,
create_by=user_name,
create_time=current_time,
@@ -788,15 +803,23 @@ class ReturnService:
)
return_order = await OperationDao.add_return_order(query_db, return_order)
return_id = int(return_order.return_id)
await OperationDao.add_return_items(
query_db,
cls._build_return_items(return_id, inventories, user_name, current_time, test_scope.batch_id),
return_items = cls._build_return_items(
return_id,
inventories,
user_name,
current_time,
test_scope.batch_id,
filling_by_id,
)
await OperationDao.add_return_items(query_db, return_items)
return_order.items = return_items
for inventory in inventories:
before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id
before_site_id = inventory.site_id
before_tunnel_id = inventory.tunnel_id
before_trace_no = inventory.trace_no
before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages
inventory.flow_state = PalletFlowState.RETURNING.value
@@ -811,12 +834,23 @@ class ReturnService:
after_state=PalletFlowState.RETURNING.value,
before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id,
before_tunnel_id=before_tunnel_id,
before_line_id=before_line_id,
after_warehouse_id=None,
after_site_id=inventory.site_id,
after_tunnel_id=inventory.tunnel_id,
after_line_id=inventory.current_line_id,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=inventory.remaining_packages,
trace_no=before_trace_no,
batch_no_snapshot=next(
(
item.batch_no_snapshot
for item in getattr(return_order, 'items', [])
if item.pallet_id == inventory.pallet_id
),
None,
),
operator_id=user_id,
occurred_at=current_time,
idempotency_key=page_object.idempotency_key,
@@ -860,6 +894,8 @@ class ReturnService:
if (
inventory.remaining_packages != item.remaining_packages_snapshot
or inventory.active_filling_id != item.active_filling_id_snapshot
or inventory.tunnel_id != item.tunnel_id_snapshot
or inventory.trace_no != item.trace_no_snapshot
):
raise ServiceException(message='托盘库存与退回申请快照不一致')
if (
@@ -881,7 +917,7 @@ class ReturnService:
return_order: BizReturnOrder,
items: list[BizReturnItem],
test_scope: TestBatchScope,
) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord]]:
) -> tuple[list[BizPalletInventory], dict[int, BizFillingRecord], dict[int, BizTunnel]]:
ordered_ids = sorted(item.pallet_id for item in items)
assert_record_in_scope(return_order, test_scope, '退回单')
for item in items:
@@ -911,6 +947,21 @@ class ReturnService:
inventory_by_id[pallet_id],
)
tunnel_by_id: dict[int, BizTunnel] = {}
tunnel_ids = sorted(
{
item.tunnel_id_snapshot
for item in items
if item.tunnel_id_snapshot is not None
}
)
for tunnel_id in tunnel_ids:
tunnel = await MasterDao.get_tunnel(query_db, tunnel_id, test_scope, for_update=True)
if tunnel is None:
raise ServiceException(message='退回单关联隧道不存在')
assert_record_in_scope(tunnel, test_scope, '隧道')
tunnel_by_id[tunnel_id] = tunnel
filling_by_id = {}
filling_ids = sorted(
{
@@ -928,7 +979,11 @@ class ReturnService:
for inventory in ordered_inventories:
if inventory.active_filling_id is not None:
cls._validate_active_filling(filling_by_id[inventory.active_filling_id], inventory)
return ordered_inventories, filling_by_id
item = item_by_id[inventory.pallet_id]
filling = filling_by_id[inventory.active_filling_id]
if item.batch_no_snapshot is not None and filling.batch_no != item.batch_no_snapshot:
raise ServiceException(message='托盘生产批次与退回申请快照不一致')
return ordered_inventories, filling_by_id, tunnel_by_id
@classmethod
async def _lock_and_reauthorize_confirm_warehouse(
@@ -1121,7 +1176,7 @@ class ReturnService:
if scanned_ids != sorted(item_ids):
raise ServiceException(message='确认扫码托盘集合与退回单明细不一致')
inventories, _ = await cls._lock_confirm_pallets(query_db, locked, items, test_scope)
inventories, _, tunnels = await cls._lock_confirm_pallets(query_db, locked, items, test_scope)
await cls._lock_and_reauthorize_confirm_warehouse(
query_db,
locked,
@@ -1151,6 +1206,8 @@ class ReturnService:
before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id
before_site_id = inventory.site_id
before_tunnel_id = inventory.tunnel_id
before_trace_no = inventory.trace_no
before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages
if inventory.remaining_packages > 0:
@@ -1162,6 +1219,9 @@ class ReturnService:
inventory.flow_state = target_state
inventory.warehouse_id = locked.target_warehouse_id
inventory.site_id = None
inventory.site_arrived_at = None
inventory.tunnel_id = None
inventory.trace_no = None
inventory.current_line_id = None
inventory.version += 1
inventory.updated_at = current_time
@@ -1176,10 +1236,21 @@ class ReturnService:
before_site_id=before_site_id,
before_line_id=before_line_id,
after_warehouse_id=locked.target_warehouse_id,
before_tunnel_id=before_tunnel_id,
after_site_id=None,
after_tunnel_id=None,
after_line_id=None,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=inventory.remaining_packages,
trace_no=before_trace_no,
batch_no_snapshot=next(
(
item.batch_no_snapshot
for item in items
if item.pallet_id == inventory.pallet_id
),
None,
),
operator_id=user_id,
occurred_at=current_time,
idempotency_key=page_object.idempotency_key,
@@ -1188,6 +1259,27 @@ class ReturnService:
)
await InventoryDao.add_event(query_db, inventory, event, test_scope)
for tunnel_id, tunnel in tunnels.items():
trace_nos = {
item.trace_no_snapshot
for item in items
if item.tunnel_id_snapshot == tunnel_id and item.trace_no_snapshot
}
if (
tunnel.current_trace_no in trace_nos
and await MasterDao.count_tunnel_trace_members(
query_db,
tunnel_id,
tunnel.current_trace_no,
test_scope,
)
== 0
):
tunnel.current_trace_no = None
tunnel.current_trace_started_at = None
tunnel.update_by = user_name
tunnel.update_time = current_time
await query_db.commit()
return cls._success('确认成功', return_id)
except IntegrityError:
@@ -0,0 +1,90 @@
import base64
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel
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.domain.enums import DocumentStatus
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.vo.shipment_label_vo import ShipmentLabelModel
from module_business.service.pallet_service import PalletService
from module_business.service.shipment_service import ShipmentService
class ShipmentLabelService:
"""复用发货双数据范围读取原始物料资料,不改变托盘、批号或库存。"""
@staticmethod
async def get_labels_services(
query_db: AsyncSession,
shipment_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[ShipmentLabelModel]:
warehouse_ids, site_ids = await ShipmentService._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:
ShipmentService._raise_shipment_not_accessible(current_user)
assert_record_in_scope(shipment, test_scope, '发货单')
if shipment.status not in (DocumentStatus.DRAFT.value, DocumentStatus.CONFIRMED.value):
raise ServiceException(message='当前发货单状态不支持打印标签')
if not shipment.items:
raise ServiceException(message='发货单没有可打印的托盘')
for item in shipment.items:
assert_record_in_scope(item, test_scope, '发货明细')
# 先授权单据,再读取其目标现场与所属客户,避免通过标签枚举跨范围资料。
site = await MasterDao.get_site(query_db, shipment.target_site_id, test_scope)
assert_record_in_scope(site, test_scope, '发货现场')
customer = await MasterDao.get_customer(query_db, site.customer_id, test_scope)
assert_record_in_scope(customer, test_scope, '发货客户')
rows = await ShipmentLabelDao.get_label_sources(query_db, shipment, test_scope)
if (
len(rows) != len(shipment.items)
or {row[0].shipment_item_id for row in rows} != {item.shipment_item_id for item in shipment.items}
):
raise ServiceException(message='发货明细已变化,请刷新后重新打印')
draft = shipment.status == DocumentStatus.DRAFT.value
# 整单全部验证后才生成任何二维码,缺少关联资料时不得输出部分标签。
for item, pallet, filling, quantity in rows:
for record, name in ((item, '发货明细'), (pallet, '托盘'), (filling, '装填记录')):
assert_record_in_scope(record, test_scope, name)
if (
item.shipment_id != shipment.shipment_id
or item.pallet_id != pallet.pallet_id
or filling.pallet_id != pallet.pallet_id
or type(quantity) is not int
or quantity <= 0
or not pallet.qr_token
):
raise ServiceException(message='发货标签资料不完整,请核对托盘与装填记录')
if draft:
if pallet.asset_status != '0' or filling.active_slot != 'ACTIVE' or quantity > pallet.capacity_packages:
raise ServiceException(message='草稿托盘已不满足发货条件,请刷新后重新打印')
elif item.active_filling_id != filling.filling_id or quantity != item.package_qty:
raise ServiceException(message='发货快照不完整,无法打印标签')
return [
ShipmentLabelModel(
shipment_no=shipment.shipment_no,
shipment_status=shipment.status,
pallet_code=pallet.pallet_code,
customer_name=customer.customer_name,
site_name=site.site_name,
strain_name=filling.strain_name_snapshot,
batch_no=filling.batch_no,
production_date=filling.production_date,
expiry_date=filling.expiry_date,
package_qty=quantity,
qr_code_data_url='data:image/png;base64,' + base64.b64encode(
PalletService.render_qr_png(pallet.qr_token),
).decode('ascii'),
)
for item, pallet, filling, quantity in rows
]
@@ -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
@@ -460,6 +460,7 @@ class ShipmentService:
)
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,
@@ -470,6 +471,8 @@ class ShipmentService:
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,
@@ -902,12 +905,13 @@ class ShipmentService:
for inventory in inventories:
PalletStateMachine.ensure_transition(
PalletFlowState(inventory.flow_state),
PalletFlowState.AT_SITE_UNUSED,
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
@@ -927,7 +931,8 @@ class ShipmentService:
before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages
inventory.flow_state = PalletFlowState.AT_SITE_UNUSED.value
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
@@ -939,7 +944,7 @@ class ShipmentService:
source_type='SHIPMENT',
source_id=shipment_id,
before_state=before_state,
after_state=PalletFlowState.AT_SITE_UNUSED.value,
after_state=PalletFlowState.IN_TRANSIT.value,
before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id,
before_line_id=before_line_id,
@@ -0,0 +1,54 @@
from sqlalchemy.ext.asyncio import AsyncSession
from exceptions.exception import ServiceException
from module_admin.entity.vo.user_vo import CurrentUserModel
from module_business.dao.master_dao import MasterDao
from module_business.dao.usage_fifo_dao import UsageFifoDao
from module_business.domain.fifo_policy import requires_fifo_skip_reason
from module_business.domain.test_batch_scope import TestBatchScope, assert_record_in_scope
from module_business.entity.vo.usage_fifo_vo import UsageFifoRecommendationModel
from module_business.service.usage_service import UsageService
class UsageFifoService:
@staticmethod
async def recommendation(
db: AsyncSession, identifier: str, user: CurrentUserModel, scope: TestBatchScope,
*, tunnel_id: int | None = None,
) -> UsageFifoRecommendationModel:
# 复用扫码入口的现场授权、当前批次与可用状态校验;客户端不指定查询现场。
pallet = await UsageService.scan_usage_pallet_services(db, identifier, user, scope)
inventory = await MasterDao.get_pallet_inventory(db, pallet.pallet_id, scope)
if inventory is None:
raise ServiceException(message='托盘库存不存在')
assert_record_in_scope(inventory, scope, '库存')
target_tunnel_id = pallet.tunnel_id
trace_no = pallet.trace_no
if tunnel_id is not None:
tunnel = await MasterDao.get_tunnel(db, tunnel_id, scope)
if tunnel is None or tunnel.site_id != pallet.site_id or tunnel.status != '0':
raise ServiceException(message='请选择托盘当前现场的启用隧道')
assert_record_in_scope(tunnel, scope, '隧道')
if pallet.tunnel_id is not None and pallet.tunnel_id != tunnel_id:
raise ServiceException(message='托盘已入其他隧道')
target_tunnel_id, trace_no = tunnel_id, tunnel.current_trace_no
candidate, unknown = await UsageFifoDao.recommendation(
db,
site_id=pallet.site_id,
strain_id=pallet.strain_id,
tunnel_id=target_tunnel_id,
trace_no=trace_no,
scope=scope,
)
message = '当前无可确定到货顺序的同品种托盘'
if candidate:
message = f'先到先用建议:托盘 {candidate["pallet_code"]},批次 {candidate["batch_no"]},余量 {candidate["remaining_packages"]} 包'
if unknown:
message += f';另有 {unknown} 托盘未登记实际收货时间,请人工核对先后顺序'
needs_reason = requires_fifo_skip_reason(pallet.pallet_id, inventory.site_arrived_at, candidate)
if needs_reason:
message += ';使用当前托盘需填写跳过先到托盘的原因'
return UsageFifoRecommendationModel(
recommended=candidate, unknown_arrival_count=unknown,
requires_skip_reason=needs_reason, message=message,
)
@@ -16,14 +16,18 @@ 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.usage_fifo_dao import UsageFifoDao
from module_business.domain.enums import PalletEventType, PalletFlowState
from module_business.domain.fifo_policy import requires_fifo_skip_reason
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.operation_do import BizPalletEvent, BizUsageRecord
from module_business.entity.vo.master_vo import LinePageQueryModel
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory, BizUsageRecord
from module_business.entity.vo.master_vo import LinePageQueryModel, TunnelPageQueryModel
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
PalletScanResponseModel,
TunnelEntryCreateModel,
TunnelEntrySuccessResponseModel,
UsageCreateModel,
UsageModel,
UsagePageQueryModel,
@@ -31,6 +35,7 @@ from module_business.entity.vo.operation_vo import (
)
from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService
from module_business.service.usage_transaction import retry_mysql_lock_conflicts
class UsageService:
@@ -110,17 +115,52 @@ class UsageService:
@staticmethod
def _build_request_hash(page_object: UsageCreateModel) -> str:
fields = {
'qr_token': page_object.qr_token.strip(),
'tunnel_id': page_object.tunnel_id,
'line_id': page_object.line_id,
'used_packages': page_object.used_packages,
}
# 无原因时保留原摘要,迁移前已成功的请求仍可按原键安全重放。
if page_object.fifo_skip_reason:
fields['fifo_skip_reason'] = page_object.fifo_skip_reason
payload = json.dumps(
{
'qr_token': page_object.qr_token.strip(),
'line_id': page_object.line_id,
'used_packages': page_object.used_packages,
},
fields,
sort_keys=True,
separators=(',', ':'),
).encode()
return hashlib.sha256(payload).hexdigest()
@staticmethod
async def _check_fifo_skip(
query_db: AsyncSession,
inventory: BizPalletInventory,
filling: BizFillingRecord,
tunnel_id: int,
trace_no: str | None,
reason: str | None,
test_scope: TestBatchScope,
) -> dict[str, Any] | None:
if inventory.site_arrived_at is None:
return None
candidate, _ = await UsageFifoDao.recommendation(
query_db, site_id=inventory.site_id, strain_id=filling.strain_id,
tunnel_id=tunnel_id, trace_no=trace_no, scope=test_scope, for_update=True,
)
if not requires_fifo_skip_reason(inventory.pallet_id, inventory.site_arrived_at, candidate):
return None
if not reason:
raise ServiceException(
data='FIFO_SKIP_REASON_REQUIRED',
message=f'存在先到托盘 {candidate["pallet_code"]},请填写跳过原因后重新提交',
)
return {
'palletId': candidate['pallet_id'], 'palletCode': candidate['pallet_code'],
'batchNo': candidate['batch_no'], 'remainingPackages': candidate['remaining_packages'],
'siteArrivedAt': candidate['site_arrived_at'].isoformat(),
'selectedSiteArrivedAt': inventory.site_arrived_at.isoformat(),
}
@staticmethod
def _is_exact_idempotent_result(
usage: BizUsageRecord,
@@ -143,6 +183,32 @@ class UsageService:
),
)
@staticmethod
def _tunnel_entry_success(pallet_id: int, tunnel_id: int, trace_no: str) -> CrudResponseModel:
return CrudResponseModel(
is_success=True,
message='扫码入隧成功',
result=TunnelEntrySuccessResponseModel(
pallet_id=pallet_id,
tunnel_id=tunnel_id,
trace_no=trace_no,
),
)
@staticmethod
def _is_exact_tunnel_entry_result(
event: BizPalletEvent,
page_object: TunnelEntryCreateModel,
request_hash: str,
) -> bool:
return (
event.event_type == PalletEventType.TUNNEL_ENTERED.value
and event.after_tunnel_id == page_object.tunnel_id
and event.idempotency_key == page_object.idempotency_key
and event.request_hash == request_hash
and bool(event.trace_no)
)
@classmethod
async def _lock_and_reauthorize_usage_site(
cls,
@@ -294,7 +360,274 @@ class UsageService:
return sorted(options, key=lambda option: option.id)
@classmethod
async def scan_usage_pallet_services(
async def get_tunnel_options_services(
cls,
query_db: AsyncSession,
site_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[BusinessOptionModel]:
cls._current_identity(current_user)
await AccessService.assert_site_access(query_db, current_user, site_id)
site = await MasterDao.get_site(query_db, 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='客户现场已停用')
tunnels = await MasterDao.list_tunnel(
query_db,
TunnelPageQueryModel(siteId=site_id, status='0'),
test_scope,
False,
)
options = [
BusinessOptionModel(
id=cls._record_value(tunnel, 'tunnel_id'),
code=cls._record_value(tunnel, 'tunnel_code'),
name=cls._record_value(tunnel, 'tunnel_name'),
)
for tunnel in tunnels
if cls._record_value(tunnel, 'site_id') == site_id and cls._record_value(tunnel, 'status') == '0'
]
return sorted(options, key=lambda option: option.id)
@staticmethod
def _build_tunnel_entry_request_hash(page_object: TunnelEntryCreateModel) -> str:
fields = {'qr_token': page_object.qr_token.strip(), 'tunnel_id': page_object.tunnel_id}
if page_object.fifo_skip_reason:
fields['fifo_skip_reason'] = page_object.fifo_skip_reason
payload = json.dumps(
fields,
sort_keys=True,
separators=(',', ':'),
).encode()
return hashlib.sha256(payload).hexdigest()
@classmethod
async def _recover_tunnel_entry_integrity_error(
cls,
query_db: AsyncSession,
page_object: TunnelEntryCreateModel,
request_hash: str,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
pallet = await InventoryDao.get_pallet_by_qr_token(
query_db,
page_object.qr_token.strip(),
test_scope,
)
if pallet is None:
cls._raise_pallet_not_accessible(current_user)
event = await InventoryDao.get_event_by_idempotency_key(
query_db,
pallet.pallet_id,
page_object.idempotency_key,
test_scope,
)
if event is None or not cls._is_exact_tunnel_entry_result(event, page_object, request_hash):
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
return cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, event.trace_no)
finally:
await query_db.rollback()
@classmethod
@retry_mysql_lock_conflicts
async def enter_tunnel_services( # noqa: PLR0912, PLR0915
cls,
query_db: AsyncSession,
page_object: TunnelEntryCreateModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
request_hash = cls._build_tunnel_entry_request_hash(page_object)
user_id, user_name = cls._current_identity(current_user)
pallet = await InventoryDao.get_pallet_by_qr_token(
query_db,
page_object.qr_token.strip(),
test_scope,
)
if pallet is None:
cls._raise_pallet_not_accessible(current_user)
assert_record_in_scope(pallet, test_scope, '托盘')
tunnel = await MasterDao.get_tunnel(query_db, page_object.tunnel_id, test_scope)
if tunnel is None:
raise ServiceException(message='隧道不存在')
assert_record_in_scope(tunnel, test_scope, '隧道')
if tunnel.status != '0':
raise ServiceException(message='隧道已停用')
preflight_tunnel_site_id = tunnel.site_id
await cls._assert_pallet_site_access(query_db, current_user, tunnel.site_id)
existing = await InventoryDao.get_event_by_idempotency_key(
query_db,
pallet.pallet_id,
page_object.idempotency_key,
test_scope,
)
if existing is not None:
assert_record_in_scope(existing, test_scope, '托盘事件')
if not cls._is_exact_tunnel_entry_result(existing, page_object, request_hash):
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
result = cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, existing.trace_no)
await query_db.rollback()
return result
# rollback 会令 ORM 属性过期;跨事务只传普通值,不触发隐式异步加载。
scanned_pallet_id = pallet.pallet_id
await query_db.rollback()
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
inventory = await InventoryDao.get_inventory_for_update(query_db, scanned_pallet_id, test_scope)
if inventory is None:
cls._raise_pallet_not_accessible(current_user, '托盘库存不存在')
assert_record_in_scope(inventory, test_scope, '库存')
if inventory.site_id is None:
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
if inventory.site_id != preflight_tunnel_site_id:
raise ServiceException(message='隧道不属于托盘当前客户现场')
locked_pallets = await InventoryDao.get_pallets_by_ids(
query_db,
[inventory.pallet_id],
test_scope,
)
if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != inventory.pallet_id:
cls._raise_pallet_not_accessible(current_user)
pallet = locked_pallets[0]
assert_record_in_scope(pallet, test_scope, '托盘')
tunnel = await MasterDao.get_tunnel(
query_db,
page_object.tunnel_id,
test_scope,
for_update=True,
)
if tunnel is None:
raise ServiceException(message='隧道不存在')
assert_record_in_scope(tunnel, test_scope, '隧道')
if tunnel.status != '0':
raise ServiceException(message='隧道已停用')
if tunnel.site_id != inventory.site_id:
raise ServiceException(message='隧道不属于托盘当前客户现场')
existing = await InventoryDao.get_event_by_idempotency_key(
query_db,
pallet.pallet_id,
page_object.idempotency_key,
test_scope,
)
if existing is not None:
assert_record_in_scope(existing, test_scope, '托盘事件')
if not cls._is_exact_tunnel_entry_result(existing, page_object, request_hash):
raise ServiceException(message='扫码入隧失败,幂等键已被其他请求使用')
result = cls._tunnel_entry_success(pallet.pallet_id, page_object.tunnel_id, existing.trace_no)
await query_db.rollback()
return result
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许入隧')
if (
inventory.flow_state
not in {
PalletFlowState.AT_SITE_UNUSED.value,
PalletFlowState.AT_SITE_PARTIAL.value,
}
or inventory.warehouse_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='托盘当前状态不允许入隧')
filling = await OperationDao.get_active_filling_for_update(
query_db,
inventory.active_filling_id,
test_scope,
)
if filling is None:
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
assert_record_in_scope(filling, test_scope, '活动装填')
if (
filling.pallet_id != pallet.pallet_id
or filling.active_slot != 'ACTIVE'
or filling.closed_at is not None
):
raise ServiceException(message='托盘活动装填周期无效')
if inventory.tunnel_id is not None:
if inventory.tunnel_id == tunnel.tunnel_id and inventory.trace_no == tunnel.current_trace_no:
raise ServiceException(message='托盘已入当前隧道')
raise ServiceException(message='托盘已入其他隧道')
if inventory.trace_no is not None:
raise ServiceException(message='托盘隧道溯源状态不一致')
fifo_snapshot = await cls._check_fifo_skip(
query_db, inventory, filling, tunnel.tunnel_id, tunnel.current_trace_no,
page_object.fifo_skip_reason, test_scope,
)
if tunnel.current_trace_no is None:
tunnel.current_trace_no = f'TN-{secrets.token_hex(16)}'
tunnel.current_trace_started_at = datetime.now()
elif not tunnel.current_trace_no.strip():
raise ServiceException(message='隧道当前溯源号无效')
current_time = datetime.now()
before_state = inventory.flow_state
before_remaining_packages = inventory.remaining_packages
inventory.tunnel_id = tunnel.tunnel_id
inventory.trace_no = tunnel.current_trace_no
inventory.version += 1
inventory.updated_at = current_time
event = BizPalletEvent(
pallet_id=inventory.pallet_id,
event_type=PalletEventType.TUNNEL_ENTERED.value,
source_type='TUNNEL',
source_id=tunnel.tunnel_id,
fifo_skip_reason=page_object.fifo_skip_reason,
fifo_recommendation=fifo_snapshot,
before_state=before_state,
after_state=inventory.flow_state,
before_warehouse_id=inventory.warehouse_id,
before_site_id=inventory.site_id,
before_tunnel_id=None,
before_line_id=inventory.current_line_id,
after_warehouse_id=inventory.warehouse_id,
after_site_id=inventory.site_id,
after_tunnel_id=inventory.tunnel_id,
after_line_id=inventory.current_line_id,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=inventory.remaining_packages,
trace_no=inventory.trace_no,
batch_no_snapshot=filling.batch_no,
operator_id=user_id,
occurred_at=current_time,
idempotency_key=page_object.idempotency_key,
request_hash=request_hash,
test_batch_id=test_scope.batch_id,
)
await InventoryDao.add_event(query_db, inventory, event, test_scope)
tunnel.update_by = user_name
tunnel.update_time = current_time
result = cls._tunnel_entry_success(pallet.pallet_id, tunnel.tunnel_id, tunnel.current_trace_no)
await query_db.commit()
return result
except IntegrityError:
await query_db.rollback()
return await cls._recover_tunnel_entry_integrity_error(
query_db,
page_object,
cls._build_tunnel_entry_request_hash(page_object),
current_user,
test_scope,
)
except Exception:
await query_db.rollback()
raise
@classmethod
async def scan_usage_pallet_services( # noqa: PLR0912
cls,
query_db: AsyncSession,
qr_token: str,
@@ -314,7 +647,7 @@ class UsageService:
cls._raise_pallet_not_accessible(current_user, '托盘不在客户现场')
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
if pallet.qr_token != normalized_qr or pallet.pallet_id != inventory.pallet_id:
if normalized_qr not in {pallet.qr_token, pallet.pallet_code} or pallet.pallet_id != inventory.pallet_id:
raise ServiceException(message='托盘二维码与库存不一致')
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许使用')
@@ -328,7 +661,7 @@ class UsageService:
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
or inventory.remaining_packages > pallet.capacity_packages
):
raise ServiceException(message='托盘当前状态不允许使用')
filling = await OperationDao.get_filling_by_id(query_db, inventory.active_filling_id, test_scope)
@@ -342,25 +675,43 @@ class UsageService:
or filling.closed_at is not None
):
raise ServiceException(message='托盘活动装填周期无效')
allowed_actions = ['usage']
if inventory.tunnel_id is None:
allowed_actions = ['enter_tunnel']
else:
if inventory.trace_no is None:
raise ServiceException(message='托盘隧道溯源状态不一致')
tunnel = await MasterDao.get_tunnel(query_db, inventory.tunnel_id, test_scope)
if tunnel is None or tunnel.site_id != inventory.site_id:
raise ServiceException(message='托盘当前隧道不存在或现场不一致')
assert_record_in_scope(tunnel, test_scope, '隧道')
if tunnel.status != '0' or tunnel.current_trace_no != inventory.trace_no:
raise ServiceException(message='托盘当前隧道已停用或溯源状态不一致')
return PalletScanResponseModel(
pallet_id=pallet.pallet_id,
qr_token=pallet.qr_token,
pallet_code=pallet.pallet_code,
flow_state=PalletFlowState(inventory.flow_state),
warehouse_id=None,
site_id=inventory.site_id,
tunnel_id=inventory.tunnel_id,
trace_no=inventory.trace_no,
current_line_id=inventory.current_line_id,
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=['usage'],
allowed_actions=allowed_actions,
)
@classmethod
@retry_mysql_lock_conflicts
async def record_usage_services( # noqa: PLR0912, PLR0915
cls,
query_db: AsyncSession,
@@ -374,11 +725,16 @@ class UsageService:
request_hash = cls._build_request_hash(page_object)
user_id, user_name = cls._current_identity(current_user)
preflight_site_ids = await cls._site_scope(query_db, current_user)
# 产线归属是请求内稳定路由;锁事务仍以库存中的实际现场复核。
preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope)
if preflight_line is not None:
assert_record_in_scope(preflight_line, test_scope, '产线')
preflight_site_id = preflight_line.site_id if preflight_line is not None else None
preflight_tunnel = await MasterDao.get_tunnel(query_db, page_object.tunnel_id, test_scope)
if preflight_tunnel is None:
raise ServiceException(message='隧道不存在')
assert_record_in_scope(preflight_tunnel, test_scope, '隧道')
preflight_site_id = preflight_tunnel.site_id
if page_object.line_id is not None:
# 仅在预检阶段读取产线以确定批次范围;状态和实际现场归属在锁定库存后复核。
preflight_line = await MasterDao.get_line(query_db, page_object.line_id, test_scope)
if preflight_line is not None:
assert_record_in_scope(preflight_line, test_scope, '产线')
existing = None
if preflight_site_id is not None and (
preflight_site_ids is None or preflight_site_id in preflight_site_ids
@@ -494,14 +850,34 @@ class UsageService:
assert_record_in_scope(site, test_scope, '现场')
if site.status != '0':
raise ServiceException(message='客户现场已停用')
line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True)
if line is None:
raise ServiceException(message='产线不存在')
assert_record_in_scope(line, test_scope, '产线')
if line.status != '0':
raise ServiceException(message='产线已停用')
if line.site_id != inventory.site_id:
raise ServiceException(message='产线不属于托盘当前客户现场')
tunnel = await MasterDao.get_tunnel(
query_db,
page_object.tunnel_id,
test_scope,
for_update=True,
)
if tunnel is None:
raise ServiceException(message='隧道不存在')
assert_record_in_scope(tunnel, test_scope, '隧道')
if tunnel.status != '0':
raise ServiceException(message='隧道已停用')
if tunnel.site_id != inventory.site_id:
raise ServiceException(message='隧道不属于托盘当前客户现场')
if inventory.tunnel_id != tunnel.tunnel_id or not inventory.trace_no:
raise ServiceException(message='托盘尚未扫码入当前隧道')
if tunnel.current_trace_no != inventory.trace_no:
raise ServiceException(message='托盘与隧道的物料溯源号不一致')
line = None
if page_object.line_id is not None:
line = await MasterDao.get_line(query_db, page_object.line_id, test_scope, for_update=True)
if line is None:
raise ServiceException(message='产线不存在')
assert_record_in_scope(line, test_scope, '产线')
if line.status != '0':
raise ServiceException(message='产线已停用')
if line.site_id != inventory.site_id:
raise ServiceException(message='产线不属于托盘当前客户现场')
if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != page_object.qr_token.strip():
raise ServiceException(message='托盘二维码与库存不一致')
@@ -527,13 +903,22 @@ class UsageService:
or filling.closed_at is not None
):
raise ServiceException(message='托盘活动装填周期无效')
if inventory.current_line_id is not None and inventory.current_line_id != line.line_id:
if (
line is not None
and inventory.current_line_id is not None
and inventory.current_line_id != line.line_id
):
raise ServiceException(message='托盘本次驻场周期已绑定其他产线')
if type(page_object.used_packages) is not int or page_object.used_packages <= 0:
raise ServiceException(message='使用包数必须为正整数')
if page_object.used_packages > inventory.remaining_packages:
raise ServiceException(message='使用包数不能超过当前余量')
fifo_snapshot = await cls._check_fifo_skip(
query_db, inventory, filling, tunnel.tunnel_id, inventory.trace_no,
page_object.fifo_skip_reason, test_scope,
)
remaining_after = inventory.remaining_packages - page_object.used_packages
target_state = PalletFlowState.AT_SITE_EMPTY if remaining_after == 0 else PalletFlowState.AT_SITE_PARTIAL
PalletStateMachine.ensure_transition(
@@ -543,11 +928,16 @@ class UsageService:
current_time = datetime.now()
usage = BizUsageRecord(
fifo_skip_reason=page_object.fifo_skip_reason,
fifo_recommendation=fifo_snapshot,
usage_no=f'US-{secrets.token_hex(16)}',
pallet_id=pallet.pallet_id,
active_filling_id=filling.filling_id,
site_id=inventory.site_id,
line_id=line.line_id,
tunnel_id=tunnel.tunnel_id,
trace_no=inventory.trace_no,
batch_no_snapshot=filling.batch_no,
line_id=line.line_id if line is not None else inventory.current_line_id,
used_packages=page_object.used_packages,
remaining_packages_after=remaining_after,
idempotency_key=page_object.idempotency_key,
@@ -564,9 +954,12 @@ class UsageService:
before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id
before_site_id = inventory.site_id
before_tunnel_id = inventory.tunnel_id
before_trace_no = inventory.trace_no
before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages
inventory.current_line_id = line.line_id
if line is not None:
inventory.current_line_id = line.line_id
inventory.remaining_packages = remaining_after
inventory.flow_state = target_state.value
if target_state is PalletFlowState.AT_SITE_EMPTY:
@@ -583,16 +976,22 @@ class UsageService:
event_type=PalletEventType.USAGE_RECORDED.value,
source_type='USAGE',
source_id=usage_id,
fifo_skip_reason=page_object.fifo_skip_reason,
fifo_recommendation=fifo_snapshot,
before_state=before_state,
after_state=target_state.value,
before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id,
before_tunnel_id=before_tunnel_id,
before_line_id=before_line_id,
after_warehouse_id=inventory.warehouse_id,
after_site_id=inventory.site_id,
after_tunnel_id=inventory.tunnel_id,
after_line_id=inventory.current_line_id,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=remaining_after,
trace_no=before_trace_no,
batch_no_snapshot=filling.batch_no,
operator_id=user_id,
occurred_at=current_time,
idempotency_key=page_object.idempotency_key,
@@ -0,0 +1,32 @@
"""入隧/使用命令的有限整事务锁冲突重试,不重放结果未知的连接错误。"""
import asyncio
import secrets
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import ParamSpec, TypeVar
from sqlalchemy.exc import OperationalError
P = ParamSpec('P')
R = TypeVar('R')
def retry_mysql_lock_conflicts(operation: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]:
"""仅用于异常出口已 rollback 的完整业务命令;所有重试沿用原payload/key。"""
max_attempts = 3
@wraps(operation)
async def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
for attempt in range(max_attempts):
try:
return await operation(*args, **kwargs)
except OperationalError as exc: # noqa: PERF203 -- 有限整事务重试必须逐次捕获锁冲突
errno = exc.orig.args[0] if getattr(exc.orig, 'args', ()) else None
# 3572=NOWAIT冲突,1213=死锁,1205=锁等待超时;不重试断连/未知提交结果。
if errno not in {3572, 1213, 1205} or attempt == max_attempts - 1:
raise
await asyncio.sleep(0.05 * (attempt + 1) + secrets.randbelow(20) / 1000)
raise AssertionError('锁冲突重试未返回结果')
return wrapped