功能:新增托盘库存事务基础

This commit is contained in:
焦龙言
2026-07-26 13:18:56 +08:00
parent 2e35da13cc
commit 85905d85ca
3 changed files with 1591 additions and 0 deletions
@@ -0,0 +1,101 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizPalletEvent, BizPalletInventory
class InventoryDao:
"""托盘库存锁、资产锁和只追加事件数据库操作层。"""
@classmethod
async def get_inventory_for_update(
cls,
db: AsyncSession,
pallet_id: int,
) -> BizPalletInventory | None:
result = await db.execute(
select(BizPalletInventory).where(BizPalletInventory.pallet_id == pallet_id).with_for_update()
)
return result.scalar_one_or_none()
@classmethod
async def get_inventories_for_update(
cls,
db: AsyncSession,
pallet_ids: list[int],
) -> list[BizPalletInventory]:
ordered_ids = sorted(set(pallet_ids))
if not ordered_ids:
return []
result = await db.execute(
select(BizPalletInventory)
.where(BizPalletInventory.pallet_id.in_(ordered_ids))
.order_by(BizPalletInventory.pallet_id)
.with_for_update()
)
return sorted(result.scalars().all(), key=lambda inventory: inventory.pallet_id)
@classmethod
async def get_pallet_by_qr_token(
cls,
db: AsyncSession,
qr_token: str,
) -> BizPallet | None:
result = await db.execute(select(BizPallet).where(BizPallet.qr_token == qr_token))
return result.scalar_one_or_none()
@classmethod
async def get_pallets_by_ids(
cls,
db: AsyncSession,
pallet_ids: list[int],
) -> list[BizPallet]:
"""在调用方已锁库存后,按托盘 ID 升序锁定资产行。"""
ordered_ids = sorted(set(pallet_ids))
if not ordered_ids:
return []
result = await db.execute(
select(BizPallet)
.where(BizPallet.pallet_id.in_(ordered_ids))
.order_by(BizPallet.pallet_id)
.with_for_update()
)
return sorted(result.scalars().all(), key=lambda pallet: pallet.pallet_id)
@classmethod
async def get_event_by_idempotency_key(
cls,
db: AsyncSession,
pallet_id: int,
idempotency_key: str,
) -> BizPalletEvent | None:
result = await db.execute(
select(BizPalletEvent).where(
BizPalletEvent.pallet_id == pallet_id,
BizPalletEvent.idempotency_key == idempotency_key,
)
)
return result.scalar_one_or_none()
@classmethod
async def add_event(
cls,
db: AsyncSession,
inventory: BizPalletInventory,
event: BizPalletEvent,
) -> BizPalletEvent:
"""在调用方持有库存行锁时,为托盘追加下一序号事件。"""
sequence_result = await db.execute(
select(func.max(BizPalletEvent.event_seq)).where(BizPalletEvent.pallet_id == inventory.pallet_id)
)
last_sequence = sequence_result.scalar_one()
event.pallet_id = inventory.pallet_id
event.event_seq = (last_sequence or 0) + 1
db.add(event)
await db.flush()
inventory.last_event_id = event.event_id
await db.flush()
return event
@@ -0,0 +1,667 @@
from datetime import datetime, time
from typing import Any
from sqlalchemy import ColumnElement, Select, delete, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizInboundItem,
BizInboundOrder,
BizReturnItem,
BizReturnOrder,
BizShipmentItem,
BizShipmentOrder,
BizUsageRecord,
)
from module_business.entity.vo.operation_vo import (
FillingPageQueryModel,
InboundPageQueryModel,
ReturnPageQueryModel,
ShipmentPageQueryModel,
UsagePageQueryModel,
)
from utils.page_util import PageUtil
class OperationDao:
"""托盘入库、装填、发货、使用和退回单据数据库操作层。"""
@staticmethod
def _time_conditions(
column: InstrumentedAttribute,
begin_time: str | None,
end_time: str | None,
) -> list[ColumnElement[bool]]:
conditions = []
if begin_time:
begin_at = datetime.combine(
datetime.strptime(begin_time, '%Y-%m-%d').date(),
time.min,
)
conditions.append(column >= begin_at)
if end_time:
end_at = datetime.combine(
datetime.strptime(end_time, '%Y-%m-%d').date(),
time(23, 59, 59),
)
conditions.append(column <= end_at)
return conditions
@staticmethod
async def _paginate(
db: AsyncSession,
statement: Select,
query_object: Any,
is_page: bool,
) -> PageModel | list[dict[str, Any]]:
return await PageUtil.paginate(
db,
statement,
query_object.page_num,
query_object.page_size,
is_page,
)
@staticmethod
async def _get_for_update(
db: AsyncSession,
model: type[Any],
id_column: InstrumentedAttribute,
record_id: int,
*extra_conditions: ColumnElement[bool],
) -> Any | None:
result = await db.execute(select(model).where(id_column == record_id, *extra_conditions).with_for_update())
return result.scalar_one_or_none()
@staticmethod
async def _get_by_key(
db: AsyncSession,
model: type[Any],
key_column: InstrumentedAttribute,
idempotency_key: str,
) -> Any | None:
result = await db.execute(select(model).where(key_column == idempotency_key))
return result.scalar_one_or_none()
@staticmethod
async def _get_by_id(
db: AsyncSession,
model: type[Any],
id_column: InstrumentedAttribute,
record_id: int,
) -> Any | None:
result = await db.execute(select(model).where(id_column == record_id))
return result.scalar_one_or_none()
@classmethod
async def _get_order_detail(
cls,
db: AsyncSession,
order_model: type[Any],
order_id_column: InstrumentedAttribute,
order_id: int,
item_model: type[Any],
item_order_id_column: InstrumentedAttribute,
item_id_column: InstrumentedAttribute,
) -> Any | None:
order = await cls._get_by_id(db, order_model, order_id_column, order_id)
if order is None:
return None
item_result = await db.execute(
select(item_model).where(item_order_id_column == order_id).order_by(item_id_column)
)
order.items = list(item_result.scalars().all())
return order
@staticmethod
async def _add(db: AsyncSession, record: Any) -> Any:
db.add(record)
await db.flush()
return record
@staticmethod
async def _update(
db: AsyncSession,
model: type[Any],
id_column: InstrumentedAttribute,
record: Any,
) -> Any:
primary_key = id_column.key
payload = {
column.key: vars(record)[column.key]
for column in model.__table__.columns
if column.key != primary_key and column.key in vars(record)
}
if payload:
await db.execute(update(model).where(id_column == getattr(record, primary_key)).values(**payload))
await db.flush()
return record
@staticmethod
async def _delete(
db: AsyncSession,
model: type[Any],
id_column: InstrumentedAttribute,
record_id: int,
) -> None:
await db.execute(delete(model).where(id_column == record_id))
await db.flush()
@staticmethod
async def _replace_items(
db: AsyncSession,
item_model: type[Any],
order_id_column: InstrumentedAttribute,
order_id: int,
items: list[Any],
) -> list[Any]:
await db.execute(delete(item_model).where(order_id_column == order_id))
for item in items:
setattr(item, order_id_column.key, order_id)
if items:
db.add_all(items)
await db.flush()
return items
@staticmethod
async def _add_items(db: AsyncSession, items: list[Any]) -> list[Any]:
if items:
db.add_all(items)
await db.flush()
return items
@classmethod
async def get_inbound_for_update(
cls,
db: AsyncSession,
inbound_id: int,
) -> BizInboundOrder | None:
return await cls._get_for_update(
db,
BizInboundOrder,
BizInboundOrder.inbound_id,
inbound_id,
)
@classmethod
async def get_shipment_for_update(
cls,
db: AsyncSession,
shipment_id: int,
) -> BizShipmentOrder | None:
return await cls._get_for_update(
db,
BizShipmentOrder,
BizShipmentOrder.shipment_id,
shipment_id,
)
@classmethod
async def get_return_for_update(
cls,
db: AsyncSession,
return_id: int,
) -> BizReturnOrder | None:
return await cls._get_for_update(
db,
BizReturnOrder,
BizReturnOrder.return_id,
return_id,
)
@classmethod
async def get_active_filling_for_update(
cls,
db: AsyncSession,
filling_id: int,
) -> BizFillingRecord | None:
"""按已锁库存的 active_filling_id 锁定仍处于活动状态的装填周期。"""
return await cls._get_for_update(
db,
BizFillingRecord,
BizFillingRecord.filling_id,
filling_id,
BizFillingRecord.active_slot == 'ACTIVE',
)
@classmethod
async def get_inbound_by_confirm_idempotency_key(
cls,
db: AsyncSession,
idempotency_key: str,
) -> BizInboundOrder | None:
return await cls._get_by_key(
db,
BizInboundOrder,
BizInboundOrder.confirm_idempotency_key,
idempotency_key,
)
@classmethod
async def get_shipment_by_confirm_idempotency_key(
cls,
db: AsyncSession,
idempotency_key: str,
) -> BizShipmentOrder | None:
return await cls._get_by_key(
db,
BizShipmentOrder,
BizShipmentOrder.confirm_idempotency_key,
idempotency_key,
)
@classmethod
async def get_filling_by_idempotency_key(
cls,
db: AsyncSession,
idempotency_key: str,
) -> BizFillingRecord | None:
return await cls._get_by_key(
db,
BizFillingRecord,
BizFillingRecord.idempotency_key,
idempotency_key,
)
@classmethod
async def get_usage_by_idempotency_key(
cls,
db: AsyncSession,
idempotency_key: str,
) -> BizUsageRecord | None:
return await cls._get_by_key(
db,
BizUsageRecord,
BizUsageRecord.idempotency_key,
idempotency_key,
)
@classmethod
async def get_return_by_request_idempotency_key(
cls,
db: AsyncSession,
idempotency_key: str,
) -> BizReturnOrder | None:
return await cls._get_by_key(
db,
BizReturnOrder,
BizReturnOrder.request_idempotency_key,
idempotency_key,
)
@classmethod
async def get_return_by_confirm_idempotency_key(
cls,
db: AsyncSession,
idempotency_key: str,
) -> BizReturnOrder | None:
return await cls._get_by_key(
db,
BizReturnOrder,
BizReturnOrder.confirm_idempotency_key,
idempotency_key,
)
@classmethod
async def get_inbound_list(
cls,
db: AsyncSession,
query_object: InboundPageQueryModel,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
if query_object.inbound_no:
conditions.append(BizInboundOrder.inbound_no.like(f'%{query_object.inbound_no}%'))
if query_object.warehouse_id:
conditions.append(BizInboundOrder.warehouse_id == query_object.warehouse_id)
if query_object.status:
conditions.append(BizInboundOrder.status == query_object.status.value)
conditions.extend(
cls._time_conditions(
BizInboundOrder.create_time,
query_object.begin_time,
query_object.end_time,
)
)
statement = select(BizInboundOrder).where(*conditions).order_by(BizInboundOrder.inbound_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
async def get_inbound_by_id(
cls,
db: AsyncSession,
inbound_id: int,
) -> BizInboundOrder | None:
return await cls._get_order_detail(
db,
BizInboundOrder,
BizInboundOrder.inbound_id,
inbound_id,
BizInboundItem,
BizInboundItem.inbound_id,
BizInboundItem.inbound_item_id,
)
@classmethod
async def add_inbound(
cls,
db: AsyncSession,
inbound: BizInboundOrder,
) -> BizInboundOrder:
return await cls._add(db, inbound)
@classmethod
async def update_inbound(
cls,
db: AsyncSession,
inbound: BizInboundOrder,
) -> BizInboundOrder:
return await cls._update(
db,
BizInboundOrder,
BizInboundOrder.inbound_id,
inbound,
)
@classmethod
async def delete_inbound(cls, db: AsyncSession, inbound_id: int) -> None:
await cls._delete(db, BizInboundOrder, BizInboundOrder.inbound_id, inbound_id)
@classmethod
async def replace_inbound_items(
cls,
db: AsyncSession,
inbound_id: int,
items: list[BizInboundItem],
) -> list[BizInboundItem]:
return await cls._replace_items(
db,
BizInboundItem,
BizInboundItem.inbound_id,
inbound_id,
items,
)
@classmethod
async def get_filling_list(
cls,
db: AsyncSession,
query_object: FillingPageQueryModel,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
if query_object.filling_no:
conditions.append(BizFillingRecord.filling_no.like(f'%{query_object.filling_no}%'))
if query_object.warehouse_id:
conditions.append(BizFillingRecord.warehouse_id == query_object.warehouse_id)
if query_object.strain_id:
conditions.append(BizFillingRecord.strain_id == query_object.strain_id)
if query_object.batch_no:
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
conditions.extend(
cls._time_conditions(
BizFillingRecord.operated_at,
query_object.begin_time,
query_object.end_time,
)
)
statement = select(BizFillingRecord)
if query_object.pallet_code:
statement = statement.join(
BizPallet,
BizPallet.pallet_id == BizFillingRecord.pallet_id,
)
statement = statement.where(*conditions).order_by(BizFillingRecord.filling_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
async def get_filling_by_id(
cls,
db: AsyncSession,
filling_id: int,
) -> BizFillingRecord | None:
return await cls._get_by_id(
db,
BizFillingRecord,
BizFillingRecord.filling_id,
filling_id,
)
@classmethod
async def add_filling_record(
cls,
db: AsyncSession,
filling: BizFillingRecord,
) -> BizFillingRecord:
return await cls._add(db, filling)
@classmethod
async def update_filling_record(
cls,
db: AsyncSession,
filling: BizFillingRecord,
) -> BizFillingRecord:
return await cls._update(
db,
BizFillingRecord,
BizFillingRecord.filling_id,
filling,
)
@classmethod
async def get_shipment_list(
cls,
db: AsyncSession,
query_object: ShipmentPageQueryModel,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
if query_object.shipment_no:
conditions.append(BizShipmentOrder.shipment_no.like(f'%{query_object.shipment_no}%'))
if query_object.source_warehouse_id:
conditions.append(BizShipmentOrder.source_warehouse_id == query_object.source_warehouse_id)
if query_object.target_site_id:
conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id)
if query_object.status:
conditions.append(BizShipmentOrder.status == query_object.status.value)
conditions.extend(
cls._time_conditions(
BizShipmentOrder.create_time,
query_object.begin_time,
query_object.end_time,
)
)
statement = select(BizShipmentOrder).where(*conditions).order_by(BizShipmentOrder.shipment_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
async def get_shipment_by_id(
cls,
db: AsyncSession,
shipment_id: int,
) -> BizShipmentOrder | None:
return await cls._get_order_detail(
db,
BizShipmentOrder,
BizShipmentOrder.shipment_id,
shipment_id,
BizShipmentItem,
BizShipmentItem.shipment_id,
BizShipmentItem.shipment_item_id,
)
@classmethod
async def add_shipment(
cls,
db: AsyncSession,
shipment: BizShipmentOrder,
) -> BizShipmentOrder:
return await cls._add(db, shipment)
@classmethod
async def update_shipment(
cls,
db: AsyncSession,
shipment: BizShipmentOrder,
) -> BizShipmentOrder:
return await cls._update(
db,
BizShipmentOrder,
BizShipmentOrder.shipment_id,
shipment,
)
@classmethod
async def delete_shipment(cls, db: AsyncSession, shipment_id: int) -> None:
await cls._delete(
db,
BizShipmentOrder,
BizShipmentOrder.shipment_id,
shipment_id,
)
@classmethod
async def replace_shipment_items(
cls,
db: AsyncSession,
shipment_id: int,
items: list[BizShipmentItem],
) -> list[BizShipmentItem]:
return await cls._replace_items(
db,
BizShipmentItem,
BizShipmentItem.shipment_id,
shipment_id,
items,
)
@classmethod
async def get_usage_list(
cls,
db: AsyncSession,
query_object: UsagePageQueryModel,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
if query_object.usage_no:
conditions.append(BizUsageRecord.usage_no.like(f'%{query_object.usage_no}%'))
if query_object.site_id:
conditions.append(BizUsageRecord.site_id == query_object.site_id)
if query_object.line_id:
conditions.append(BizUsageRecord.line_id == query_object.line_id)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
conditions.extend(
cls._time_conditions(
BizUsageRecord.occurred_at,
query_object.begin_time,
query_object.end_time,
)
)
statement = select(BizUsageRecord)
if query_object.pallet_code:
statement = statement.join(
BizPallet,
BizPallet.pallet_id == BizUsageRecord.pallet_id,
)
statement = statement.where(*conditions).order_by(BizUsageRecord.usage_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
async def get_usage_by_id(
cls,
db: AsyncSession,
usage_id: int,
) -> BizUsageRecord | None:
return await cls._get_by_id(
db,
BizUsageRecord,
BizUsageRecord.usage_id,
usage_id,
)
@classmethod
async def add_usage_record(
cls,
db: AsyncSession,
usage: BizUsageRecord,
) -> BizUsageRecord:
return await cls._add(db, usage)
@classmethod
async def get_return_list(
cls,
db: AsyncSession,
query_object: ReturnPageQueryModel,
is_page: bool = True,
) -> PageModel | list[dict[str, Any]]:
conditions = []
if query_object.return_no:
conditions.append(BizReturnOrder.return_no.like(f'%{query_object.return_no}%'))
if query_object.source_site_id:
conditions.append(BizReturnOrder.source_site_id == query_object.source_site_id)
if query_object.target_warehouse_id:
conditions.append(BizReturnOrder.target_warehouse_id == query_object.target_warehouse_id)
if query_object.status:
conditions.append(BizReturnOrder.status == query_object.status.value)
conditions.extend(
cls._time_conditions(
BizReturnOrder.requested_at,
query_object.begin_time,
query_object.end_time,
)
)
statement = select(BizReturnOrder).where(*conditions).order_by(BizReturnOrder.return_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
async def get_return_by_id(
cls,
db: AsyncSession,
return_id: int,
) -> BizReturnOrder | None:
return await cls._get_order_detail(
db,
BizReturnOrder,
BizReturnOrder.return_id,
return_id,
BizReturnItem,
BizReturnItem.return_id,
BizReturnItem.return_item_id,
)
@classmethod
async def add_return_order(
cls,
db: AsyncSession,
return_order: BizReturnOrder,
) -> BizReturnOrder:
return await cls._add(db, return_order)
@classmethod
async def update_return_order(
cls,
db: AsyncSession,
return_order: BizReturnOrder,
) -> BizReturnOrder:
return await cls._update(
db,
BizReturnOrder,
BizReturnOrder.return_id,
return_order,
)
@classmethod
async def add_return_items(
cls,
db: AsyncSession,
items: list[BizReturnItem],
) -> list[BizReturnItem]:
return await cls._add_items(db, items)
@@ -0,0 +1,823 @@
import inspect
import unittest
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql, postgresql
from sqlalchemy.ext.asyncio import AsyncSession
from module_business.dao import inventory_dao as inventory_dao_module
from module_business.dao import operation_dao as operation_dao_module
from module_business.dao.inventory_dao import InventoryDao
from module_business.dao.operation_dao import OperationDao
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizInboundItem,
BizInboundOrder,
BizPalletEvent,
BizPalletInventory,
BizReturnItem,
BizReturnOrder,
BizShipmentItem,
BizShipmentOrder,
BizUsageRecord,
)
from module_business.entity.vo.operation_vo import (
FillingPageQueryModel,
InboundPageQueryModel,
ReturnPageQueryModel,
ShipmentPageQueryModel,
UsagePageQueryModel,
)
from utils.page_util import PageUtil
def compile_sql(statement: Any, dialect: Any | None = None) -> str:
compiled = statement.compile(
dialect=dialect or postgresql.dialect(),
compile_kwargs={'literal_binds': True},
)
return ' '.join(str(compiled).split()).replace('%%', '%')
def public_methods(dao: type[Any]) -> set[str]:
return {name for name in vars(dao) if not name.startswith('_') and callable(getattr(dao, name))}
class TestDaoPublicContract(unittest.TestCase):
def test_inventory_dao_exposes_only_fixed_append_only_api(self) -> None:
self.assertEqual(
{
'get_inventory_for_update',
'get_inventories_for_update',
'get_pallet_by_qr_token',
'get_pallets_by_ids',
'get_event_by_idempotency_key',
'add_event',
},
public_methods(InventoryDao),
)
self.assertFalse(hasattr(InventoryDao, 'update_event'))
self.assertFalse(hasattr(InventoryDao, 'delete_event'))
def test_inventory_dao_critical_parameter_names_are_stable(self) -> None:
expected = {
'get_inventory_for_update': ('db', 'pallet_id'),
'get_inventories_for_update': ('db', 'pallet_ids'),
'get_pallet_by_qr_token': ('db', 'qr_token'),
'get_pallets_by_ids': ('db', 'pallet_ids'),
'get_event_by_idempotency_key': ('db', 'pallet_id', 'idempotency_key'),
'add_event': ('db', 'inventory', 'event'),
}
for method_name, parameter_names in expected.items():
with self.subTest(method=method_name):
signature = inspect.signature(getattr(InventoryDao, method_name))
self.assertEqual(parameter_names, tuple(signature.parameters))
def test_operation_dao_exposes_fixed_api(self) -> None:
self.assertEqual(
{
'get_inbound_for_update',
'get_shipment_for_update',
'get_return_for_update',
'get_active_filling_for_update',
'get_inbound_by_confirm_idempotency_key',
'get_shipment_by_confirm_idempotency_key',
'get_filling_by_idempotency_key',
'get_usage_by_idempotency_key',
'get_return_by_request_idempotency_key',
'get_return_by_confirm_idempotency_key',
'get_inbound_list',
'get_inbound_by_id',
'add_inbound',
'update_inbound',
'delete_inbound',
'replace_inbound_items',
'get_filling_list',
'get_filling_by_id',
'add_filling_record',
'update_filling_record',
'get_shipment_list',
'get_shipment_by_id',
'add_shipment',
'update_shipment',
'delete_shipment',
'replace_shipment_items',
'get_usage_list',
'get_usage_by_id',
'add_usage_record',
'get_return_list',
'get_return_by_id',
'add_return_order',
'update_return_order',
'add_return_items',
},
public_methods(OperationDao),
)
def test_operation_dao_critical_parameter_names_and_defaults_are_stable(self) -> None:
expected = {
'get_inbound_for_update': ('db', 'inbound_id'),
'get_shipment_for_update': ('db', 'shipment_id'),
'get_return_for_update': ('db', 'return_id'),
'get_active_filling_for_update': ('db', 'filling_id'),
'get_inbound_by_confirm_idempotency_key': ('db', 'idempotency_key'),
'get_shipment_by_confirm_idempotency_key': ('db', 'idempotency_key'),
'get_filling_by_idempotency_key': ('db', 'idempotency_key'),
'get_usage_by_idempotency_key': ('db', 'idempotency_key'),
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'),
'get_return_by_confirm_idempotency_key': ('db', 'idempotency_key'),
'get_inbound_by_id': ('db', 'inbound_id'),
'add_inbound': ('db', 'inbound'),
'update_inbound': ('db', 'inbound'),
'delete_inbound': ('db', 'inbound_id'),
'replace_inbound_items': ('db', 'inbound_id', 'items'),
'get_filling_by_id': ('db', 'filling_id'),
'add_filling_record': ('db', 'filling'),
'update_filling_record': ('db', 'filling'),
'get_shipment_by_id': ('db', 'shipment_id'),
'add_shipment': ('db', 'shipment'),
'update_shipment': ('db', 'shipment'),
'delete_shipment': ('db', 'shipment_id'),
'replace_shipment_items': ('db', 'shipment_id', 'items'),
'get_usage_by_id': ('db', 'usage_id'),
'add_usage_record': ('db', 'usage'),
'get_return_by_id': ('db', 'return_id'),
'add_return_order': ('db', 'return_order'),
'update_return_order': ('db', 'return_order'),
'add_return_items': ('db', 'items'),
}
for method_name, parameter_names in expected.items():
with self.subTest(method=method_name):
signature = inspect.signature(getattr(OperationDao, method_name))
self.assertEqual(parameter_names, tuple(signature.parameters))
for method_name in (
'get_inbound_list',
'get_filling_list',
'get_shipment_list',
'get_usage_list',
'get_return_list',
):
with self.subTest(method=method_name):
signature = inspect.signature(getattr(OperationDao, method_name))
self.assertEqual(('db', 'query_object', 'is_page'), tuple(signature.parameters))
self.assertIs(True, signature.parameters['is_page'].default)
def test_dao_source_never_controls_transaction_boundary(self) -> None:
for module in (inventory_dao_module, operation_dao_module):
with self.subTest(module=module.__name__):
source = inspect.getsource(module)
self.assertNotIn('.commit(', source)
self.assertNotIn('.rollback(', source)
class TestInventoryDao(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
async def test_get_inventory_for_update_targets_primary_key_with_lock(self) -> None:
inventory = SimpleNamespace(pallet_id=7)
result = MagicMock()
result.scalar_one_or_none.return_value = inventory
self.db.execute.return_value = result
actual = await InventoryDao.get_inventory_for_update(self.db, pallet_id=7)
self.assertIs(inventory, actual)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizPalletInventory.__table__))
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect)
self.assertIn('biz_pallet_inventory.pallet_id = 7', sql)
self.assertIn('FOR UPDATE', sql)
async def test_get_inventories_for_update_deduplicates_sorts_and_locks(self) -> None:
inventories = [
SimpleNamespace(pallet_id=9),
SimpleNamespace(pallet_id=2),
SimpleNamespace(pallet_id=5),
]
result = MagicMock()
result.scalars.return_value.all.return_value = inventories
self.db.execute.return_value = result
actual = await InventoryDao.get_inventories_for_update(self.db, [9, 2, 5, 2])
self.assertEqual([2, 5, 9], [inventory.pallet_id for inventory in actual])
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizPalletInventory.__table__))
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect)
self.assertIn('biz_pallet_inventory.pallet_id IN (2, 5, 9)', sql)
self.assertIn('ORDER BY biz_pallet_inventory.pallet_id', sql)
self.assertIn('FOR UPDATE', sql)
async def test_get_inventories_for_update_empty_input_skips_sql(self) -> None:
self.assertEqual([], await InventoryDao.get_inventories_for_update(self.db, []))
self.db.execute.assert_not_awaited()
async def test_get_pallet_by_qr_token_uses_exact_token_without_lock(self) -> None:
pallet = SimpleNamespace(pallet_id=4)
result = MagicMock()
result.scalar_one_or_none.return_value = pallet
self.db.execute.return_value = result
actual = await InventoryDao.get_pallet_by_qr_token(self.db, qr_token='fixed-token')
self.assertIs(pallet, actual)
statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement)
self.assertTrue(statement.get_final_froms()[0].compare(BizPallet.__table__))
self.assertIn("biz_pallet.qr_token = 'fixed-token'", sql)
self.assertNotIn('FOR UPDATE', sql)
async def test_get_pallets_by_ids_deduplicates_sorts_and_locks_after_inventory(self) -> None:
pallets = [
SimpleNamespace(pallet_id=9),
SimpleNamespace(pallet_id=2),
SimpleNamespace(pallet_id=5),
]
result = MagicMock()
result.scalars.return_value.all.return_value = pallets
self.db.execute.return_value = result
actual = await InventoryDao.get_pallets_by_ids(self.db, [9, 2, 5, 2])
self.assertEqual([2, 5, 9], [pallet.pallet_id for pallet in actual])
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizPallet.__table__))
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect)
self.assertIn('biz_pallet.pallet_id IN (2, 5, 9)', sql)
self.assertIn('ORDER BY biz_pallet.pallet_id', sql)
self.assertIn('FOR UPDATE', sql)
async def test_get_pallets_by_ids_empty_input_skips_sql(self) -> None:
self.assertEqual([], await InventoryDao.get_pallets_by_ids(self.db, []))
self.db.execute.assert_not_awaited()
async def test_get_event_by_idempotency_key_uses_pallet_scoped_key(self) -> None:
event = SimpleNamespace(event_id=19)
result = MagicMock()
result.scalar_one_or_none.return_value = event
self.db.execute.return_value = result
actual = await InventoryDao.get_event_by_idempotency_key(
self.db,
pallet_id=7,
idempotency_key='event-key',
)
self.assertIs(event, actual)
statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement)
self.assertTrue(statement.get_final_froms()[0].compare(BizPalletEvent.__table__))
self.assertIn('biz_pallet_event.pallet_id = 7', sql)
self.assertIn("biz_pallet_event.idempotency_key = 'event-key'", sql)
self.assertNotIn('FOR UPDATE', sql)
async def test_add_event_allocates_next_sequence_and_updates_inventory_pointer(self) -> None:
sequence_result = MagicMock()
sequence_result.scalar_one.return_value = 4
sequence_result.scalar_one_or_none.return_value = 4
sequence_result.scalar.return_value = 4
self.db.execute.return_value = sequence_result
inventory = BizPalletInventory(pallet_id=7, last_event_id=None)
event = BizPalletEvent(pallet_id=99, event_seq=99, idempotency_key='event-key')
flush_count = 0
async def assign_event_id() -> None:
nonlocal flush_count
flush_count += 1
if flush_count == 1:
event.event_id = 41
self.db.flush.side_effect = assign_event_id
actual = await InventoryDao.add_event(self.db, inventory, event)
self.assertIs(event, actual)
self.assertEqual(7, event.pallet_id)
self.assertEqual(5, event.event_seq)
self.assertEqual(41, inventory.last_event_id)
self.db.add.assert_called_once_with(event)
self.assertEqual(2, self.db.flush.await_count)
statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement)
self.assertIn('max(biz_pallet_event.event_seq)', sql.lower())
self.assertIn('biz_pallet_event.pallet_id = 7', sql)
async def test_add_first_event_starts_sequence_at_one(self) -> None:
sequence_result = MagicMock()
sequence_result.scalar_one.return_value = None
sequence_result.scalar_one_or_none.return_value = None
sequence_result.scalar.return_value = None
self.db.execute.return_value = sequence_result
inventory = BizPalletInventory(pallet_id=8, last_event_id=None)
event = BizPalletEvent(pallet_id=8, idempotency_key='first-key', event_id=51)
await InventoryDao.add_event(self.db, inventory, event)
self.assertEqual(1, event.event_seq)
self.assertEqual(51, inventory.last_event_id)
class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
async def test_for_update_queries_target_primary_keys_and_lock_rows(self) -> None:
cases = (
(
OperationDao.get_inbound_for_update,
BizInboundOrder,
'inbound_id',
11,
(),
),
(
OperationDao.get_shipment_for_update,
BizShipmentOrder,
'shipment_id',
12,
(),
),
(
OperationDao.get_return_for_update,
BizReturnOrder,
'return_id',
13,
(),
),
(
OperationDao.get_active_filling_for_update,
BizFillingRecord,
'filling_id',
14,
("biz_filling_record.active_slot = 'ACTIVE'",),
),
)
for method, model, id_field, record_id, extra_fragments in cases:
with self.subTest(table=model.__tablename__):
self.db.reset_mock()
record = SimpleNamespace()
result = MagicMock()
result.scalar_one_or_none.return_value = record
self.db.execute.return_value = result
actual = await method(self.db, record_id)
self.assertIs(record, actual)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect)
self.assertIn(f'{model.__tablename__}.{id_field} = {record_id}', sql)
self.assertIn('FOR UPDATE', sql)
for fragment in extra_fragments:
self.assertIn(fragment, sql)
async def test_idempotency_queries_use_the_exact_declared_column(self) -> None:
cases = (
(
OperationDao.get_inbound_by_confirm_idempotency_key,
BizInboundOrder,
'confirm_idempotency_key',
),
(
OperationDao.get_shipment_by_confirm_idempotency_key,
BizShipmentOrder,
'confirm_idempotency_key',
),
(OperationDao.get_filling_by_idempotency_key, BizFillingRecord, 'idempotency_key'),
(OperationDao.get_usage_by_idempotency_key, BizUsageRecord, 'idempotency_key'),
(
OperationDao.get_return_by_request_idempotency_key,
BizReturnOrder,
'request_idempotency_key',
),
(
OperationDao.get_return_by_confirm_idempotency_key,
BizReturnOrder,
'confirm_idempotency_key',
),
)
for method, model, key_column in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
record = SimpleNamespace()
result = MagicMock()
result.scalar_one_or_none.return_value = record
self.db.execute.return_value = result
actual = await method(self.db, 'same-key')
self.assertIs(record, actual)
statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement)
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
self.assertIn(f"{model.__tablename__}.{key_column} = 'same-key'", sql)
self.assertNotIn('FOR UPDATE', sql)
async def test_list_queries_apply_every_declared_filter_sort_and_pagination(self) -> None:
cases = (
(
OperationDao.get_inbound_list,
InboundPageQueryModel(
page_num=2,
page_size=7,
inbound_no='IN-26',
warehouse_id=3,
status='DRAFT',
begin_time='2026-07-01',
end_time='2026-07-02',
),
BizInboundOrder,
'inbound_id',
(
"biz_inbound_order.inbound_no LIKE '%IN-26%'",
'biz_inbound_order.warehouse_id = 3',
"biz_inbound_order.status = 'DRAFT'",
"biz_inbound_order.create_time >= '2026-07-01 00:00:00'",
"biz_inbound_order.create_time <= '2026-07-02 23:59:59'",
),
),
(
OperationDao.get_filling_list,
FillingPageQueryModel(
page_num=2,
page_size=7,
filling_no='FI-26',
warehouse_id=3,
strain_id=4,
batch_no='BATCH-A',
pallet_code='P-00',
begin_time='2026-07-01',
end_time='2026-07-02',
),
BizFillingRecord,
'filling_id',
(
"biz_filling_record.filling_no LIKE '%FI-26%'",
'biz_filling_record.warehouse_id = 3',
'biz_filling_record.strain_id = 4',
"biz_filling_record.batch_no LIKE '%BATCH-A%'",
"biz_pallet.pallet_code LIKE '%P-00%'",
"biz_filling_record.operated_at >= '2026-07-01 00:00:00'",
"biz_filling_record.operated_at <= '2026-07-02 23:59:59'",
),
),
(
OperationDao.get_shipment_list,
ShipmentPageQueryModel(
page_num=2,
page_size=7,
shipment_no='SH-26',
source_warehouse_id=3,
target_site_id=4,
status='CONFIRMED',
begin_time='2026-07-01',
end_time='2026-07-02',
),
BizShipmentOrder,
'shipment_id',
(
"biz_shipment_order.shipment_no LIKE '%SH-26%'",
'biz_shipment_order.source_warehouse_id = 3',
'biz_shipment_order.target_site_id = 4',
"biz_shipment_order.status = 'CONFIRMED'",
"biz_shipment_order.create_time >= '2026-07-01 00:00:00'",
"biz_shipment_order.create_time <= '2026-07-02 23:59:59'",
),
),
(
OperationDao.get_usage_list,
UsagePageQueryModel(
page_num=2,
page_size=7,
usage_no='US-26',
site_id=3,
line_id=4,
pallet_code='P-00',
begin_time='2026-07-01',
end_time='2026-07-02',
),
BizUsageRecord,
'usage_id',
(
"biz_usage_record.usage_no LIKE '%US-26%'",
'biz_usage_record.site_id = 3',
'biz_usage_record.line_id = 4',
"biz_pallet.pallet_code LIKE '%P-00%'",
"biz_usage_record.occurred_at >= '2026-07-01 00:00:00'",
"biz_usage_record.occurred_at <= '2026-07-02 23:59:59'",
),
),
(
OperationDao.get_return_list,
ReturnPageQueryModel(
page_num=2,
page_size=7,
return_no='RE-26',
source_site_id=3,
target_warehouse_id=4,
status='REQUESTED',
begin_time='2026-07-01',
end_time='2026-07-02',
),
BizReturnOrder,
'return_id',
(
"biz_return_order.return_no LIKE '%RE-26%'",
'biz_return_order.source_site_id = 3',
'biz_return_order.target_warehouse_id = 4',
"biz_return_order.status = 'REQUESTED'",
"biz_return_order.requested_at >= '2026-07-01 00:00:00'",
"biz_return_order.requested_at <= '2026-07-02 23:59:59'",
),
),
)
for index, (method, query, model, id_field, fragments) in enumerate(cases):
with self.subTest(method=method.__name__):
marker = object()
is_page = index % 2 == 0
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate:
actual = await method(self.db, query, is_page=is_page)
self.assertIs(marker, actual)
args = paginate.await_args.args
self.assertIs(self.db, args[0])
self.assertEqual((query.page_num, query.page_size, is_page), args[2:])
statement = args[1]
sql = compile_sql(statement)
self.assertIn(f'FROM {model.__tablename__}', sql)
self.assertIn(f'ORDER BY {model.__tablename__}.{id_field}', sql)
for fragment in fragments:
self.assertIn(fragment, sql)
async def test_order_details_load_items_explicitly_in_stable_order(self) -> None:
cases = (
(
OperationDao.get_inbound_by_id,
BizInboundOrder,
'inbound_id',
BizInboundItem,
'inbound_item_id',
11,
),
(
OperationDao.get_shipment_by_id,
BizShipmentOrder,
'shipment_id',
BizShipmentItem,
'shipment_item_id',
12,
),
(
OperationDao.get_return_by_id,
BizReturnOrder,
'return_id',
BizReturnItem,
'return_item_id',
13,
),
)
for method, order_model, order_id_field, item_model, item_id_field, order_id in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
order = SimpleNamespace()
items = [SimpleNamespace(item_id=2), SimpleNamespace(item_id=5)]
order_result = MagicMock()
order_result.scalar_one_or_none.return_value = order
item_result = MagicMock()
item_result.scalars.return_value.all.return_value = items
self.db.execute.side_effect = [order_result, item_result]
actual = await method(self.db, order_id)
self.assertIs(order, actual)
self.assertEqual(items, order.items)
order_statement = self.db.execute.await_args_list[0].args[0]
item_statement = self.db.execute.await_args_list[1].args[0]
self.assertTrue(order_statement.get_final_froms()[0].compare(order_model.__table__))
self.assertIn(
f'{order_model.__tablename__}.{order_id_field} = {order_id}',
compile_sql(order_statement),
)
self.assertTrue(item_statement.get_final_froms()[0].compare(item_model.__table__))
item_sql = compile_sql(item_statement)
self.assertIn(f'{item_model.__tablename__}.{order_id_field} = {order_id}', item_sql)
self.assertIn(f'ORDER BY {item_model.__tablename__}.{item_id_field}', item_sql)
async def test_missing_order_detail_skips_item_query(self) -> None:
result = MagicMock()
result.scalar_one_or_none.return_value = None
self.db.execute.return_value = result
self.assertIsNone(await OperationDao.get_inbound_by_id(self.db, inbound_id=404))
self.db.execute.assert_awaited_once()
async def test_simple_details_target_primary_keys(self) -> None:
cases = (
(OperationDao.get_filling_by_id, BizFillingRecord, 'filling_id', 14),
(OperationDao.get_usage_by_id, BizUsageRecord, 'usage_id', 15),
)
for method, model, id_field, record_id in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
record = SimpleNamespace()
result = MagicMock()
result.scalar_one_or_none.return_value = record
self.db.execute.return_value = result
actual = await method(self.db, record_id)
self.assertIs(record, actual)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
self.assertIn(f'{model.__tablename__}.{id_field} = {record_id}', compile_sql(statement))
class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.db = MagicMock(spec=AsyncSession)
async def test_add_methods_add_exact_orm_and_flush_without_commit(self) -> None:
cases = (
(OperationDao.add_inbound, BizInboundOrder(inbound_no='IN-1', warehouse_id=3)),
(
OperationDao.add_filling_record,
BizFillingRecord(filling_no='FI-1', pallet_id=4, warehouse_id=3),
),
(
OperationDao.add_shipment,
BizShipmentOrder(shipment_no='SH-1', source_warehouse_id=3, target_site_id=4),
),
(OperationDao.add_usage_record, BizUsageRecord(usage_no='US-1', pallet_id=4)),
(
OperationDao.add_return_order,
BizReturnOrder(return_no='RE-1', source_site_id=4, target_warehouse_id=3),
),
)
for method, record in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
actual = await method(self.db, record)
self.assertIs(record, actual)
self.db.add.assert_called_once_with(record)
self.db.flush.assert_awaited_once()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_update_methods_target_primary_key_and_flush_without_commit(self) -> None:
cases = (
(
OperationDao.update_inbound,
BizInboundOrder(inbound_id=11, warehouse_id=3, status='CONFIRMED'),
BizInboundOrder,
'inbound_id',
11,
'warehouse_id=3',
),
(
OperationDao.update_filling_record,
BizFillingRecord(filling_id=12, active_slot=None),
BizFillingRecord,
'filling_id',
12,
'active_slot=NULL',
),
(
OperationDao.update_shipment,
BizShipmentOrder(shipment_id=13, status='CONFIRMED'),
BizShipmentOrder,
'shipment_id',
13,
"status='CONFIRMED'",
),
(
OperationDao.update_return_order,
BizReturnOrder(return_id=14, status='CONFIRMED'),
BizReturnOrder,
'return_id',
14,
"status='CONFIRMED'",
),
)
for method, record, model, id_field, record_id, value_fragment in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
actual = await method(self.db, record)
self.assertIs(record, actual)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.table.compare(model.__table__))
sql = compile_sql(statement)
self.assertIn(f'WHERE {model.__tablename__}.{id_field} = {record_id}', sql)
self.assertIn(value_fragment, sql)
self.db.flush.assert_awaited_once()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_delete_methods_target_one_order_without_commit(self) -> None:
cases = (
(OperationDao.delete_inbound, BizInboundOrder, 'inbound_id', 11),
(OperationDao.delete_shipment, BizShipmentOrder, 'shipment_id', 12),
)
for method, model, id_field, record_id in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
await method(self.db, record_id)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.table.compare(model.__table__))
self.assertIn(
f'{model.__tablename__}.{id_field} = {record_id}',
compile_sql(statement),
)
self.db.flush.assert_awaited_once()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_replace_items_deletes_whole_order_then_adds_normalized_items(self) -> None:
cases = (
(
OperationDao.replace_inbound_items,
BizInboundItem,
'inbound_id',
11,
[BizInboundItem(inbound_id=99, pallet_id=2), BizInboundItem(inbound_id=99, pallet_id=5)],
),
(
OperationDao.replace_shipment_items,
BizShipmentItem,
'shipment_id',
12,
[BizShipmentItem(shipment_id=99, pallet_id=2), BizShipmentItem(shipment_id=99, pallet_id=5)],
),
)
for method, item_model, order_id_field, order_id, items in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
actual = await method(self.db, order_id, items)
self.assertEqual(items, actual)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.table.compare(item_model.__table__))
self.assertIn(
f'{item_model.__tablename__}.{order_id_field} = {order_id}',
compile_sql(statement),
)
self.assertEqual([order_id, order_id], [getattr(item, order_id_field) for item in items])
self.db.add_all.assert_called_once_with(items)
self.db.flush.assert_awaited_once()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
async def test_replace_items_empty_list_means_clear_all_existing_items(self) -> None:
cases = (
(OperationDao.replace_inbound_items, BizInboundItem, 'inbound_id', 11),
(OperationDao.replace_shipment_items, BizShipmentItem, 'shipment_id', 12),
)
for method, item_model, order_id_field, order_id in cases:
with self.subTest(method=method.__name__):
self.db.reset_mock()
actual = await method(self.db, order_id, [])
self.assertEqual([], actual)
statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.table.compare(item_model.__table__))
self.assertIn(
f'{item_model.__tablename__}.{order_id_field} = {order_id}',
compile_sql(statement),
)
self.db.add_all.assert_not_called()
self.db.flush.assert_awaited_once()
async def test_add_return_items_adds_all_and_flushes_without_commit(self) -> None:
items = [
BizReturnItem(return_id=13, pallet_id=2, remaining_packages_snapshot=0),
BizReturnItem(return_id=13, pallet_id=5, remaining_packages_snapshot=4),
]
actual = await OperationDao.add_return_items(self.db, items)
self.assertEqual(items, actual)
self.db.add_all.assert_called_once_with(items)
self.db.flush.assert_awaited_once()
self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
if __name__ == '__main__':
unittest.main()