修复:消除托盘与用户分配查询竞态
This commit is contained in:
@@ -2,12 +2,13 @@ from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import ColumnElement, Select, delete, func, or_, select, update
|
||||
from sqlalchemy import ColumnElement, Select, and_, delete, func, or_, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import InstrumentedAttribute
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_admin.entity.do.user_do import SysUser
|
||||
from module_business.domain.enums import PalletFlowState
|
||||
from module_business.entity.do.master_do import (
|
||||
BizCustomer,
|
||||
BizCustomerSite,
|
||||
@@ -20,10 +21,13 @@ from module_business.entity.do.master_do import (
|
||||
)
|
||||
from module_business.entity.do.operation_do import (
|
||||
BizFillingRecord,
|
||||
BizInboundItem,
|
||||
BizInboundOrder,
|
||||
BizPalletEvent,
|
||||
BizPalletInventory,
|
||||
BizReturnItem,
|
||||
BizReturnOrder,
|
||||
BizShipmentItem,
|
||||
BizShipmentOrder,
|
||||
BizUsageRecord,
|
||||
)
|
||||
@@ -74,6 +78,15 @@ _REFERENCE_COLUMNS: dict[MasterResource, tuple[tuple[InstrumentedAttribute, ...]
|
||||
),
|
||||
}
|
||||
|
||||
_PALLET_REMOVAL_BLOCKING_COLUMNS: tuple[InstrumentedAttribute, ...] = (
|
||||
BizInboundItem.pallet_id,
|
||||
BizFillingRecord.pallet_id,
|
||||
BizShipmentItem.pallet_id,
|
||||
BizUsageRecord.pallet_id,
|
||||
BizReturnItem.pallet_id,
|
||||
BizPalletEvent.pallet_id,
|
||||
)
|
||||
|
||||
|
||||
class MasterDao:
|
||||
"""菌种、客户、现场、产线和仓库主数据数据库操作层。"""
|
||||
@@ -99,8 +112,13 @@ class MasterDao:
|
||||
model: type[Any],
|
||||
id_column: InstrumentedAttribute,
|
||||
resource_id: int,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> Any | None:
|
||||
result = await db.execute(select(model).where(id_column == resource_id))
|
||||
statement = select(model).where(id_column == resource_id)
|
||||
if for_update:
|
||||
statement = statement.with_for_update()
|
||||
result = await db.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
@@ -245,8 +263,20 @@ class MasterDao:
|
||||
return await cls._paginate(db, statement, query_object, is_page)
|
||||
|
||||
@classmethod
|
||||
async def get_site(cls, db: AsyncSession, site_id: int) -> BizCustomerSite | None:
|
||||
return await cls._get(db, BizCustomerSite, BizCustomerSite.site_id, site_id)
|
||||
async def get_site(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
site_id: int,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> BizCustomerSite | None:
|
||||
return await cls._get(
|
||||
db,
|
||||
BizCustomerSite,
|
||||
BizCustomerSite.site_id,
|
||||
site_id,
|
||||
for_update=for_update,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def find_site_by_code(
|
||||
@@ -343,8 +373,20 @@ class MasterDao:
|
||||
return await cls._paginate(db, statement, query_object, is_page)
|
||||
|
||||
@classmethod
|
||||
async def get_warehouse(cls, db: AsyncSession, warehouse_id: int) -> BizWarehouse | None:
|
||||
return await cls._get(db, BizWarehouse, BizWarehouse.warehouse_id, warehouse_id)
|
||||
async def get_warehouse(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
warehouse_id: int,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> BizWarehouse | None:
|
||||
return await cls._get(
|
||||
db,
|
||||
BizWarehouse,
|
||||
BizWarehouse.warehouse_id,
|
||||
warehouse_id,
|
||||
for_update=for_update,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def find_warehouse_by_code(cls, db: AsyncSession, warehouse_code: str) -> BizWarehouse | None:
|
||||
@@ -391,6 +433,37 @@ class MasterDao:
|
||||
result = await db.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
async def get_pallet_removability(
|
||||
cls,
|
||||
db: AsyncSession,
|
||||
pallet_ids: list[int],
|
||||
) -> dict[int, bool]:
|
||||
"""批量判断托盘是否仍为未入库且没有任何业务引用。"""
|
||||
removability = dict.fromkeys(pallet_ids, False)
|
||||
if not pallet_ids:
|
||||
return removability
|
||||
|
||||
has_unstocked_inventory = (
|
||||
select(BizPalletInventory.pallet_id)
|
||||
.where(
|
||||
BizPalletInventory.pallet_id == BizPallet.pallet_id,
|
||||
BizPalletInventory.flow_state == PalletFlowState.UNSTOCKED.value,
|
||||
)
|
||||
.exists()
|
||||
)
|
||||
has_no_business_references = tuple(
|
||||
~select(column).where(column == BizPallet.pallet_id).exists() for column in _PALLET_REMOVAL_BLOCKING_COLUMNS
|
||||
)
|
||||
statement = select(
|
||||
BizPallet.pallet_id,
|
||||
and_(has_unstocked_inventory, *has_no_business_references).label('can_remove'),
|
||||
).where(BizPallet.pallet_id.in_(pallet_ids))
|
||||
result = await db.execute(statement)
|
||||
for pallet_id, can_remove in result.all():
|
||||
removability[pallet_id] = bool(can_remove)
|
||||
return removability
|
||||
|
||||
@classmethod
|
||||
async def find_pallet_by_code(cls, db: AsyncSession, pallet_code: str) -> BizPallet | None:
|
||||
return await cls._find(db, BizPallet, BizPallet.pallet_code, pallet_code)
|
||||
@@ -484,6 +557,16 @@ class MasterDao:
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
@classmethod
|
||||
async def list_warehouse_assigned_user_ids(cls, db: AsyncSession, warehouse_id: int) -> list[int]:
|
||||
result = await db.execute(select(BizUserWarehouse.user_id).where(BizUserWarehouse.warehouse_id == warehouse_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def list_site_assigned_user_ids(cls, db: AsyncSession, site_id: int) -> list[int]:
|
||||
result = await db.execute(select(BizUserSite.user_id).where(BizUserSite.site_id == site_id))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_references(
|
||||
cls,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from io import BytesIO
|
||||
from typing import Any, Literal
|
||||
from typing import Literal
|
||||
|
||||
import qrcode
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
@@ -11,7 +11,6 @@ from common.vo import CrudResponseModel, PageModel
|
||||
from exceptions.exception import ServiceException
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.domain.enums import PalletFlowState
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizPalletInventory
|
||||
from module_business.entity.vo.master_vo import (
|
||||
@@ -58,18 +57,6 @@ class PalletService:
|
||||
raise ServiceException(message='托盘ID不能重复')
|
||||
return sorted(pallet_ids)
|
||||
|
||||
@classmethod
|
||||
async def _can_remove(cls, db: AsyncSession, pallet_id: int) -> bool:
|
||||
inventory = await MasterDao.get_pallet_inventory(db, pallet_id)
|
||||
if inventory is None or inventory.flow_state != PalletFlowState.UNSTOCKED.value:
|
||||
return False
|
||||
return await MasterDao.count_pallet_events(db, pallet_id) == 0
|
||||
|
||||
@classmethod
|
||||
async def _to_pallet_model(cls, db: AsyncSession, record: Any) -> PalletModel:
|
||||
model = PalletModel.model_validate(record)
|
||||
return model.model_copy(update={'can_remove': await cls._can_remove(db, model.pallet_id)})
|
||||
|
||||
@classmethod
|
||||
async def _lock_pallet_inventory(cls, db: AsyncSession, pallet_id: int) -> tuple[BizPallet, BizPalletInventory]:
|
||||
"""按库存、托盘顺序加锁,避免与流转事务形成反序死锁。"""
|
||||
@@ -91,17 +78,25 @@ class PalletService:
|
||||
is_page: bool = True,
|
||||
) -> PageModel | list[PalletModel]:
|
||||
result = await MasterDao.list_pallet(db, query_object, is_page)
|
||||
records = result.rows if isinstance(result, PageModel) else result
|
||||
models = [PalletModel.model_validate(record) for record in records]
|
||||
removability = await MasterDao.get_pallet_removability(
|
||||
db,
|
||||
[model.pallet_id for model in models],
|
||||
)
|
||||
rows = [model.model_copy(update={'can_remove': removability.get(model.pallet_id, False)}) for model in models]
|
||||
if isinstance(result, PageModel):
|
||||
rows = [await cls._to_pallet_model(db, row) for row in result.rows]
|
||||
return result.model_copy(update={'rows': rows})
|
||||
return [await cls._to_pallet_model(db, row) for row in result]
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
async def detail_pallet(cls, db: AsyncSession, pallet_id: int) -> PalletModel:
|
||||
pallet = await MasterDao.get_pallet(db, pallet_id)
|
||||
if pallet is None:
|
||||
raise ServiceException(message='托盘不存在')
|
||||
return await cls._to_pallet_model(db, pallet)
|
||||
model = PalletModel.model_validate(pallet)
|
||||
removability = await MasterDao.get_pallet_removability(db, [pallet_id])
|
||||
return model.model_copy(update={'can_remove': removability.get(pallet_id, False)})
|
||||
|
||||
@classmethod
|
||||
async def add_pallet(
|
||||
@@ -179,11 +174,11 @@ class PalletService:
|
||||
try:
|
||||
pallet_ids = cls._extract_pallet_ids(value)
|
||||
for pallet_id in pallet_ids:
|
||||
_, inventory = await cls._lock_pallet_inventory(db, pallet_id)
|
||||
if inventory.flow_state != PalletFlowState.UNSTOCKED.value:
|
||||
raise ServiceException(message='托盘未处于未入库状态,不能删除')
|
||||
if await MasterDao.count_pallet_events(db, pallet_id) > 0:
|
||||
raise ServiceException(message='托盘已有流转事件,不能删除')
|
||||
await cls._lock_pallet_inventory(db, pallet_id)
|
||||
|
||||
removability = await MasterDao.get_pallet_removability(db, pallet_ids)
|
||||
if not all(removability.get(pallet_id, False) for pallet_id in pallet_ids):
|
||||
raise ServiceException(message='托盘状态或关联数据不允许删除')
|
||||
|
||||
for pallet_id in pallet_ids:
|
||||
await MasterDao.delete_pallet_inventory(db, pallet_id)
|
||||
@@ -208,19 +203,16 @@ class PalletService:
|
||||
raise ServiceException(message=f'{_ASSIGNMENT_LABELS[resource]}不存在')
|
||||
|
||||
users = await MasterDao.list_assignable_users(db)
|
||||
result = []
|
||||
association_method = getattr(AccessDao, f'list_user_{resource}_ids')
|
||||
for user in users:
|
||||
assigned_ids = await association_method(db, user.user_id)
|
||||
result.append(
|
||||
BusinessUserOptionModel(
|
||||
user_id=user.user_id,
|
||||
user_name=user.user_name,
|
||||
nick_name=user.nick_name,
|
||||
assigned=resource_id in assigned_ids,
|
||||
)
|
||||
assigned_user_ids = set(await getattr(MasterDao, f'list_{resource}_assigned_user_ids')(db, resource_id))
|
||||
return [
|
||||
BusinessUserOptionModel(
|
||||
user_id=user.user_id,
|
||||
user_name=user.user_name,
|
||||
nick_name=user.nick_name,
|
||||
assigned=user.user_id in assigned_user_ids,
|
||||
)
|
||||
return result
|
||||
for user in users
|
||||
]
|
||||
|
||||
@classmethod
|
||||
async def list_warehouse_users(
|
||||
@@ -248,7 +240,14 @@ class PalletService:
|
||||
) -> CrudResponseModel:
|
||||
label = _ASSIGNMENT_LABELS[resource]
|
||||
try:
|
||||
if await getattr(MasterDao, f'get_{resource}')(db, resource_id) is None:
|
||||
if (
|
||||
await getattr(MasterDao, f'get_{resource}')(
|
||||
db,
|
||||
resource_id,
|
||||
for_update=True,
|
||||
)
|
||||
is None
|
||||
):
|
||||
raise ServiceException(message=f'{label}不存在')
|
||||
users = await MasterDao.list_assignable_users_by_ids(db, assignment.user_ids)
|
||||
if {user.user_id for user in users} != set(assignment.user_ids):
|
||||
|
||||
Reference in New Issue
Block a user