283 lines
11 KiB
Python
283 lines
11 KiB
Python
import secrets
|
|
from datetime import datetime
|
|
from io import BytesIO
|
|
from typing import Any, Literal
|
|
|
|
import qrcode
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
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 (
|
|
BusinessUserOptionModel,
|
|
DeletePalletModel,
|
|
PalletCreateModel,
|
|
PalletEditModel,
|
|
PalletModel,
|
|
PalletPageQueryModel,
|
|
UserAssignModel,
|
|
)
|
|
|
|
AssignmentResource = Literal['warehouse', 'site']
|
|
|
|
_ASSIGNMENT_LABELS: dict[AssignmentResource, str] = {
|
|
'warehouse': '仓库',
|
|
'site': '客户现场',
|
|
}
|
|
|
|
|
|
class PalletService:
|
|
"""托盘资产及业务用户分配服务层。"""
|
|
|
|
@staticmethod
|
|
def render_qr_png(qr_token: str) -> bytes:
|
|
"""将固定二维码标识渲染为 PNG;补打不会生成新标识。"""
|
|
buffer = BytesIO()
|
|
qrcode.make(qr_token).save(buffer, format='PNG')
|
|
return buffer.getvalue()
|
|
|
|
@classmethod
|
|
async def get_pallet_qrcode(cls, db: AsyncSession, pallet_id: int) -> bytes:
|
|
pallet = await MasterDao.get_pallet(db, pallet_id)
|
|
if pallet is None:
|
|
raise ServiceException(message='托盘不存在')
|
|
return cls.render_qr_png(pallet.qr_token)
|
|
|
|
@staticmethod
|
|
def _extract_pallet_ids(value: int | DeletePalletModel) -> list[int]:
|
|
pallet_ids = [value] if isinstance(value, int) else list(value.pallet_ids)
|
|
if not pallet_ids or any(pallet_id <= 0 for pallet_id in pallet_ids):
|
|
raise ServiceException(message='托盘ID必须为正整数')
|
|
if len(pallet_ids) != len(set(pallet_ids)):
|
|
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]:
|
|
"""按库存、托盘顺序加锁,避免与流转事务形成反序死锁。"""
|
|
inventory = await MasterDao.get_pallet_inventory(db, pallet_id, for_update=True)
|
|
if inventory is None:
|
|
if await MasterDao.get_pallet(db, pallet_id) is None:
|
|
raise ServiceException(message='托盘不存在')
|
|
raise ServiceException(message='托盘库存不存在')
|
|
pallet = await MasterDao.get_pallet(db, pallet_id, for_update=True)
|
|
if pallet is None:
|
|
raise ServiceException(message='托盘不存在')
|
|
return pallet, inventory
|
|
|
|
@classmethod
|
|
async def list_pallet(
|
|
cls,
|
|
db: AsyncSession,
|
|
query_object: PalletPageQueryModel,
|
|
is_page: bool = True,
|
|
) -> PageModel | list[PalletModel]:
|
|
result = await MasterDao.list_pallet(db, query_object, is_page)
|
|
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]
|
|
|
|
@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)
|
|
|
|
@classmethod
|
|
async def add_pallet(
|
|
cls,
|
|
db: AsyncSession,
|
|
pallet: PalletCreateModel,
|
|
current_user_name: str,
|
|
) -> CrudResponseModel:
|
|
try:
|
|
if await MasterDao.find_pallet_by_code(db, pallet.pallet_code) is not None:
|
|
raise ServiceException(message='托盘编号已存在')
|
|
|
|
current_time = datetime.now()
|
|
pallet_record = BizPallet(
|
|
pallet_code=pallet.pallet_code,
|
|
qr_token=secrets.token_urlsafe(32),
|
|
capacity_packages=pallet.capacity_packages,
|
|
asset_status='0',
|
|
remark=pallet.remark,
|
|
create_by=current_user_name,
|
|
create_time=current_time,
|
|
update_by=current_user_name,
|
|
update_time=current_time,
|
|
)
|
|
pallet_record = await MasterDao.add_pallet(db, pallet_record)
|
|
inventory = BizPalletInventory(
|
|
pallet_id=pallet_record.pallet_id,
|
|
flow_state='UNSTOCKED',
|
|
remaining_packages=0,
|
|
version=0,
|
|
updated_at=current_time,
|
|
)
|
|
await MasterDao.add_pallet_inventory(db, inventory)
|
|
await db.commit()
|
|
return CrudResponseModel(is_success=True, message='新增成功')
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ServiceException(message='新增托盘失败,托盘编号或二维码标识已存在') from None
|
|
except Exception:
|
|
await db.rollback()
|
|
raise
|
|
|
|
@classmethod
|
|
async def edit_pallet(
|
|
cls,
|
|
db: AsyncSession,
|
|
pallet: PalletEditModel,
|
|
current_user_name: str,
|
|
) -> CrudResponseModel:
|
|
try:
|
|
_, inventory = await cls._lock_pallet_inventory(db, pallet.pallet_id)
|
|
if pallet.capacity_packages < inventory.remaining_packages:
|
|
raise ServiceException(message='托盘容量不能小于当前剩余包数')
|
|
await MasterDao.update_pallet(
|
|
db,
|
|
pallet,
|
|
update_by=current_user_name,
|
|
update_time=datetime.now(),
|
|
)
|
|
await db.commit()
|
|
return CrudResponseModel(is_success=True, message='更新成功')
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ServiceException(message='修改托盘失败,托盘状态已变化') from None
|
|
except Exception:
|
|
await db.rollback()
|
|
raise
|
|
|
|
@classmethod
|
|
async def remove_pallet(
|
|
cls,
|
|
db: AsyncSession,
|
|
value: int | DeletePalletModel,
|
|
) -> CrudResponseModel:
|
|
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='托盘已有流转事件,不能删除')
|
|
|
|
for pallet_id in pallet_ids:
|
|
await MasterDao.delete_pallet_inventory(db, pallet_id)
|
|
await MasterDao.delete_pallet(db, pallet_id)
|
|
await db.commit()
|
|
return CrudResponseModel(is_success=True, message='删除成功')
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ServiceException(message='删除托盘失败,托盘状态或关联数据已变化') from None
|
|
except Exception:
|
|
await db.rollback()
|
|
raise
|
|
|
|
@classmethod
|
|
async def _list_resource_users(
|
|
cls,
|
|
db: AsyncSession,
|
|
resource: AssignmentResource,
|
|
resource_id: int,
|
|
) -> list[BusinessUserOptionModel]:
|
|
if await getattr(MasterDao, f'get_{resource}')(db, resource_id) is None:
|
|
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,
|
|
)
|
|
)
|
|
return result
|
|
|
|
@classmethod
|
|
async def list_warehouse_users(
|
|
cls,
|
|
db: AsyncSession,
|
|
warehouse_id: int,
|
|
) -> list[BusinessUserOptionModel]:
|
|
return await cls._list_resource_users(db, 'warehouse', warehouse_id)
|
|
|
|
@classmethod
|
|
async def list_site_users(
|
|
cls,
|
|
db: AsyncSession,
|
|
site_id: int,
|
|
) -> list[BusinessUserOptionModel]:
|
|
return await cls._list_resource_users(db, 'site', site_id)
|
|
|
|
@classmethod
|
|
async def _assign_resource_users(
|
|
cls,
|
|
db: AsyncSession,
|
|
resource: AssignmentResource,
|
|
resource_id: int,
|
|
assignment: UserAssignModel,
|
|
) -> CrudResponseModel:
|
|
label = _ASSIGNMENT_LABELS[resource]
|
|
try:
|
|
if await getattr(MasterDao, f'get_{resource}')(db, resource_id) 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):
|
|
raise ServiceException(message='存在不存在、停用或已删除的用户')
|
|
await getattr(AccessDao, f'replace_{resource}_users')(db, resource_id, assignment.user_ids)
|
|
await db.commit()
|
|
return CrudResponseModel(is_success=True, message='分配成功')
|
|
except IntegrityError:
|
|
await db.rollback()
|
|
raise ServiceException(message=f'分配{label}用户失败,关联数据无效') from None
|
|
except Exception:
|
|
await db.rollback()
|
|
raise
|
|
|
|
@classmethod
|
|
async def assign_warehouse_users(
|
|
cls,
|
|
db: AsyncSession,
|
|
warehouse_id: int,
|
|
assignment: UserAssignModel,
|
|
) -> CrudResponseModel:
|
|
return await cls._assign_resource_users(db, 'warehouse', warehouse_id, assignment)
|
|
|
|
@classmethod
|
|
async def assign_site_users(
|
|
cls,
|
|
db: AsyncSession,
|
|
site_id: int,
|
|
assignment: UserAssignModel,
|
|
) -> CrudResponseModel:
|
|
return await cls._assign_resource_users(db, 'site', site_id, assignment)
|