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

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,11 +1,13 @@
import inspect
import unittest
from datetime import datetime
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 sqlalchemy.sql import operators
from module_business.dao import inventory_dao as inventory_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)
statement = self.db.execute.await_args.args[0]
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()):
sql = compile_sql(statement, dialect)
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])
statement = self.db.execute.await_args.args[0]
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()):
sql = compile_sql(statement, dialect)
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])
statement = self.db.execute.await_args.args[0]
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()):
sql = compile_sql(statement, dialect)
self.assertIn('biz_pallet.pallet_id IN (2, 5, 9)', sql)
@@ -283,7 +288,6 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase):
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')
@@ -306,15 +310,19 @@ class TestInventoryDao(unittest.IsolatedAsyncioTestCase):
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)
self.assertTrue(statement.selected_columns[0].compare(BizPalletEvent.event_seq.property.columns[0]))
for dialect in (mysql.dialect(), postgresql.dialect()):
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:
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)
@@ -373,6 +381,7 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
self.assertIs(record, actual)
statement = self.db.execute.await_args.args[0]
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()):
sql = compile_sql(statement, dialect)
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.status = 'DRAFT'",
"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_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'",
"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.status = 'CONFIRMED'",
"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_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'",
"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.status = 'REQUESTED'",
"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:
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:
cases = (
(
@@ -675,11 +698,18 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
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:
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 = (
(
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,
'inbound_id',
11,
@@ -687,7 +717,12 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
),
(
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,
'filling_id',
12,
@@ -695,7 +730,12 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
),
(
OperationDao.update_shipment,
BizShipmentOrder(shipment_id=13, status='CONFIRMED'),
BizShipmentOrder(
shipment_id=13,
status='CONFIRMED',
create_by='malicious',
create_time=malicious_create_time,
),
BizShipmentOrder,
'shipment_id',
13,
@@ -703,7 +743,12 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
),
(
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,
'return_id',
14,
@@ -722,27 +767,49 @@ class TestOperationDaoWrites(unittest.IsolatedAsyncioTestCase):
sql = compile_sql(statement)
self.assertIn(f'WHERE {model.__tablename__}.{id_field} = {record_id}', sql)
self.assertIn(value_fragment, sql)
self.assertNotIn('create_by', sql)
self.assertNotIn('create_time', 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:
async def test_delete_methods_remove_items_before_parent_without_commit(self) -> None:
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__):
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.assertEqual(2, len(self.db.execute.await_args_list))
item_statement = self.db.execute.await_args_list[0].args[0]
order_statement = self.db.execute.await_args_list[1].args[0]
self.assertTrue(item_statement.table.compare(item_model.__table__))
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.commit.assert_not_awaited()
self.db.rollback.assert_not_awaited()
@@ -569,6 +569,14 @@ WRITE_MODELS = (
ReturnConfirmModel,
)
OPERATION_PAGE_QUERY_MODELS = (
InboundPageQueryModel,
FillingPageQueryModel,
ShipmentPageQueryModel,
UsagePageQueryModel,
ReturnPageQueryModel,
)
PAGE_QUERY_MODELS = (
StrainPageQueryModel,
CustomerPageQueryModel,
@@ -576,11 +584,7 @@ PAGE_QUERY_MODELS = (
LinePageQueryModel,
WarehousePageQueryModel,
PalletPageQueryModel,
InboundPageQueryModel,
FillingPageQueryModel,
ShipmentPageQueryModel,
UsagePageQueryModel,
ReturnPageQueryModel,
*OPERATION_PAGE_QUERY_MODELS,
PalletInventoryReportQueryModel,
FillingReportQueryModel,
ShipmentReportQueryModel,
@@ -775,6 +779,36 @@ class BusinessVoContractTest(unittest.TestCase):
with self.assertRaises(ValidationError):
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:
self.assertEqual(100, PalletPageQueryModel.model_validate({'pageSize': 100}).page_size)
with self.assertRaises(ValidationError):