功能:实现多托盘发货出库

This commit is contained in:
焦龙言
2026-07-26 19:11:52 +08:00
parent 66890ca63c
commit 7317631a13
8 changed files with 2867 additions and 25 deletions
@@ -3,6 +3,7 @@ import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy.dialects import mysql, postgresql
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import InstrumentedAttribute
from sqlalchemy.sql import Select, operators
@@ -88,6 +89,33 @@ class AccessServiceTest(unittest.IsolatedAsyncioTestCase):
user_has_site.assert_awaited_once_with(db, 8, 7)
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_locking_site_access_is_forwarded(self, user_has_site: AsyncMock) -> None:
parameter = inspect.signature(AccessService.assert_site_access).parameters.get('for_update')
self.assertIsNotNone(parameter)
self.assertIs(inspect.Parameter.KEYWORD_ONLY, parameter.kind)
self.assertIs(False, parameter.default)
user_has_site.return_value = True
user = SimpleNamespace(user=SimpleNamespace(user_id=8, admin=False))
db = AsyncMock(spec=AsyncSession)
await AccessService.assert_site_access(db, user, 7, for_update=True)
user_has_site.assert_awaited_once_with(db, 8, 7, for_update=True)
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_admin_bypasses_locking_site_mapping(self, user_has_site: AsyncMock) -> None:
user = SimpleNamespace(user=SimpleNamespace(user_id=1, admin=True))
await AccessService.assert_site_access(
AsyncMock(spec=AsyncSession),
user,
7,
for_update=True,
)
user_has_site.assert_not_awaited()
@patch('module_business.service.access_service.AccessDao.user_has_site', new_callable=AsyncMock)
async def test_unmapped_site_is_forbidden(self, user_has_site: AsyncMock) -> None:
user_has_site.return_value = False
@@ -176,8 +204,11 @@ class AccessDaoTest(unittest.IsolatedAsyncioTestCase):
await AccessDao.user_has_warehouse(db, 8, 99)
statement = db.execute.await_args.args[0]
sql = ' '.join(str(statement.compile(compile_kwargs={'literal_binds': True})).split())
self.assertIs(for_update, 'FOR UPDATE' in sql)
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = ' '.join(
str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).split()
)
self.assertIs(for_update, 'FOR UPDATE' in sql)
async def test_user_has_site_returns_mapping_existence(self) -> None:
for scalar, expected in ((8, True), (None, False)):
@@ -200,6 +231,30 @@ class AccessDaoTest(unittest.IsolatedAsyncioTestCase):
),
)
async def test_user_has_site_current_read_is_optional_keyword_only_lock(self) -> None:
parameter = inspect.signature(AccessDao.user_has_site).parameters.get('for_update')
self.assertIsNotNone(parameter)
self.assertIs(inspect.Parameter.KEYWORD_ONLY, parameter.kind)
self.assertIs(False, parameter.default)
for for_update in (False, True):
with self.subTest(for_update=for_update):
result = MagicMock()
result.scalar_one_or_none.return_value = 8
db = self._db_with_result(result)
if for_update:
await AccessDao.user_has_site(db, 8, 7, for_update=True)
else:
await AccessDao.user_has_site(db, 8, 7)
statement = db.execute.await_args.args[0]
for dialect in (mysql.dialect(), postgresql.dialect()):
sql = ' '.join(
str(statement.compile(dialect=dialect, compile_kwargs={'literal_binds': True})).split()
)
self.assertIs(for_update, 'FOR UPDATE' in sql)
async def test_list_user_warehouse_ids_returns_scalar_ids(self) -> None:
result = MagicMock()
result.scalars.return_value.all.return_value = (11, 13)
@@ -122,11 +122,16 @@ class TestDaoPublicContract(unittest.TestCase):
def test_operation_dao_critical_parameter_names_and_defaults_are_stable(self) -> None:
expected = {
'get_inbound_for_update': ('db', 'inbound_id', 'warehouse_ids'),
'get_shipment_for_update': ('db', 'shipment_id'),
'get_shipment_for_update': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'),
'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_shipment_by_confirm_idempotency_key': (
'db',
'idempotency_key',
'warehouse_ids',
'site_ids',
),
'get_filling_by_idempotency_key': ('db', 'idempotency_key', 'warehouse_ids'),
'get_usage_by_idempotency_key': ('db', 'idempotency_key'),
'get_return_by_request_idempotency_key': ('db', 'idempotency_key'),
@@ -139,7 +144,7 @@ class TestDaoPublicContract(unittest.TestCase):
'get_filling_by_id': ('db', 'filling_id', 'warehouse_ids'),
'add_filling_record': ('db', 'filling'),
'update_filling_record': ('db', 'filling'),
'get_shipment_by_id': ('db', 'shipment_id'),
'get_shipment_by_id': ('db', 'shipment_id', 'warehouse_ids', 'site_ids'),
'add_shipment': ('db', 'shipment'),
'update_shipment': ('db', 'shipment'),
'delete_shipment': ('db', 'shipment_id'),
@@ -177,6 +182,16 @@ class TestDaoPublicContract(unittest.TestCase):
if warehouse_ids is not None:
self.assertIsNone(warehouse_ids.default)
for method_name in (
'get_shipment_for_update',
'get_shipment_by_confirm_idempotency_key',
'get_shipment_by_id',
):
with self.subTest(method=method_name):
signature = inspect.signature(getattr(OperationDao, method_name))
self.assertIsNone(signature.parameters['warehouse_ids'].default)
self.assertIsNone(signature.parameters['site_ids'].default)
filling_list_signature = inspect.signature(OperationDao.get_filling_list)
self.assertEqual(
('db', 'query_object', 'is_page', 'warehouse_ids'),
@@ -185,7 +200,16 @@ class TestDaoPublicContract(unittest.TestCase):
self.assertIs(True, filling_list_signature.parameters['is_page'].default)
self.assertIsNone(filling_list_signature.parameters['warehouse_ids'].default)
for method_name in ('get_shipment_list', 'get_usage_list', 'get_return_list'):
shipment_list_signature = inspect.signature(OperationDao.get_shipment_list)
self.assertEqual(
('db', 'query_object', 'is_page', 'warehouse_ids', 'site_ids'),
tuple(shipment_list_signature.parameters),
)
self.assertIs(True, shipment_list_signature.parameters['is_page'].default)
self.assertIsNone(shipment_list_signature.parameters['warehouse_ids'].default)
self.assertIsNone(shipment_list_signature.parameters['site_ids'].default)
for method_name in ('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))
@@ -383,13 +407,6 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
async def test_for_update_queries_target_primary_keys_and_lock_rows(self) -> None:
cases = (
(
OperationDao.get_shipment_for_update,
BizShipmentOrder,
'shipment_id',
12,
(),
),
(
OperationDao.get_return_for_update,
BizReturnOrder,
@@ -426,6 +443,69 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
for fragment in extra_fragments:
self.assertIn(fragment, sql)
async def test_shipment_for_update_locks_scoped_parent_then_current_items(self) -> None:
shipment = SimpleNamespace(shipment_id=12)
items = [
SimpleNamespace(shipment_item_id=1, shipment_id=12, pallet_id=5),
SimpleNamespace(shipment_item_id=3, shipment_id=12, pallet_id=2),
]
parent_result = MagicMock()
parent_result.scalar_one_or_none.return_value = shipment
item_result = MagicMock()
item_result.scalars.return_value.all.return_value = items
self.db.execute.side_effect = [parent_result, item_result]
actual = await OperationDao.get_shipment_for_update(
self.db,
12,
warehouse_ids=[7, 3, 7],
site_ids=[11, 9, 11],
)
self.assertIs(shipment, actual)
self.assertEqual([2, 5], [item.pallet_id for item in actual.items])
self.assertEqual(2, self.db.execute.await_count)
parent_statement = self.db.execute.await_args_list[0].args[0]
item_statement = self.db.execute.await_args_list[1].args[0]
self.assertTrue(parent_statement.get_final_froms()[0].compare(BizShipmentOrder.__table__))
self.assertTrue(item_statement.get_final_froms()[0].compare(BizShipmentItem.__table__))
self.assertIs(True, parent_statement.get_execution_options().get('populate_existing'))
self.assertIs(True, item_statement.get_execution_options().get('populate_existing'))
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
parent_sql = compile_sql(parent_statement, dialect)
item_sql = compile_sql(item_statement, dialect)
self.assertIn('biz_shipment_order.shipment_id = 12', parent_sql)
self.assertIn('biz_shipment_order.source_warehouse_id IN (3, 7)', parent_sql)
self.assertIn('biz_shipment_order.target_site_id IN (9, 11)', parent_sql)
self.assertIn('FOR UPDATE', parent_sql)
self.assertIn('biz_shipment_item.shipment_id = 12', item_sql)
self.assertIn(
'ORDER BY biz_shipment_item.pallet_id, biz_shipment_item.shipment_item_id',
item_sql,
)
self.assertIn('FOR UPDATE', item_sql)
async def test_shipment_for_update_missing_scoped_parent_skips_item_lock(self) -> None:
parent_result = MagicMock()
parent_result.scalar_one_or_none.return_value = None
self.db.execute.return_value = parent_result
actual = await OperationDao.get_shipment_for_update(
self.db,
12,
warehouse_ids=[],
site_ids=[9],
)
self.assertIsNone(actual)
self.db.execute.assert_awaited_once()
parent_statement = self.db.execute.await_args.args[0]
mysql_sql = compile_sql(parent_statement, mysql.dialect())
postgresql_sql = compile_sql(parent_statement, postgresql.dialect())
self.assertIn('false = 1', mysql_sql)
self.assertIn('false', postgresql_sql)
async def test_inbound_for_update_locks_parent_then_current_items(self) -> None:
inbound = SimpleNamespace(inbound_id=11)
items = [
@@ -716,6 +796,74 @@ class TestOperationDaoReads(unittest.IsolatedAsyncioTestCase):
self.assertNotIn('warehouse_id IN', sql)
self.assertNotIn('WHERE false', sql)
async def test_shipment_list_applies_both_scopes_and_empty_scope_is_false(self) -> None:
query = ShipmentPageQueryModel(page_num=2, page_size=7)
marker = object()
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as paginate:
actual = await OperationDao.get_shipment_list(
self.db,
query,
is_page=True,
warehouse_ids=[7, 3, 7],
site_ids=[11, 9, 11],
)
self.assertIs(marker, actual)
statement = paginate.await_args.args[1]
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
sql = compile_sql(statement, dialect)
self.assertIn('biz_shipment_order.source_warehouse_id IN (3, 7)', sql)
self.assertIn('biz_shipment_order.target_site_id IN (9, 11)', sql)
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=marker)) as empty_paginate:
await OperationDao.get_shipment_list(
self.db,
query,
warehouse_ids=[7],
site_ids=[],
)
empty_statement = empty_paginate.await_args.args[1]
self.assertIn('false = 1', compile_sql(empty_statement, mysql.dialect()))
self.assertIn('false', compile_sql(empty_statement, postgresql.dialect()))
async def test_shipment_detail_and_confirm_key_push_both_scopes_into_parent_sql(self) -> None:
parent_result = MagicMock()
parent_result.scalar_one_or_none.return_value = None
self.db.execute.return_value = parent_result
self.assertIsNone(
await OperationDao.get_shipment_by_id(
self.db,
12,
warehouse_ids=[7, 3],
site_ids=[11, 9],
)
)
self.db.execute.assert_awaited_once()
detail_statement = self.db.execute.await_args.args[0]
self.db.reset_mock()
self.db.execute.return_value = parent_result
self.assertIsNone(
await OperationDao.get_shipment_by_confirm_idempotency_key(
self.db,
'confirm-key',
warehouse_ids=[7, 3],
site_ids=[11, 9],
)
)
key_statement = self.db.execute.await_args.args[0]
for dialect in (mysql.dialect(), postgresql.dialect()):
with self.subTest(dialect=dialect.name):
detail_sql = compile_sql(detail_statement, dialect)
key_sql = compile_sql(key_statement, dialect)
for sql in (detail_sql, key_sql):
self.assertIn('biz_shipment_order.source_warehouse_id IN (3, 7)', sql)
self.assertIn('biz_shipment_order.target_site_id IN (9, 11)', sql)
self.assertIn("biz_shipment_order.confirm_idempotency_key = 'confirm-key'", key_sql)
self.assertNotIn('FOR UPDATE', key_sql)
def test_time_conditions_include_fractional_last_second_with_exclusive_next_day(self) -> None:
lower, upper = OperationDao._time_conditions(
BizUsageRecord.occurred_at,
File diff suppressed because it is too large Load Diff