Files
ShiErFeng/shierfeng-fastapi-backend/module_business/service/filling_service.py
T
yujiangandClaude Opus 5.5 3050ccdc4e Feat: 后端新增基础资料与业务管理只读汇总接口
为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情
返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。

- 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转,
  托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级
  资料选项接口;Excel 导入支持 preview 只校验不写入
- 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和
  菌种构成;详情明细补充托盘号、菌种、批次
- 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary
- 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道;
  新增 /summary、/site-options
- 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、
  /batches、/batches/detail
- 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号;
  新增 /site-options、/site-board 以及 usage-fifo 的 /queue
- 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致
- 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:10 -07:00

624 lines
25 KiB
Python

import hashlib
import json
import secrets
from datetime import datetime, timedelta
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.filling_summary_dao import FillingSummaryDao
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.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, BizStrain
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory
from module_business.entity.vo.master_vo import StrainPageQueryModel
from module_business.entity.vo.operation_vo import (
BusinessOptionModel,
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingCreateModel,
FillingListItemModel,
FillingModel,
FillingOverviewModel,
FillingPageQueryModel,
PalletScanResponseModel,
)
from module_business.service.access_service import AccessService
from module_business.service.test_batch_access_service import TestBatchAccessService
class FillingService:
"""仓库内单托盘即时装填服务层。"""
# 有效期剩余不超过该天数的在仓批次视为临期
NEAR_EXPIRY_DAYS = 14
@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)
@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: FillingCreateModel) -> str:
payload = json.dumps(
{
'qr_token': page_object.qr_token.strip(),
'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,
},
sort_keys=True,
separators=(',', ':'),
).encode()
return hashlib.sha256(payload).hexdigest()
@classmethod
async def _warehouse_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_warehouse_ids(query_db, user_id)))
@classmethod
def _raise_filling_not_accessible(cls, current_user: CurrentUserModel) -> NoReturn:
if cls._is_admin(current_user):
raise ServiceException(message='装填记录不存在')
raise PermissionException(data='', message='无权访问该装填记录')
@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_warehouse_access(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
warehouse_id: int,
*,
for_update: bool = False,
) -> None:
try:
if for_update:
await AccessService.assert_warehouse_access(
query_db,
current_user,
warehouse_id,
for_update=True,
)
else:
await AccessService.assert_warehouse_access(query_db, current_user, warehouse_id)
except PermissionException:
if cls._is_admin(current_user):
raise
cls._raise_pallet_not_accessible(current_user)
@staticmethod
def _validate_empty_warehouse_pallet(
pallet: BizPallet,
inventory: BizPalletInventory,
warehouse_id: int,
qr_token: str,
) -> None:
if pallet.pallet_id != inventory.pallet_id or pallet.qr_token != qr_token:
raise ServiceException(message='托盘二维码与库存不一致')
if pallet.asset_status != '0':
raise ServiceException(message='托盘资产已停用,不允许装填')
if (
inventory.flow_state != PalletFlowState.EMPTY_IN_WAREHOUSE.value
or inventory.warehouse_id != warehouse_id
or inventory.site_id is not None
or inventory.current_line_id is not None
or inventory.active_filling_id is not None
or inventory.remaining_packages != 0
):
raise ServiceException(message='托盘不符合仓库空托盘装填条件')
if type(inventory.version) is not int or inventory.version < 0:
raise ServiceException(message='托盘库存版本无效')
@staticmethod
def _validate_package_count(initial_packages: int, pallet: BizPallet) -> None:
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:
if strain is None:
raise ServiceException(message='菌种不存在')
if strain.status != '0':
raise ServiceException(message='菌种已停用')
return strain
@staticmethod
def _is_exact_idempotent_result(
filling: BizFillingRecord,
page_object: FillingCreateModel,
request_hash: str,
) -> bool:
return (
filling.idempotency_key == page_object.idempotency_key
and filling.warehouse_id == page_object.warehouse_id
and filling.request_hash == request_hash
)
@staticmethod
def _success(filling_id: int) -> CrudResponseModel:
return CrudResponseModel(is_success=True, message='装填成功', result=filling_id)
@classmethod
async def get_filling_list_services(
cls,
query_db: AsyncSession,
query_object: FillingPageQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
is_page: bool = True,
) -> PageModel | list[FillingListItemModel]:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if (
warehouse_ids is not None
and query_object.warehouse_id is not None
and query_object.warehouse_id not in warehouse_ids
):
raise PermissionException(data='', message='无权访问该仓库')
result = await OperationDao.get_filling_list(
query_db,
query_object,
warehouse_ids=warehouse_ids,
scope=test_scope,
is_page=is_page,
)
rows = result.rows if isinstance(result, PageModel) else result
records = [FillingModel.model_validate(record) for record in rows]
# 仅对本页已按数据范围筛出的记录补充托盘编号、仓库与操作人名称
summaries = await FillingSummaryDao.get_list_summaries(
query_db,
[record.filling_id for record in records],
test_scope,
)
models = [
FillingListItemModel.model_validate({**record.model_dump(), **summaries.get(record.filling_id, {})})
for record in records
]
if isinstance(result, PageModel):
return result.model_copy(update={'rows': models})
return models
@classmethod
async def get_filling_batches_services(
cls,
query_db: AsyncSession,
query_object: FillingBatchPageQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PageModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
batches, total = await FillingSummaryDao.get_batch_page(query_db, query_object, warehouse_ids, test_scope)
return PageModel(
rows=[FillingBatchModel.model_validate(batch) for batch in batches],
pageNum=query_object.page_num,
pageSize=query_object.page_size,
total=total,
hasNext=query_object.page_num * query_object.page_size < total,
)
@classmethod
async def get_filling_batch_detail_services(
cls,
query_db: AsyncSession,
query_object: FillingBatchDetailQueryModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> FillingBatchDetailModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
batch = await FillingSummaryDao.get_batch_detail(
query_db,
query_object.strain_id,
query_object.batch_no,
warehouse_ids,
test_scope,
)
if batch is None:
if cls._is_admin(current_user):
raise ServiceException(message='生产批次不存在')
raise PermissionException(data='', message='无权访问该生产批次')
return FillingBatchDetailModel.model_validate(batch)
@classmethod
async def get_filling_overview_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> FillingOverviewModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
overview = await FillingSummaryDao.get_overview(
query_db,
warehouse_ids,
test_scope,
today_start,
today_start.replace(day=1),
(today_start + timedelta(days=cls.NEAR_EXPIRY_DAYS)).date(),
cls.NEAR_EXPIRY_DAYS,
)
return FillingOverviewModel.model_validate(overview)
@classmethod
async def filling_detail_services(
cls,
query_db: AsyncSession,
filling_id: int,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> FillingModel:
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
filling = await OperationDao.get_filling_by_id(
query_db,
filling_id,
test_scope,
warehouse_ids=warehouse_ids,
)
if filling is None:
cls._raise_filling_not_accessible(current_user)
return FillingModel.model_validate(filling)
@classmethod
async def get_strain_options_services(
cls,
query_db: AsyncSession,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> list[BusinessOptionModel]:
cls._current_identity(current_user)
strains = await MasterDao.list_strain(
query_db,
StrainPageQueryModel(status='0'),
test_scope,
False,
)
options = [
BusinessOptionModel(
id=cls._record_value(strain, 'strain_id'),
code=cls._record_value(strain, 'strain_code'),
name=cls._record_value(strain, 'strain_name'),
)
for strain in strains
if cls._record_value(strain, 'status') == '0'
]
return sorted(options, key=lambda option: option.id)
@classmethod
async def scan_filling_pallet_services(
cls,
query_db: AsyncSession,
qr_token: str,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> PalletScanResponseModel:
pallet = await InventoryDao.get_pallet_by_qr_token(query_db, qr_token, 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.warehouse_id is None:
cls._raise_pallet_not_accessible(current_user, '托盘不在仓库')
await cls._assert_pallet_warehouse_access(
query_db,
current_user,
inventory.warehouse_id,
)
cls._validate_empty_warehouse_pallet(
pallet,
inventory,
inventory.warehouse_id,
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,
site_id=None,
current_line_id=None,
active_filling_id=None,
strain_id=None,
strain_code=None,
strain_name=None,
batch_no=None,
package_spec=None,
capacity_packages=pallet.capacity_packages,
remaining_packages=0,
allowed_actions=['filling'],
)
@classmethod
async def _recover_integrity_error(
cls,
query_db: AsyncSession,
page_object: FillingCreateModel,
request_hash: str,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
try:
await TestBatchAccessService.lock_active_batch(query_db, test_scope)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if warehouse_ids is not None and page_object.warehouse_id not in warehouse_ids:
raise PermissionException(data='', message='无权访问该仓库')
recovered = await OperationDao.get_filling_by_idempotency_key(
query_db,
page_object.idempotency_key,
test_scope,
warehouse_ids=[page_object.warehouse_id],
)
if recovered is None:
raise ServiceException(message='装填失败,数据冲突')
await AccessService.assert_warehouse_access(
query_db,
current_user,
recovered.warehouse_id,
)
if not cls._is_exact_idempotent_result(recovered, page_object, request_hash):
raise ServiceException(message='装填失败,幂等键已被其他请求使用')
return cls._success(recovered.filling_id)
finally:
await query_db.rollback()
@classmethod
async def fill_pallet_services( # noqa: PLR0912, PLR0915
cls,
query_db: AsyncSession,
page_object: FillingCreateModel,
current_user: CurrentUserModel,
test_scope: TestBatchScope,
) -> CrudResponseModel:
recovery_transaction_ended = False
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)
warehouse_ids = await cls._warehouse_scope(query_db, current_user)
if warehouse_ids is not None and page_object.warehouse_id not in warehouse_ids:
raise PermissionException(data='', message='无权访问该仓库')
existing = await OperationDao.get_filling_by_idempotency_key(
query_db,
page_object.idempotency_key,
test_scope,
warehouse_ids=[page_object.warehouse_id],
)
if existing is not None:
assert_record_in_scope(existing, test_scope, '装填记录')
await AccessService.assert_warehouse_access(
query_db,
current_user,
existing.warehouse_id,
)
if not cls._is_exact_idempotent_result(existing, page_object, request_hash):
raise ServiceException(message='装填失败,幂等键已被其他请求使用')
filling_id = existing.filling_id
await query_db.rollback()
return cls._success(filling_id)
await AccessService.assert_warehouse_access(
query_db,
current_user,
page_object.warehouse_id,
)
scanned_pallet = await InventoryDao.get_pallet_by_qr_token(
query_db,
page_object.qr_token,
test_scope,
)
if scanned_pallet is None:
cls._raise_pallet_not_accessible(current_user)
assert_record_in_scope(scanned_pallet, test_scope, '托盘')
inventory = await InventoryDao.get_inventory_for_update(
query_db,
scanned_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.warehouse_id is None:
cls._raise_pallet_not_accessible(current_user, '托盘不在仓库')
await cls._assert_pallet_warehouse_access(
query_db,
current_user,
inventory.warehouse_id,
)
locked_pallets = await InventoryDao.get_pallets_by_ids(
query_db,
[scanned_pallet.pallet_id],
test_scope,
)
if len(locked_pallets) != 1 or locked_pallets[0].pallet_id != scanned_pallet.pallet_id:
cls._raise_pallet_not_accessible(current_user)
pallet = locked_pallets[0]
assert_record_in_scope(pallet, test_scope, '托盘')
if inventory.active_filling_id is not None:
concurrent = await OperationDao.get_active_filling_for_update(
query_db,
inventory.active_filling_id,
test_scope,
)
if concurrent is not None and concurrent.idempotency_key == page_object.idempotency_key:
assert_record_in_scope(concurrent, test_scope, '活动装填')
# 结束首次非锁定读取建立的 RR 快照,再按最新数据范围恢复并发成功结果。
await query_db.rollback()
recovery_transaction_ended = True
return await cls._recover_integrity_error(
query_db,
page_object,
request_hash,
current_user,
test_scope,
)
warehouse = await MasterDao.get_warehouse(
query_db,
inventory.warehouse_id,
test_scope,
for_update=True,
)
if warehouse is None:
raise ServiceException(message='仓库不存在')
assert_record_in_scope(warehouse, test_scope, '仓库')
if warehouse.status != '0':
raise ServiceException(message='仓库已停用')
await cls._assert_pallet_warehouse_access(
query_db,
current_user,
inventory.warehouse_id,
for_update=True,
)
strain = cls._validate_active_strain(
await MasterDao.get_strain(
query_db,
page_object.strain_id,
test_scope,
for_update=True,
)
)
assert_record_in_scope(strain, test_scope, '菌种')
cls._validate_empty_warehouse_pallet(
pallet,
inventory,
page_object.warehouse_id,
page_object.qr_token,
)
cls._validate_package_count(page_object.initial_packages, pallet)
PalletStateMachine.ensure_transition(
PalletFlowState(inventory.flow_state),
PalletFlowState.FILLED_IN_WAREHOUSE,
)
current_time = datetime.now()
filling = BizFillingRecord(
filling_no=f'FL-{secrets.token_hex(16)}',
pallet_id=pallet.pallet_id,
warehouse_id=inventory.warehouse_id,
strain_id=strain.strain_id,
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',
idempotency_key=page_object.idempotency_key,
request_hash=request_hash,
operator_id=user_id,
operated_at=current_time,
create_by=user_name,
create_time=current_time,
update_by=user_name,
update_time=current_time,
test_batch_id=test_scope.batch_id,
)
filling = await OperationDao.add_filling_record(query_db, filling)
filling_id = filling.filling_id
before_state = inventory.flow_state
before_warehouse_id = inventory.warehouse_id
before_site_id = inventory.site_id
before_line_id = inventory.current_line_id
before_remaining_packages = inventory.remaining_packages
inventory.active_filling_id = filling_id
inventory.remaining_packages = page_object.initial_packages
inventory.flow_state = PalletFlowState.FILLED_IN_WAREHOUSE.value
inventory.version += 1
inventory.updated_at = current_time
event = BizPalletEvent(
pallet_id=inventory.pallet_id,
event_type=PalletEventType.FILLING_CONFIRMED.value,
source_type='FILLING',
source_id=filling_id,
before_state=before_state,
after_state=PalletFlowState.FILLED_IN_WAREHOUSE.value,
before_warehouse_id=before_warehouse_id,
before_site_id=before_site_id,
before_line_id=before_line_id,
after_warehouse_id=inventory.warehouse_id,
after_site_id=inventory.site_id,
after_line_id=inventory.current_line_id,
before_remaining_packages=before_remaining_packages,
after_remaining_packages=page_object.initial_packages,
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 cls._success(filling_id)
except IntegrityError:
await query_db.rollback()
return await cls._recover_integrity_error(
query_db,
page_object,
request_hash,
current_user,
test_scope,
)
except Exception:
if not recovery_transaction_ended:
await query_db.rollback()
raise