修复:强化库存事务并发与日期边界

This commit is contained in:
焦龙言
2026-07-26 13:54:42 +08:00
parent 85905d85ca
commit 6c2a5fa344
5 changed files with 187 additions and 40 deletions
@@ -1,4 +1,4 @@
from sqlalchemy import func, select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from module_business.entity.do.master_do import BizPallet from module_business.entity.do.master_do import BizPallet
@@ -15,7 +15,10 @@ class InventoryDao:
pallet_id: int, pallet_id: int,
) -> BizPalletInventory | None: ) -> BizPalletInventory | None:
result = await db.execute( result = await db.execute(
select(BizPalletInventory).where(BizPalletInventory.pallet_id == pallet_id).with_for_update() select(BizPalletInventory)
.where(BizPalletInventory.pallet_id == pallet_id)
.with_for_update()
.execution_options(populate_existing=True)
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -34,6 +37,7 @@ class InventoryDao:
.where(BizPalletInventory.pallet_id.in_(ordered_ids)) .where(BizPalletInventory.pallet_id.in_(ordered_ids))
.order_by(BizPalletInventory.pallet_id) .order_by(BizPalletInventory.pallet_id)
.with_for_update() .with_for_update()
.execution_options(populate_existing=True)
) )
return sorted(result.scalars().all(), key=lambda inventory: inventory.pallet_id) return sorted(result.scalars().all(), key=lambda inventory: inventory.pallet_id)
@@ -62,6 +66,7 @@ class InventoryDao:
.where(BizPallet.pallet_id.in_(ordered_ids)) .where(BizPallet.pallet_id.in_(ordered_ids))
.order_by(BizPallet.pallet_id) .order_by(BizPallet.pallet_id)
.with_for_update() .with_for_update()
.execution_options(populate_existing=True)
) )
return sorted(result.scalars().all(), key=lambda pallet: pallet.pallet_id) return sorted(result.scalars().all(), key=lambda pallet: pallet.pallet_id)
@@ -89,9 +94,13 @@ class InventoryDao:
) -> BizPalletEvent: ) -> BizPalletEvent:
"""在调用方持有库存行锁时,为托盘追加下一序号事件。""" """在调用方持有库存行锁时,为托盘追加下一序号事件。"""
sequence_result = await db.execute( sequence_result = await db.execute(
select(func.max(BizPalletEvent.event_seq)).where(BizPalletEvent.pallet_id == inventory.pallet_id) select(BizPalletEvent.event_seq)
.where(BizPalletEvent.pallet_id == inventory.pallet_id)
.order_by(BizPalletEvent.event_seq.desc())
.limit(1)
.with_for_update()
) )
last_sequence = sequence_result.scalar_one() last_sequence = sequence_result.scalar_one_or_none()
event.pallet_id = inventory.pallet_id event.pallet_id = inventory.pallet_id
event.event_seq = (last_sequence or 0) + 1 event.event_seq = (last_sequence or 0) + 1
db.add(event) db.add(event)
@@ -1,4 +1,4 @@
from datetime import datetime, time from datetime import datetime, time, timedelta
from typing import Any from typing import Any
from sqlalchemy import ColumnElement, Select, delete, select, update from sqlalchemy import ColumnElement, Select, delete, select, update
@@ -45,10 +45,10 @@ class OperationDao:
conditions.append(column >= begin_at) conditions.append(column >= begin_at)
if end_time: if end_time:
end_at = datetime.combine( end_at = datetime.combine(
datetime.strptime(end_time, '%Y-%m-%d').date(), datetime.strptime(end_time, '%Y-%m-%d').date() + timedelta(days=1),
time(23, 59, 59), time.min,
) )
conditions.append(column <= end_at) conditions.append(column < end_at)
return conditions return conditions
@staticmethod @staticmethod
@@ -74,7 +74,12 @@ class OperationDao:
record_id: int, record_id: int,
*extra_conditions: ColumnElement[bool], *extra_conditions: ColumnElement[bool],
) -> Any | None: ) -> Any | None:
result = await db.execute(select(model).where(id_column == record_id, *extra_conditions).with_for_update()) result = await db.execute(
select(model)
.where(id_column == record_id, *extra_conditions)
.with_for_update()
.execution_options(populate_existing=True)
)
return result.scalar_one_or_none() return result.scalar_one_or_none()
@staticmethod @staticmethod
@@ -135,7 +140,7 @@ class OperationDao:
payload = { payload = {
column.key: vars(record)[column.key] column.key: vars(record)[column.key]
for column in model.__table__.columns for column in model.__table__.columns
if column.key != primary_key and column.key in vars(record) if column.key not in {primary_key, 'create_by', 'create_time'} and column.key in vars(record)
} }
if payload: if payload:
await db.execute(update(model).where(id_column == getattr(record, primary_key)).values(**payload)) await db.execute(update(model).where(id_column == getattr(record, primary_key)).values(**payload))
@@ -370,6 +375,7 @@ class OperationDao:
@classmethod @classmethod
async def delete_inbound(cls, db: AsyncSession, inbound_id: int) -> None: async def delete_inbound(cls, db: AsyncSession, inbound_id: int) -> None:
await db.execute(delete(BizInboundItem).where(BizInboundItem.inbound_id == inbound_id))
await cls._delete(db, BizInboundOrder, BizInboundOrder.inbound_id, inbound_id) await cls._delete(db, BizInboundOrder, BizInboundOrder.inbound_id, inbound_id)
@classmethod @classmethod
@@ -520,6 +526,7 @@ class OperationDao:
@classmethod @classmethod
async def delete_shipment(cls, db: AsyncSession, shipment_id: int) -> None: async def delete_shipment(cls, db: AsyncSession, shipment_id: int) -> None:
await db.execute(delete(BizShipmentItem).where(BizShipmentItem.shipment_id == shipment_id))
await cls._delete( await cls._delete(
db, db,
BizShipmentOrder, BizShipmentOrder,
@@ -16,6 +16,26 @@ def _ensure_unique_ids(value: list[int]) -> list[int]:
return value return value
def _parse_page_query_date(value: str) -> datetime:
try:
parsed = datetime.strptime(value, '%Y-%m-%d')
except ValueError as exc:
raise ValueError('日期必须为有效的 YYYY-MM-DD 格式') from exc
if parsed.strftime('%Y-%m-%d') != value:
raise ValueError('日期必须为有效的 YYYY-MM-DD 格式')
return parsed
def _validate_page_query_date_range(value: BaseModel) -> BaseModel:
begin_value = value.begin_time
end_value = value.end_time
begin_date = _parse_page_query_date(begin_value) if begin_value else None
end_date = _parse_page_query_date(end_value) if end_value else None
if begin_date and end_date and begin_date > end_date:
raise ValueError('开始日期不能晚于结束日期')
return value
class InboundCreateModel(BaseModel): class InboundCreateModel(BaseModel):
"""首次入库草稿新增模型。""" """首次入库草稿新增模型。"""
@@ -335,6 +355,8 @@ class InboundPageQueryModel(BaseModel):
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
class FillingPageQueryModel(BaseModel): class FillingPageQueryModel(BaseModel):
"""托盘装填分页查询模型。""" """托盘装填分页查询模型。"""
@@ -351,6 +373,8 @@ class FillingPageQueryModel(BaseModel):
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
class ShipmentPageQueryModel(BaseModel): class ShipmentPageQueryModel(BaseModel):
"""整托发货分页查询模型。""" """整托发货分页查询模型。"""
@@ -366,6 +390,8 @@ class ShipmentPageQueryModel(BaseModel):
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
class UsagePageQueryModel(BaseModel): class UsagePageQueryModel(BaseModel):
"""客户现场使用分页查询模型。""" """客户现场使用分页查询模型。"""
@@ -381,6 +407,8 @@ class UsagePageQueryModel(BaseModel):
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
class ReturnPageQueryModel(BaseModel): class ReturnPageQueryModel(BaseModel):
"""托盘退回分页查询模型。""" """托盘退回分页查询模型。"""
@@ -396,6 +424,8 @@ class ReturnPageQueryModel(BaseModel):
begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间')
end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间')
_validate_date_range = model_validator(mode='after')(_validate_page_query_date_range)
class InboundScanQueryModel(BaseModel): class InboundScanQueryModel(BaseModel):
"""首次入库扫码查询模型。""" """首次入库扫码查询模型。"""
@@ -1,11 +1,13 @@
import inspect import inspect
import unittest import unittest
from datetime import datetime
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql, postgresql from sqlalchemy.dialects import mysql, postgresql
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import operators
from module_business.dao import inventory_dao as inventory_dao_module 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 import operation_dao as operation_dao_module
@@ -189,6 +191,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase):
self.assertIs(inventory, actual) self.assertIs(inventory, actual)
statement = self.db.execute.await_args.args[0] statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizPalletInventory.__table__)) self.assertTrue(statement.get_final_froms()[0].compare(BizPalletInventory.__table__))
self.assertIs(True, statement.get_execution_options().get('populate_existing'))
for dialect in (mysql.dialect(), postgresql.dialect()): for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect) sql = compile_sql(statement, dialect)
self.assertIn('biz_pallet_inventory.pallet_id = 7', sql) self.assertIn('biz_pallet_inventory.pallet_id = 7', sql)
@@ -209,6 +212,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase):
self.assertEqual([2, 5, 9], [inventory.pallet_id for inventory in actual]) self.assertEqual([2, 5, 9], [inventory.pallet_id for inventory in actual])
statement = self.db.execute.await_args.args[0] statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizPalletInventory.__table__)) self.assertTrue(statement.get_final_froms()[0].compare(BizPalletInventory.__table__))
self.assertIs(True, statement.get_execution_options().get('populate_existing'))
for dialect in (mysql.dialect(), postgresql.dialect()): for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect) sql = compile_sql(statement, dialect)
self.assertIn('biz_pallet_inventory.pallet_id IN (2, 5, 9)', sql) self.assertIn('biz_pallet_inventory.pallet_id IN (2, 5, 9)', sql)
@@ -249,6 +253,7 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase):
self.assertEqual([2, 5, 9], [pallet.pallet_id for pallet in actual]) self.assertEqual([2, 5, 9], [pallet.pallet_id for pallet in actual])
statement = self.db.execute.await_args.args[0] statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(BizPallet.__table__)) self.assertTrue(statement.get_final_froms()[0].compare(BizPallet.__table__))
self.assertIs(True, statement.get_execution_options().get('populate_existing'))
for dialect in (mysql.dialect(), postgresql.dialect()): for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect) sql = compile_sql(statement, dialect)
self.assertIn('biz_pallet.pallet_id IN (2, 5, 9)', sql) self.assertIn('biz_pallet.pallet_id IN (2, 5, 9)', sql)
@@ -283,7 +288,6 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase):
sequence_result = MagicMock() sequence_result = MagicMock()
sequence_result.scalar_one.return_value = 4 sequence_result.scalar_one.return_value = 4
sequence_result.scalar_one_or_none.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 self.db.execute.return_value = sequence_result
inventory = BizPalletInventory(pallet_id=7, last_event_id=None) inventory = BizPalletInventory(pallet_id=7, last_event_id=None)
event = BizPalletEvent(pallet_id=99, event_seq=99, idempotency_key='event-key') event = BizPalletEvent(pallet_id=99, event_seq=99, idempotency_key='event-key')
@@ -306,15 +310,19 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase):
self.db.add.assert_called_once_with(event) self.db.add.assert_called_once_with(event)
self.assertEqual(2, self.db.flush.await_count) self.assertEqual(2, self.db.flush.await_count)
statement = self.db.execute.await_args.args[0] statement = self.db.execute.await_args.args[0]
sql = compile_sql(statement) self.assertTrue(statement.selected_columns[0].compare(BizPalletEvent.event_seq.property.columns[0]))
self.assertIn('max(biz_pallet_event.event_seq)', sql.lower()) for dialect in (mysql.dialect(), postgresql.dialect()):
self.assertIn('biz_pallet_event.pallet_id = 7', sql) sql = compile_sql(statement, dialect)
self.assertNotIn('max(', sql.lower())
self.assertIn('biz_pallet_event.pallet_id = 7', sql)
self.assertIn('ORDER BY biz_pallet_event.event_seq DESC', sql)
self.assertIn('LIMIT 1', sql)
self.assertIn('FOR UPDATE', sql)
async def test_add_first_event_starts_sequence_at_one(self) -> None: async def test_add_first_event_starts_sequence_at_one(self) -> None:
sequence_result = MagicMock() sequence_result = MagicMock()
sequence_result.scalar_one.return_value = None sequence_result.scalar_one.return_value = None
sequence_result.scalar_one_or_none.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 self.db.execute.return_value = sequence_result
inventory = BizPalletInventory(pallet_id=8, last_event_id=None) inventory = BizPalletInventory(pallet_id=8, last_event_id=None)
event = BizPalletEvent(pallet_id=8, idempotency_key='first-key', event_id=51) event = BizPalletEvent(pallet_id=8, idempotency_key='first-key', event_id=51)
@@ -373,6 +381,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
self.assertIs(record, actual) self.assertIs(record, actual)
statement = self.db.execute.await_args.args[0] statement = self.db.execute.await_args.args[0]
self.assertTrue(statement.get_final_froms()[0].compare(model.__table__)) self.assertTrue(statement.get_final_froms()[0].compare(model.__table__))
self.assertIs(True, statement.get_execution_options().get('populate_existing'))
for dialect in (mysql.dialect(), postgresql.dialect()): for dialect in (mysql.dialect(), postgresql.dialect()):
sql = compile_sql(statement, dialect) sql = compile_sql(statement, dialect)
self.assertIn(f'{model.__tablename__}.{id_field} = {record_id}', sql) self.assertIn(f'{model.__tablename__}.{id_field} = {record_id}', sql)
@@ -442,7 +451,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
'biz_inbound_order.warehouse_id = 3', 'biz_inbound_order.warehouse_id = 3',
"biz_inbound_order.status = 'DRAFT'", "biz_inbound_order.status = 'DRAFT'",
"biz_inbound_order.create_time >= '2026-07-01 00:00:00'", "biz_inbound_order.create_time >= '2026-07-01 00:00:00'",
"biz_inbound_order.create_time <= '2026-07-02 23:59:59'", "biz_inbound_order.create_time < '2026-07-03 00:00:00'",
), ),
), ),
( (
@@ -467,7 +476,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
"biz_filling_record.batch_no LIKE '%BATCH-A%'", "biz_filling_record.batch_no LIKE '%BATCH-A%'",
"biz_pallet.pallet_code LIKE '%P-00%'", "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-01 00:00:00'",
"biz_filling_record.operated_at <= '2026-07-02 23:59:59'", "biz_filling_record.operated_at < '2026-07-03 00:00:00'",
), ),
), ),
( (
@@ -490,7 +499,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
'biz_shipment_order.target_site_id = 4', 'biz_shipment_order.target_site_id = 4',
"biz_shipment_order.status = 'CONFIRMED'", "biz_shipment_order.status = 'CONFIRMED'",
"biz_shipment_order.create_time >= '2026-07-01 00:00:00'", "biz_shipment_order.create_time >= '2026-07-01 00:00:00'",
"biz_shipment_order.create_time <= '2026-07-02 23:59:59'", "biz_shipment_order.create_time < '2026-07-03 00:00:00'",
), ),
), ),
( (
@@ -513,7 +522,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
'biz_usage_record.line_id = 4', 'biz_usage_record.line_id = 4',
"biz_pallet.pallet_code LIKE '%P-00%'", "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-01 00:00:00'",
"biz_usage_record.occurred_at <= '2026-07-02 23:59:59'", "biz_usage_record.occurred_at < '2026-07-03 00:00:00'",
), ),
), ),
( (
@@ -536,7 +545,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
'biz_return_order.target_warehouse_id = 4', 'biz_return_order.target_warehouse_id = 4',
"biz_return_order.status = 'REQUESTED'", "biz_return_order.status = 'REQUESTED'",
"biz_return_order.requested_at >= '2026-07-01 00:00:00'", "biz_return_order.requested_at >= '2026-07-01 00:00:00'",
"biz_return_order.requested_at <= '2026-07-02 23:59:59'", "biz_return_order.requested_at < '2026-07-03 00:00:00'",
), ),
), ),
) )
@@ -558,6 +567,20 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
for fragment in fragments: for fragment in fragments:
self.assertIn(fragment, sql) self.assertIn(fragment, sql)
def test_time_conditions_include_fractional_last_second_with_exclusive_next_day(self) -> None:
lower, upper = OperationDao._time_conditions(
BizUsageRecord.occurred_at,
'2026-07-02',
'2026-07-02',
)
self.assertIs(operators.ge, lower.operator)
self.assertIs(operators.lt, upper.operator)
self.assertEqual(datetime(2026, 7, 2), lower.right.value)
self.assertEqual(datetime(2026, 7, 3), upper.right.value)
fractional_last_second = datetime(2026, 7, 2, 23, 59, 59, 500000)
self.assertTrue(lower.right.value <= fractional_last_second < upper.right.value)
async def test_order_details_load_items_explicitly_in_stable_order(self) -> None: async def test_order_details_load_items_explicitly_in_stable_order(self) -> None:
cases = ( cases = (
( (
@@ -675,11 +698,18 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited() self.db.rollback.assert_not_awaited()
async def test_update_methods_target_primary_key_and_flush_without_commit(self) -> None: async def test_update_methods_target_primary_key_and_exclude_creation_audit(self) -> None:
malicious_create_time = datetime(2020, 1, 2, 3, 4, 5)
cases = ( cases = (
( (
OperationDao.update_inbound, OperationDao.update_inbound,
BizInboundOrder(inbound_id=11, warehouse_id=3, status='CONFIRMED'), BizInboundOrder(
inbound_id=11,
warehouse_id=3,
status='CONFIRMED',
create_by='malicious',
create_time=malicious_create_time,
),
BizInboundOrder, BizInboundOrder,
'inbound_id', 'inbound_id',
11, 11,
@@ -687,7 +717,12 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
), ),
( (
OperationDao.update_filling_record, OperationDao.update_filling_record,
BizFillingRecord(filling_id=12, active_slot=None), BizFillingRecord(
filling_id=12,
active_slot=None,
create_by='malicious',
create_time=malicious_create_time,
),
BizFillingRecord, BizFillingRecord,
'filling_id', 'filling_id',
12, 12,
@@ -695,7 +730,12 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
), ),
( (
OperationDao.update_shipment, OperationDao.update_shipment,
BizShipmentOrder(shipment_id=13, status='CONFIRMED'), BizShipmentOrder(
shipment_id=13,
status='CONFIRMED',
create_by='malicious',
create_time=malicious_create_time,
),
BizShipmentOrder, BizShipmentOrder,
'shipment_id', 'shipment_id',
13, 13,
@@ -703,7 +743,12 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
), ),
( (
OperationDao.update_return_order, OperationDao.update_return_order,
BizReturnOrder(return_id=14, status='CONFIRMED'), BizReturnOrder(
return_id=14,
status='CONFIRMED',
create_by='malicious',
create_time=malicious_create_time,
),
BizReturnOrder, BizReturnOrder,
'return_id', 'return_id',
14, 14,
@@ -722,27 +767,49 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
sql = compile_sql(statement) sql = compile_sql(statement)
self.assertIn(f'WHERE {model.__tablename__}.{id_field} = {record_id}', sql) self.assertIn(f'WHERE {model.__tablename__}.{id_field} = {record_id}', sql)
self.assertIn(value_fragment, sql) self.assertIn(value_fragment, sql)
self.assertNotIn('create_by', sql)
self.assertNotIn('create_time', sql)
self.db.flush.assert_awaited_once() self.db.flush.assert_awaited_once()
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited() self.db.rollback.assert_not_awaited()
async def test_delete_methods_target_one_order_without_commit(self) -> None: async def test_delete_methods_remove_items_before_parent_without_commit(self) -> None:
cases = ( cases = (
(OperationDao.delete_inbound, BizInboundOrder, 'inbound_id', 11), (
(OperationDao.delete_shipment, BizShipmentOrder, 'shipment_id', 12), OperationDao.delete_inbound,
BizInboundItem,
BizInboundOrder,
'inbound_id',
11,
),
(
OperationDao.delete_shipment,
BizShipmentItem,
BizShipmentOrder,
'shipment_id',
12,
),
) )
for method, model, id_field, record_id in cases: for method, item_model, order_model, id_field, record_id in cases:
with self.subTest(method=method.__name__): with self.subTest(method=method.__name__):
self.db.reset_mock() self.db.reset_mock()
await method(self.db, record_id) await method(self.db, record_id)
statement = self.db.execute.await_args.args[0] self.assertEqual(2, len(self.db.execute.await_args_list))
self.assertTrue(statement.table.compare(model.__table__)) item_statement = self.db.execute.await_args_list[0].args[0]
self.assertIn( order_statement = self.db.execute.await_args_list[1].args[0]
f'{model.__tablename__}.{id_field} = {record_id}', self.assertTrue(item_statement.table.compare(item_model.__table__))
compile_sql(statement), self.assertTrue(order_statement.table.compare(order_model.__table__))
) for dialect in (mysql.dialect(), postgresql.dialect()):
self.assertIn(
f'{item_model.__tablename__}.{id_field} = {record_id}',
compile_sql(item_statement, dialect),
)
self.assertIn(
f'{order_model.__tablename__}.{id_field} = {record_id}',
compile_sql(order_statement, dialect),
)
self.db.flush.assert_awaited_once() self.db.flush.assert_awaited_once()
self.db.commit.assert_not_awaited() self.db.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited() self.db.rollback.assert_not_awaited()
@@ -569,6 +569,14 @@ WRITE_MODELS = (
ReturnConfirmModel, ReturnConfirmModel,
) )
OPERATION_PAGE_QUERY_MODELS = (
InboundPageQueryModel,
FillingPageQueryModel,
ShipmentPageQueryModel,
UsagePageQueryModel,
ReturnPageQueryModel,
)
PAGE_QUERY_MODELS = ( PAGE_QUERY_MODELS = (
StrainPageQueryModel, StrainPageQueryModel,
CustomerPageQueryModel, CustomerPageQueryModel,
@@ -576,11 +584,7 @@ PAGE_QUERY_MODELS = (
LinePageQueryModel, LinePageQueryModel,
WarehousePageQueryModel, WarehousePageQueryModel,
PalletPageQueryModel, PalletPageQueryModel,
InboundPageQueryModel, *OPERATION_PAGE_QUERY_MODELS,
FillingPageQueryModel,
ShipmentPageQueryModel,
UsagePageQueryModel,
ReturnPageQueryModel,
PalletInventoryReportQueryModel, PalletInventoryReportQueryModel,
FillingReportQueryModel, FillingReportQueryModel,
ShipmentReportQueryModel, ShipmentReportQueryModel,
@@ -775,6 +779,36 @@ class BusinessVoContractTest(unittest.TestCase):
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
model_class.model_validate({'pageSize': 0}) model_class.model_validate({'pageSize': 0})
def test_operation_page_query_dates_require_strict_calendar_format(self) -> None:
for model_class in OPERATION_PAGE_QUERY_MODELS:
with self.subTest(model=model_class.__name__, case='begin-only'):
model = model_class.model_validate({'beginTime': '2026-07-01'})
self.assertEqual('2026-07-01', model.begin_time)
self.assertIsNone(model.end_time)
with self.subTest(model=model_class.__name__, case='end-only'):
model = model_class.model_validate({'endTime': '2026-07-31'})
self.assertIsNone(model.begin_time)
self.assertEqual('2026-07-31', model.end_time)
for field_name in ('beginTime', 'endTime'):
for invalid_date in ('not-a-date', '2026-02-30', '2026-7-01', '2026-07-1'):
with (
self.subTest(
model=model_class.__name__,
field=field_name,
value=invalid_date,
),
self.assertRaises(ValidationError),
):
model_class.model_validate({field_name: invalid_date})
def test_operation_page_query_date_range_must_not_be_inverted(self) -> None:
for model_class in OPERATION_PAGE_QUERY_MODELS:
with self.subTest(model=model_class.__name__, case='equal'):
model = model_class.model_validate({'beginTime': '2026-07-02', 'endTime': '2026-07-02'})
self.assertEqual(model.begin_time, model.end_time)
with self.subTest(model=model_class.__name__, case='inverted'), self.assertRaises(ValidationError):
model_class.model_validate({'beginTime': '2026-07-03', 'endTime': '2026-07-02'})
def test_pallet_page_size_is_capped_at_100(self) -> None: def test_pallet_page_size_is_capped_at_100(self) -> None:
self.assertEqual(100, PalletPageQueryModel.model_validate({'pageSize': 100}).page_size) self.assertEqual(100, PalletPageQueryModel.model_validate({'pageSize': 100}).page_size)
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):