1019 lines
45 KiB
Python
1019 lines
45 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
|
|
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.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 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,
|
|
UsageSuccessResponseModel,
|
|
)
|
|
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:
|
|
"""客户现场使用服务层。"""
|
|
|
|
@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)
|
|
|
|
@classmethod
|
|
async def _site_scope(
|
|
cls,
|
|
query_db: AsyncSession,
|
|
current_user: CurrentUserModel,
|
|
) -> list[int] | None:
|
|
if cls._is_admin(current_user):
|
|
return None
|
|
user_id, _ = cls._current_identity(current_user)
|
|
return sorted(set(await AccessDao.list_user_site_ids(query_db, user_id)))
|
|
|
|
@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 _assert_pallet_site_access(
|
|
cls,
|
|
query_db: AsyncSession,
|
|
current_user: CurrentUserModel,
|
|
site_id: int,
|
|
*,
|
|
for_update: bool = False,
|
|
) -> None:
|
|
try:
|
|
if for_update:
|
|
await AccessService.assert_site_access(
|
|
query_db,
|
|
current_user,
|
|
site_id,
|
|
for_update=True,
|
|
)
|
|
else:
|
|
await AccessService.assert_site_access(query_db, current_user, site_id)
|
|
except PermissionException:
|
|
if cls._is_admin(current_user):
|
|
raise
|
|
cls._raise_pallet_not_accessible(current_user)
|
|
|
|
@classmethod
|
|
def _raise_usage_not_accessible(cls, current_user: CurrentUserModel) -> NoReturn:
|
|
if cls._is_admin(current_user):
|
|
raise ServiceException(message='使用记录不存在')
|
|
raise PermissionException(data='', message='无权访问该使用记录')
|
|
|
|
@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)
|
|
|
|
@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(
|
|
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,
|
|
page_object: UsageCreateModel,
|
|
request_hash: str,
|
|
) -> bool:
|
|
return usage.idempotency_key == page_object.idempotency_key and usage.request_hash == request_hash
|
|
|
|
@staticmethod
|
|
def _success(usage: BizUsageRecord) -> CrudResponseModel:
|
|
remaining_packages = usage.remaining_packages_after
|
|
flow_state = PalletFlowState.AT_SITE_EMPTY if remaining_packages == 0 else PalletFlowState.AT_SITE_PARTIAL
|
|
return CrudResponseModel(
|
|
is_success=True,
|
|
message='使用成功',
|
|
result=UsageSuccessResponseModel(
|
|
usage_id=usage.usage_id,
|
|
remaining_packages=remaining_packages,
|
|
flow_state=flow_state,
|
|
),
|
|
)
|
|
|
|
@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,
|
|
query_db: AsyncSession,
|
|
site_id: int,
|
|
current_user: CurrentUserModel,
|
|
test_scope: TestBatchScope,
|
|
) -> None:
|
|
site = await MasterDao.get_site(query_db, site_id, test_scope, for_update=True)
|
|
try:
|
|
await AccessService.assert_site_access(
|
|
query_db,
|
|
current_user,
|
|
site_id,
|
|
for_update=True,
|
|
)
|
|
except PermissionException:
|
|
if cls._is_admin(current_user):
|
|
raise
|
|
cls._raise_usage_not_accessible(current_user)
|
|
if site is None:
|
|
raise ServiceException(message='客户现场不存在')
|
|
assert_record_in_scope(site, test_scope, '现场')
|
|
|
|
@classmethod
|
|
async def _recover_integrity_error(
|
|
cls,
|
|
query_db: AsyncSession,
|
|
page_object: UsageCreateModel,
|
|
request_hash: str,
|
|
current_user: CurrentUserModel,
|
|
expected_site_id: int,
|
|
test_scope: TestBatchScope,
|
|
) -> CrudResponseModel:
|
|
try:
|
|
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
|
|
site_ids = await cls._site_scope(query_db, current_user)
|
|
if site_ids is not None and expected_site_id not in site_ids:
|
|
cls._raise_pallet_not_accessible(current_user)
|
|
recovered = await OperationDao.get_usage_by_idempotency_key(
|
|
query_db,
|
|
page_object.idempotency_key,
|
|
test_scope,
|
|
site_ids=[expected_site_id],
|
|
)
|
|
if recovered is None:
|
|
raise ServiceException(message='使用失败,数据冲突')
|
|
assert_record_in_scope(recovered, test_scope, '使用记录')
|
|
if recovered.site_id != expected_site_id or not cls._is_exact_idempotent_result(
|
|
recovered, page_object, request_hash
|
|
):
|
|
raise ServiceException(message='使用失败,幂等键已被其他请求使用')
|
|
await cls._lock_and_reauthorize_usage_site(
|
|
query_db,
|
|
recovered.site_id,
|
|
current_user,
|
|
test_scope,
|
|
)
|
|
return cls._success(recovered)
|
|
finally:
|
|
await query_db.rollback()
|
|
|
|
@classmethod
|
|
async def get_usage_list_services(
|
|
cls,
|
|
query_db: AsyncSession,
|
|
query_object: UsagePageQueryModel,
|
|
current_user: CurrentUserModel,
|
|
test_scope: TestBatchScope,
|
|
is_page: bool = True,
|
|
) -> PageModel | list[UsageModel]:
|
|
site_ids = await cls._site_scope(query_db, current_user)
|
|
if site_ids is not None and query_object.site_id is not None and query_object.site_id not in site_ids:
|
|
raise PermissionException(data='', message='无权访问该客户现场')
|
|
if 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_usage_list(
|
|
query_db,
|
|
query_object,
|
|
site_ids=site_ids,
|
|
scope=test_scope,
|
|
is_page=is_page,
|
|
)
|
|
rows = result.rows if isinstance(result, PageModel) else result
|
|
models = [UsageModel.model_validate(record) for record in rows]
|
|
if isinstance(result, PageModel):
|
|
return result.model_copy(update={'rows': models})
|
|
return models
|
|
|
|
@classmethod
|
|
async def usage_detail_services(
|
|
cls,
|
|
query_db: AsyncSession,
|
|
usage_id: int,
|
|
current_user: CurrentUserModel,
|
|
test_scope: TestBatchScope,
|
|
) -> UsageModel:
|
|
site_ids = await cls._site_scope(query_db, current_user)
|
|
usage = await OperationDao.get_usage_by_id(
|
|
query_db,
|
|
usage_id,
|
|
test_scope,
|
|
site_ids=site_ids,
|
|
)
|
|
if usage is None:
|
|
cls._raise_usage_not_accessible(current_user)
|
|
assert_record_in_scope(usage, test_scope, '使用记录')
|
|
return UsageModel.model_validate(usage)
|
|
|
|
@classmethod
|
|
async def get_line_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='客户现场已停用')
|
|
lines = await MasterDao.list_line(
|
|
query_db,
|
|
LinePageQueryModel(siteId=site_id, status='0'),
|
|
test_scope,
|
|
False,
|
|
)
|
|
options = [
|
|
BusinessOptionModel(
|
|
id=cls._record_value(line, 'line_id'),
|
|
code=cls._record_value(line, 'line_code'),
|
|
name=cls._record_value(line, 'line_name'),
|
|
)
|
|
for line in lines
|
|
if cls._record_value(line, 'site_id') == site_id and cls._record_value(line, 'status') == '0'
|
|
]
|
|
return sorted(options, key=lambda option: option.id)
|
|
|
|
@classmethod
|
|
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,
|
|
current_user: CurrentUserModel,
|
|
test_scope: TestBatchScope,
|
|
) -> PalletScanResponseModel:
|
|
normalized_qr = qr_token.strip()
|
|
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, normalized_qr, 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.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 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='托盘资产已停用,不允许使用')
|
|
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_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, '活动装填')
|
|
if (
|
|
filling.filling_id != inventory.active_filling_id
|
|
or filling.pallet_id != pallet.pallet_id
|
|
or filling.active_slot != 'ACTIVE'
|
|
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=allowed_actions,
|
|
)
|
|
|
|
@classmethod
|
|
@retry_mysql_lock_conflicts
|
|
async def record_usage_services( # noqa: PLR0912, PLR0915
|
|
cls,
|
|
query_db: AsyncSession,
|
|
page_object: UsageCreateModel,
|
|
current_user: CurrentUserModel,
|
|
test_scope: TestBatchScope,
|
|
) -> CrudResponseModel:
|
|
expected_site_id: int | None = None
|
|
try:
|
|
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
|
|
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_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
|
|
):
|
|
existing = await OperationDao.get_usage_by_idempotency_key(
|
|
query_db,
|
|
page_object.idempotency_key,
|
|
test_scope,
|
|
site_ids=[preflight_site_id],
|
|
)
|
|
if existing is not None:
|
|
assert_record_in_scope(existing, test_scope, '使用记录')
|
|
if not cls._is_exact_idempotent_result(existing, page_object, request_hash):
|
|
raise ServiceException(message='使用失败,幂等键已被其他请求使用')
|
|
if preflight_site_ids is not None and existing.site_id not in preflight_site_ids:
|
|
cls._raise_usage_not_accessible(current_user)
|
|
await cls._lock_and_reauthorize_usage_site(
|
|
query_db,
|
|
existing.site_id,
|
|
current_user,
|
|
test_scope,
|
|
)
|
|
result = cls._success(existing)
|
|
await query_db.rollback()
|
|
return result
|
|
|
|
scanned_pallet = await InventoryDao.get_pallet_by_qr_token(
|
|
query_db,
|
|
page_object.qr_token.strip(),
|
|
test_scope,
|
|
)
|
|
if scanned_pallet is None:
|
|
cls._raise_pallet_not_accessible(current_user)
|
|
assert_record_in_scope(scanned_pallet, test_scope, '托盘')
|
|
scanned_pallet_id = int(scanned_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, '托盘不在客户现场')
|
|
expected_site_id = inventory.site_id
|
|
await cls._assert_pallet_site_access(query_db, current_user, inventory.site_id)
|
|
locked_site_ids = await cls._site_scope(query_db, current_user)
|
|
if locked_site_ids is not None and inventory.site_id not in locked_site_ids:
|
|
cls._raise_pallet_not_accessible(current_user)
|
|
|
|
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, '托盘')
|
|
|
|
filling = (
|
|
await OperationDao.get_active_filling_for_update(
|
|
query_db,
|
|
inventory.active_filling_id,
|
|
test_scope,
|
|
)
|
|
if inventory.active_filling_id is not None
|
|
else None
|
|
)
|
|
locked_existing = await OperationDao.get_usage_by_idempotency_key(
|
|
query_db,
|
|
page_object.idempotency_key,
|
|
test_scope,
|
|
site_ids=[inventory.site_id],
|
|
)
|
|
if locked_existing is not None:
|
|
assert_record_in_scope(locked_existing, test_scope, '使用记录')
|
|
if locked_existing.site_id != inventory.site_id or not cls._is_exact_idempotent_result(
|
|
locked_existing,
|
|
page_object,
|
|
request_hash,
|
|
):
|
|
raise ServiceException(message='使用失败,幂等键已被其他请求使用')
|
|
await cls._lock_and_reauthorize_usage_site(
|
|
query_db,
|
|
locked_existing.site_id,
|
|
current_user,
|
|
test_scope,
|
|
)
|
|
result = cls._success(locked_existing)
|
|
await query_db.rollback()
|
|
return result
|
|
|
|
if inventory.active_filling_id is None:
|
|
raise ServiceException(message='托盘不存在活动装填周期')
|
|
if filling is None:
|
|
raise ServiceException(message='托盘活动装填周期不存在或已关闭')
|
|
assert_record_in_scope(filling, test_scope, '活动装填')
|
|
|
|
site = await MasterDao.get_site(query_db, inventory.site_id, test_scope, for_update=True)
|
|
await cls._assert_pallet_site_access(
|
|
query_db,
|
|
current_user,
|
|
inventory.site_id,
|
|
for_update=True,
|
|
)
|
|
if site is None:
|
|
raise ServiceException(message='客户现场不存在')
|
|
assert_record_in_scope(site, test_scope, '现场')
|
|
if site.status != '0':
|
|
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='托盘二维码与库存不一致')
|
|
if pallet.asset_status != '0':
|
|
raise ServiceException(message='托盘资产已停用,不允许使用')
|
|
if inventory.flow_state not in {
|
|
PalletFlowState.AT_SITE_UNUSED.value,
|
|
PalletFlowState.AT_SITE_PARTIAL.value,
|
|
}:
|
|
raise ServiceException(message='托盘当前状态不允许使用')
|
|
if inventory.warehouse_id is not 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'
|
|
or filling.closed_at is not None
|
|
):
|
|
raise ServiceException(message='托盘活动装填周期无效')
|
|
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(
|
|
PalletFlowState(inventory.flow_state),
|
|
target_state,
|
|
)
|
|
|
|
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,
|
|
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,
|
|
request_hash=request_hash,
|
|
operator_id=user_id,
|
|
occurred_at=current_time,
|
|
test_batch_id=test_scope.batch_id,
|
|
)
|
|
usage = await OperationDao.add_usage_record(query_db, usage)
|
|
assert_record_in_scope(usage, test_scope, '使用记录')
|
|
usage_id = usage.usage_id
|
|
result = cls._success(usage)
|
|
|
|
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 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:
|
|
filling.active_slot = None
|
|
filling.closed_at = current_time
|
|
filling.update_by = user_name
|
|
filling.update_time = current_time
|
|
await OperationDao.update_filling_record(query_db, filling)
|
|
inventory.active_filling_id = None
|
|
inventory.version += 1
|
|
inventory.updated_at = current_time
|
|
event = BizPalletEvent(
|
|
pallet_id=inventory.pallet_id,
|
|
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,
|
|
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 result
|
|
except IntegrityError:
|
|
await query_db.rollback()
|
|
if expected_site_id is None:
|
|
raise ServiceException(message='使用失败,数据冲突') from None
|
|
return await cls._recover_integrity_error(
|
|
query_db,
|
|
page_object,
|
|
request_hash,
|
|
current_user,
|
|
expected_site_id,
|
|
test_scope,
|
|
)
|
|
except Exception:
|
|
await query_db.rollback()
|
|
raise
|