Feat: 后端新增基础资料与业务管理只读汇总接口
为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情 返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。 - 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转, 托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级 资料选项接口;Excel 导入支持 preview 只校验不写入 - 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和 菌种构成;详情明细补充托盘号、菌种、批次 - 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary - 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道; 新增 /summary、/site-options - 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、 /batches、/batches/detail - 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号; 新增 /site-options、/site-board 以及 usage-fifo 的 /queue - 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致 - 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -230,7 +230,11 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
||||
('/master/warehouse/{warehouse_id}/users', 'GET'): 'master:warehouse:assign',
|
||||
('/master/warehouse/{warehouse_id}/users', 'PUT'): 'master:warehouse:assign',
|
||||
('/master/pallet/{pallet_id}/qrcode', 'GET'): 'master:pallet:print',
|
||||
('/master/site/customer-options', 'GET'): 'master:site:list',
|
||||
('/master/line/site-options', 'GET'): 'master:line:list',
|
||||
('/master/tunnel/site-options', 'GET'): 'master:tunnel:list',
|
||||
('/business/inbound/list', 'GET'): 'business:inbound:list',
|
||||
('/business/inbound/summary', 'GET'): 'business:inbound:list',
|
||||
('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query',
|
||||
('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query',
|
||||
('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query',
|
||||
@@ -239,6 +243,9 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
||||
('/business/inbound/{inbound_ids}', 'DELETE'): 'business:inbound:list',
|
||||
('/business/inbound/{inbound_id}/confirm', 'PUT'): 'business:inbound:confirm',
|
||||
('/business/filling/list', 'GET'): 'business:filling:list',
|
||||
('/business/filling/summary', 'GET'): 'business:filling:list',
|
||||
('/business/filling/batches', 'GET'): 'business:filling:list',
|
||||
('/business/filling/batches/detail', 'GET'): 'business:filling:query',
|
||||
('/business/filling/strain-options', 'GET'): 'business:filling:query',
|
||||
('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query',
|
||||
('/business/filling/{filling_id}', 'GET'): 'business:filling:query',
|
||||
@@ -254,6 +261,7 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
||||
('/business/shipment-receipt/{shipment_id}/scan/{identifier}', 'GET'): 'business:shipment:receive',
|
||||
('/business/shipment-receipt/{shipment_id}/confirm', 'PUT'): 'business:shipment:receive',
|
||||
('/business/usage-fifo/recommendation', 'GET'): 'business:usage:query',
|
||||
('/business/usage-fifo/queue', 'GET'): 'business:usage:query',
|
||||
('/business/shipment', 'POST'): 'business:shipment:add',
|
||||
('/business/shipment', 'PUT'): 'business:shipment:edit',
|
||||
('/business/shipment/{shipment_ids}', 'DELETE'): 'business:shipment:list',
|
||||
@@ -261,11 +269,15 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
||||
('/business/usage/list', 'GET'): 'business:usage:list',
|
||||
('/business/usage/line-options', 'GET'): 'business:usage:query',
|
||||
('/business/usage/tunnel-options', 'GET'): 'business:usage:query',
|
||||
('/business/usage/site-options', 'GET'): 'business:usage:list',
|
||||
('/business/usage/site-board', 'GET'): 'business:usage:query',
|
||||
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add',
|
||||
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
|
||||
('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
|
||||
('/business/usage', 'POST'): 'business:usage:add',
|
||||
('/business/return/list', 'GET'): 'business:return:list',
|
||||
('/business/return/summary', 'GET'): 'business:return:list',
|
||||
('/business/return/site-options', 'GET'): 'business:return:list',
|
||||
('/business/return/warehouse-options', 'GET'): 'business:return:query',
|
||||
('/business/return/scan/{qr_token}', 'GET'): 'business:return:query',
|
||||
('/business/return/{return_id}', 'GET'): 'business:return:query',
|
||||
@@ -295,11 +307,18 @@ def _build_expected_route_permissions() -> dict[tuple[str, str], str]:
|
||||
|
||||
EXPECTED_ROUTE_PERMISSIONS = _build_expected_route_permissions()
|
||||
STATIC_READS_BEFORE_DETAIL = {
|
||||
'/master/site/{site_id}': ('/master/site/customer-options',),
|
||||
'/master/line/{line_id}': ('/master/line/site-options',),
|
||||
'/master/tunnel/{tunnel_id}': ('/master/tunnel/site-options',),
|
||||
'/business/inbound/{inbound_id}': (
|
||||
'/business/inbound/summary',
|
||||
'/business/inbound/warehouse-options',
|
||||
'/business/inbound/scan/{qr_token}',
|
||||
),
|
||||
'/business/filling/{filling_id}': (
|
||||
'/business/filling/summary',
|
||||
'/business/filling/batches',
|
||||
'/business/filling/batches/detail',
|
||||
'/business/filling/strain-options',
|
||||
'/business/filling/scan/{qr_token}',
|
||||
),
|
||||
@@ -311,9 +330,13 @@ STATIC_READS_BEFORE_DETAIL = {
|
||||
'/business/usage/{usage_id}': (
|
||||
'/business/usage/line-options',
|
||||
'/business/usage/tunnel-options',
|
||||
'/business/usage/site-options',
|
||||
'/business/usage/site-board',
|
||||
'/business/usage/scan/{qr_token}',
|
||||
),
|
||||
'/business/return/{return_id}': (
|
||||
'/business/return/summary',
|
||||
'/business/return/site-options',
|
||||
'/business/return/warehouse-options',
|
||||
'/business/return/scan/{qr_token}',
|
||||
),
|
||||
@@ -327,6 +350,8 @@ EXACT_QUERY_PARAMETERS = {
|
||||
('/business/shipment/scan/{qr_token}', 'GET'): {'sourceWarehouseId', 'targetSiteId'},
|
||||
('/business/usage/line-options', 'GET'): {'siteId'},
|
||||
('/business/usage/tunnel-options', 'GET'): {'siteId'},
|
||||
('/business/usage/site-board', 'GET'): {'siteId'},
|
||||
('/business/usage-fifo/queue', 'GET'): {'siteId'},
|
||||
('/business/return/scan/{qr_token}', 'GET'): {'mode', 'returnId'},
|
||||
}
|
||||
REQUIRED_QUERY_PARAMETERS = {
|
||||
@@ -349,7 +374,7 @@ def _auth_dependencies(route: APIRoute) -> list[CheckUserInterfaceAuth]:
|
||||
|
||||
def _assert_controller_permission_literals(test_case: unittest.TestCase, literals: list[str]) -> None:
|
||||
expected = Counter(EXPECTED_ROUTE_PERMISSIONS.values())
|
||||
test_case.assertEqual(119, expected.total())
|
||||
test_case.assertEqual(131, expected.total())
|
||||
test_case.assertEqual(expected, Counter(literals))
|
||||
test_case.assertEqual(EXPECTED_PERMISSIONS, frozenset(expected))
|
||||
|
||||
@@ -501,7 +526,7 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
}
|
||||
|
||||
self.assertEqual(set(EXPECTED_ROUTE_PERMISSIONS) | UNGUARDED_AUTHENTICATED_ROUTES, actual_operations)
|
||||
self.assertEqual(121, len(actual_operations))
|
||||
self.assertEqual(133, len(actual_operations))
|
||||
|
||||
openapi_ai_paths = sorted(path for path in self.schema['paths'] if path == '/ai' or path.startswith('/ai/'))
|
||||
app_ai_paths = sorted(
|
||||
@@ -582,7 +607,7 @@ class ApiPermissionContractTest(unittest.TestCase):
|
||||
|
||||
def test_controller_ast_permission_contract_rejects_duplicate_call(self) -> None:
|
||||
literals = list(EXPECTED_ROUTE_PERMISSIONS.values())
|
||||
self.assertEqual(119, len(literals))
|
||||
self.assertEqual(131, len(literals))
|
||||
literals.append(literals[0])
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
|
||||
@@ -21,6 +21,7 @@ from module_admin.entity.vo.user_vo import CurrentUserModel, UserInfoModel
|
||||
from module_admin.service.login_service import LoginService
|
||||
from module_business.controller import filling_controller as filling_controller_module
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.filling_summary_dao import FillingSummaryDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
@@ -30,8 +31,12 @@ from module_business.entity.do.master_do import BizPallet, BizStrain
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletEvent, BizPalletInventory
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchModel,
|
||||
FillingCreateModel,
|
||||
FillingListItemModel,
|
||||
FillingModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
PalletScanResponseModel,
|
||||
)
|
||||
@@ -242,6 +247,7 @@ class TestFillingReadServices(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[9, 7, 7])),
|
||||
patch.object(OperationDao, 'get_filling_list', new=get_list),
|
||||
patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
|
||||
):
|
||||
result = await FillingService.get_filling_list_services(self.db, query, self.user, TestBatchScope.formal())
|
||||
|
||||
@@ -1169,6 +1175,9 @@ class TestFillingController(unittest.TestCase):
|
||||
schema = app.openapi()
|
||||
expected = {
|
||||
('/business/filling/list', 'get'),
|
||||
('/business/filling/summary', 'get'),
|
||||
('/business/filling/batches', 'get'),
|
||||
('/business/filling/batches/detail', 'get'),
|
||||
('/business/filling/strain-options', 'get'),
|
||||
('/business/filling/scan/{qr_token}', 'get'),
|
||||
('/business/filling/{filling_id}', 'get'),
|
||||
@@ -1179,7 +1188,21 @@ class TestFillingController(unittest.TestCase):
|
||||
self.assertEqual({'qr_token'}, {parameter['name'] for parameter in scan_parameters})
|
||||
|
||||
routes = self._routes()
|
||||
self.assertEqual(PageResponseModel[FillingModel], routes[('/business/filling/list', 'GET')].response_model)
|
||||
self.assertEqual(
|
||||
PageResponseModel[FillingListItemModel], routes[('/business/filling/list', 'GET')].response_model
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[FillingOverviewModel],
|
||||
routes[('/business/filling/summary', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
PageResponseModel[FillingBatchModel],
|
||||
routes[('/business/filling/batches', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[FillingBatchDetailModel],
|
||||
routes[('/business/filling/batches/detail', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/filling/strain-options', 'GET')].response_model,
|
||||
@@ -1197,12 +1220,21 @@ class TestFillingController(unittest.TestCase):
|
||||
def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
|
||||
paths = [route.path_format for route in filling_controller_module.filling_controller.routes]
|
||||
detail_index = paths.index('/business/filling/{filling_id}')
|
||||
self.assertLess(paths.index('/business/filling/strain-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/filling/scan/{qr_token}'), detail_index)
|
||||
for static_path in (
|
||||
'/business/filling/summary',
|
||||
'/business/filling/batches',
|
||||
'/business/filling/batches/detail',
|
||||
'/business/filling/strain-options',
|
||||
'/business/filling/scan/{qr_token}',
|
||||
):
|
||||
self.assertLess(paths.index(static_path), detail_index)
|
||||
|
||||
def test_each_route_has_exact_permission_and_three_unique_values(self) -> None:
|
||||
expected = {
|
||||
('/business/filling/list', 'GET'): 'business:filling:list',
|
||||
('/business/filling/summary', 'GET'): 'business:filling:list',
|
||||
('/business/filling/batches', 'GET'): 'business:filling:list',
|
||||
('/business/filling/batches/detail', 'GET'): 'business:filling:query',
|
||||
('/business/filling/strain-options', 'GET'): 'business:filling:query',
|
||||
('/business/filling/scan/{qr_token}', 'GET'): 'business:filling:query',
|
||||
('/business/filling/{filling_id}', 'GET'): 'business:filling:query',
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import unittest
|
||||
from datetime import date, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.filling_summary_dao import FillingSummaryDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchDetailQueryModel,
|
||||
FillingBatchModel,
|
||||
FillingBatchPageQueryModel,
|
||||
FillingListItemModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
)
|
||||
from module_business.service.filling_service import FillingService
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
|
||||
def compile_sql(statement: Any) -> str:
|
||||
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
|
||||
return ' '.join(str(compiled).split()).replace('%%', '%')
|
||||
|
||||
|
||||
def make_test_scope(batch_id: int) -> TestBatchScope:
|
||||
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
|
||||
|
||||
|
||||
def result_rows(rows: list[Any]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.all.return_value = rows
|
||||
result.first.return_value = rows[0] if rows else None
|
||||
return result
|
||||
|
||||
|
||||
def result_scalar(value: Any) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.scalar_one.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
def batch_row(**overrides: Any) -> SimpleNamespace:
|
||||
values = {
|
||||
'strain_id': 3,
|
||||
'batch_no': 'B261017-01',
|
||||
'strain_code': 'A15',
|
||||
'strain_name': '平菇 A15',
|
||||
'production_date': date(2026, 10, 16),
|
||||
'expiry_date': date(2026, 11, 30),
|
||||
'pallet_count': 6,
|
||||
'package_sum': 600,
|
||||
'last_operated_at': datetime(2026, 10, 17, 9, 10),
|
||||
'last_filling_id': 88,
|
||||
}
|
||||
values.update(overrides)
|
||||
return SimpleNamespace(**values)
|
||||
|
||||
|
||||
def admin_user() -> CurrentUserModel:
|
||||
return CurrentUserModel.model_construct(
|
||||
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
|
||||
)
|
||||
|
||||
|
||||
def normal_user() -> CurrentUserModel:
|
||||
return CurrentUserModel.model_construct(
|
||||
permissions=['business:filling:list'],
|
||||
roles=['common'],
|
||||
user=SimpleNamespace(user_id=5, user_name='ln', admin=False),
|
||||
)
|
||||
|
||||
|
||||
class FillingListFilterTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def _capture(self, query: FillingPageQueryModel) -> str:
|
||||
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
|
||||
await OperationDao.get_filling_list(
|
||||
MagicMock(spec=AsyncSession),
|
||||
query,
|
||||
warehouse_ids=[7],
|
||||
scope=make_test_scope(4),
|
||||
)
|
||||
return compile_sql(paginate.await_args.args[1])
|
||||
|
||||
async def test_keyword_matches_filling_no_pallet_code_or_batch_within_scope(self) -> None:
|
||||
sql = await self._capture(FillingPageQueryModel(keyword='B2610'))
|
||||
for fragment in (
|
||||
'JOIN biz_pallet ON biz_pallet.pallet_id = biz_filling_record.pallet_id',
|
||||
"biz_filling_record.filling_no LIKE '%B2610%'",
|
||||
"biz_pallet.pallet_code LIKE '%B2610%'",
|
||||
"biz_filling_record.batch_no LIKE '%B2610%'",
|
||||
'biz_filling_record.test_batch_id = 4',
|
||||
'biz_filling_record.warehouse_id IN (7)',
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
async def test_without_keyword_or_pallet_code_list_does_not_join_pallets(self) -> None:
|
||||
sql = await self._capture(FillingPageQueryModel(batchNo='B1'))
|
||||
self.assertNotIn('JOIN', sql)
|
||||
|
||||
def test_keyword_rejects_blank_value(self) -> None:
|
||||
for model in (FillingPageQueryModel, FillingBatchPageQueryModel):
|
||||
with self.subTest(model=model.__name__), self.assertRaises(ValueError):
|
||||
model(keyword=' ')
|
||||
|
||||
|
||||
class FillingListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_empty_ids_skip_database(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
self.assertEqual({}, await FillingSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
|
||||
db.execute.assert_not_called()
|
||||
|
||||
async def test_names_are_joined_in_same_batch_scope(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10401', '成品一仓', '年糕')]))
|
||||
|
||||
summaries = await FillingSummaryDao.get_list_summaries(db, [32, 31, 31], make_test_scope(4))
|
||||
|
||||
self.assertEqual(
|
||||
{'pallet_code': 'TP-10401', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}, summaries[31]
|
||||
)
|
||||
self.assertEqual({'pallet_code': None, 'warehouse_name': None, 'operator_name': None}, summaries[32])
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
for fragment in (
|
||||
'biz_filling_record.filling_id IN (31, 32)',
|
||||
'biz_filling_record.test_batch_id = 4',
|
||||
'biz_pallet.test_batch_id = 4',
|
||||
'biz_warehouse.test_batch_id = 4',
|
||||
'sys_user.user_id = biz_filling_record.operator_id',
|
||||
"sys_user.del_flag = '0'",
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
|
||||
class FillingBatchPageDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_groups_by_strain_and_batch_newest_first_within_scope(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_scalar(18),
|
||||
result_rows([batch_row(), batch_row(batch_no='B261016-01', last_filling_id=80)]),
|
||||
result_rows([(88, '1kg/包', '年糕'), (80, '1.2kg/包', None)]),
|
||||
]
|
||||
)
|
||||
query = FillingBatchPageQueryModel(
|
||||
pageNum=2, pageSize=5, keyword='A15', strainId=3, beginTime='2026-10-01', endTime='2026-10-17'
|
||||
)
|
||||
|
||||
batches, total = await FillingSummaryDao.get_batch_page(db, query, [7, 6], make_test_scope(4))
|
||||
|
||||
self.assertEqual(18, total)
|
||||
self.assertEqual(['B261017-01', 'B261016-01'], [batch['batch_no'] for batch in batches])
|
||||
self.assertEqual(('1kg/包', '年糕'), (batches[0]['package_spec'], batches[0]['last_operator_name']))
|
||||
self.assertEqual(('1.2kg/包', None), (batches[1]['package_spec'], batches[1]['last_operator_name']))
|
||||
FillingBatchModel.model_validate(batches[0])
|
||||
|
||||
count_sql, page_sql, latest_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
|
||||
for sql in (count_sql, page_sql):
|
||||
for fragment in (
|
||||
'GROUP BY biz_filling_record.strain_id, biz_filling_record.batch_no',
|
||||
'biz_filling_record.test_batch_id = 4',
|
||||
'biz_filling_record.warehouse_id IN (6, 7)',
|
||||
"biz_filling_record.batch_no LIKE '%A15%'",
|
||||
"biz_filling_record.strain_name_snapshot LIKE '%A15%'",
|
||||
'biz_filling_record.strain_id = 3',
|
||||
"biz_filling_record.operated_at >= '2026-10-01 00:00:00'",
|
||||
"biz_filling_record.operated_at < '2026-10-18 00:00:00'",
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
self.assertIn(
|
||||
'ORDER BY max(biz_filling_record.operated_at) DESC, max(biz_filling_record.filling_id) DESC', page_sql
|
||||
)
|
||||
self.assertIn('LIMIT 5, 5', page_sql)
|
||||
self.assertIn('biz_filling_record.filling_id IN (80, 88)', latest_sql)
|
||||
|
||||
async def test_empty_page_skips_latest_record_lookup(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(side_effect=[result_scalar(0), result_rows([])])
|
||||
|
||||
batches, total = await FillingSummaryDao.get_batch_page(
|
||||
db, FillingBatchPageQueryModel(), [], TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertEqual(([], 0), (batches, total))
|
||||
self.assertEqual(2, db.execute.await_count)
|
||||
self.assertIn('false', compile_sql(db.execute.await_args_list[0].args[0]).lower())
|
||||
|
||||
|
||||
class FillingBatchDetailDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_missing_batch_returns_none_without_further_queries(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([]))
|
||||
|
||||
self.assertIsNone(await FillingSummaryDao.get_batch_detail(db, 3, 'B1', [7], TestBatchScope.formal()))
|
||||
self.assertEqual(1, db.execute.await_count)
|
||||
|
||||
async def test_distribution_uses_active_cycle_and_closed_cycles_count_as_consumed(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
recent = [
|
||||
(90, 'FL-90', 501, 'TP-10501', '成品一仓', '李娜', datetime(2026, 10, 17, 9, 10), '1kg/包'),
|
||||
(89, 'FL-89', 500, None, None, None, datetime(2026, 10, 17, 9, 5), '0.8kg/包'),
|
||||
]
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_rows([batch_row(pallet_count=30)]),
|
||||
result_rows([('AT_SITE_UNUSED', 15), (None, 2), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5)]),
|
||||
result_rows(recent),
|
||||
]
|
||||
)
|
||||
|
||||
detail = await FillingSummaryDao.get_batch_detail(db, 3, 'B261017-01', [7], make_test_scope(4))
|
||||
|
||||
self.assertEqual(
|
||||
[('AT_SITE_UNUSED', 15), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5), ('CONSUMED', 2)],
|
||||
[(item['state'], item['pallet_count']) for item in detail['distribution']],
|
||||
)
|
||||
self.assertEqual(['TP-10501', None], [item['pallet_code'] for item in detail['recent']])
|
||||
# 包装规格与操作人取批次内最新一条记录
|
||||
self.assertEqual(('1kg/包', '李娜'), (detail['package_spec'], detail['last_operator_name']))
|
||||
FillingBatchDetailModel.model_validate(detail)
|
||||
|
||||
header_sql, distribution_sql, recent_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
|
||||
for sql in (header_sql, distribution_sql, recent_sql):
|
||||
for fragment in (
|
||||
'biz_filling_record.strain_id = 3',
|
||||
"biz_filling_record.batch_no = 'B261017-01'",
|
||||
'biz_filling_record.warehouse_id IN (7)',
|
||||
'biz_filling_record.test_batch_id = 4',
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
self.assertIn('biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id', distribution_sql)
|
||||
self.assertIn('biz_pallet_inventory.test_batch_id = 4', distribution_sql)
|
||||
self.assertIn('ORDER BY biz_filling_record.operated_at DESC, biz_filling_record.filling_id DESC', recent_sql)
|
||||
self.assertIn('LIMIT 10', recent_sql)
|
||||
|
||||
|
||||
class FillingOverviewDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
def _db(self) -> MagicMock:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_rows([(3, 'B261017-01', '平菇 A15', 'A15', 6, 600)]),
|
||||
result_rows(
|
||||
[
|
||||
(3, 'B261017-01', '平菇 A15', 'A15', 6, 600),
|
||||
(3, 'B261016-01', '平菇 A15', 'A15', 10, 1000),
|
||||
(4, 'B261015-01', '白灵菇 B22', 'B22', 27, 2700),
|
||||
(5, 'B261010-01', None, 'S130', 18, 1800),
|
||||
(6, 'B261008-01', '金针菇 J61', 'J61', 8, 800),
|
||||
]
|
||||
),
|
||||
result_rows([(6, '周转仓', 10), (4, '成品一仓', 13)]),
|
||||
result_rows([('B260930-01', date(2026, 10, 28), 3), ('B260923-01', date(2026, 10, 25), 4)]),
|
||||
]
|
||||
)
|
||||
return db
|
||||
|
||||
async def test_overview_totals_top_strains_empty_pallets_and_nearest_expiry(self) -> None:
|
||||
db = self._db()
|
||||
|
||||
overview = await FillingSummaryDao.get_overview(
|
||||
db,
|
||||
[4, 6],
|
||||
make_test_scope(4),
|
||||
datetime(2026, 10, 17),
|
||||
datetime(2026, 10, 1),
|
||||
date(2026, 10, 31),
|
||||
14,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
(6, 600, 1), (overview['today_pallet_count'], overview['today_package_sum'], overview['today_batch_count'])
|
||||
)
|
||||
self.assertEqual((69, 5), (overview['month_pallet_count'], overview['month_batch_count']))
|
||||
self.assertEqual(
|
||||
[('白灵菇 B22', 27), ('S130', 18), ('平菇 A15', 16)],
|
||||
[(item['strain_name'], item['pallet_count']) for item in overview['month_top_strains']],
|
||||
)
|
||||
self.assertEqual(23, overview['empty_pallet_count'])
|
||||
self.assertEqual([4, 6], [row['warehouse_id'] for row in overview['empty_by_warehouse']])
|
||||
self.assertEqual((7, 2), (overview['near_expiry_pallet_count'], overview['near_expiry_batch_count']))
|
||||
self.assertEqual(
|
||||
('B260923-01', date(2026, 10, 25)), (overview['nearest_expiry_batch_no'], overview['nearest_expiry_date'])
|
||||
)
|
||||
FillingOverviewModel.model_validate(overview)
|
||||
|
||||
today_sql, month_sql, empty_sql, near_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
|
||||
self.assertIn("biz_filling_record.operated_at >= '2026-10-17 00:00:00'", today_sql)
|
||||
self.assertIn("biz_filling_record.operated_at >= '2026-10-01 00:00:00'", month_sql)
|
||||
for sql in (today_sql, month_sql):
|
||||
self.assertIn('biz_filling_record.warehouse_id IN (4, 6)', sql)
|
||||
self.assertIn('biz_filling_record.test_batch_id = 4', sql)
|
||||
self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql)
|
||||
self.assertIn("biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'", near_sql)
|
||||
self.assertIn("biz_filling_record.expiry_date <= '2026-10-31'", near_sql)
|
||||
self.assertIn('biz_filling_record.filling_id = biz_pallet_inventory.active_filling_id', near_sql)
|
||||
for sql in (empty_sql, near_sql):
|
||||
self.assertIn('biz_pallet_inventory.warehouse_id IN (4, 6)', sql)
|
||||
self.assertIn('biz_pallet_inventory.test_batch_id = 4', sql)
|
||||
|
||||
async def test_user_without_warehouses_sees_nothing(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(side_effect=[result_rows([]), result_rows([]), result_rows([]), result_rows([])])
|
||||
|
||||
overview = await FillingSummaryDao.get_overview(
|
||||
db, [], TestBatchScope.formal(), datetime(2026, 10, 17), datetime(2026, 10, 1), date(2026, 10, 31), 14
|
||||
)
|
||||
|
||||
self.assertEqual(0, overview['month_pallet_count'])
|
||||
self.assertIsNone(overview['nearest_expiry_date'])
|
||||
FillingOverviewModel.model_validate(overview)
|
||||
for call in db.execute.await_args_list:
|
||||
self.assertIn('false', compile_sql(call.args[0]).lower())
|
||||
|
||||
|
||||
class FillingServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_list_rows_carry_names_in_camel_case(self) -> None:
|
||||
record = {
|
||||
'filling_id': 31,
|
||||
'filling_no': 'FL-1',
|
||||
'pallet_id': 501,
|
||||
'warehouse_id': 4,
|
||||
'strain_id': 3,
|
||||
'strain_code_snapshot': 'A15',
|
||||
'strain_name_snapshot': '平菇 A15',
|
||||
'batch_no': 'B1',
|
||||
'package_spec': '1kg/包',
|
||||
'initial_packages': 100,
|
||||
'idempotency_key': 'k',
|
||||
'request_hash': 'h',
|
||||
'operator_id': 2,
|
||||
'operated_at': datetime(2026, 10, 17, 9, 10),
|
||||
}
|
||||
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
|
||||
summary = {31: {'pallet_code': 'TP-10501', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}}
|
||||
with (
|
||||
patch.object(OperationDao, 'get_filling_list', new=AsyncMock(return_value=page)),
|
||||
patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
|
||||
):
|
||||
result = await FillingService.get_filling_list_services(
|
||||
MagicMock(spec=AsyncSession), FillingPageQueryModel(), admin_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertEqual([31], summaries.await_args.args[1])
|
||||
self.assertIsInstance(result.rows[0], FillingListItemModel)
|
||||
dumped = result.rows[0].model_dump(by_alias=True)
|
||||
self.assertEqual(
|
||||
('TP-10501', '成品一仓', '年糕'), (dumped['palletCode'], dumped['warehouseName'], dumped['operatorName'])
|
||||
)
|
||||
|
||||
async def test_batches_page_uses_warehouse_scope_and_reports_has_next(self) -> None:
|
||||
batch = {**vars(batch_row()), 'package_spec': '1kg/包', 'last_operator_name': '年糕'}
|
||||
query = FillingBatchPageQueryModel(pageNum=1, pageSize=1)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 4, 7])),
|
||||
patch.object(FillingSummaryDao, 'get_batch_page', new=AsyncMock(return_value=([batch], 3))) as get_page,
|
||||
):
|
||||
result = await FillingService.get_filling_batches_services(
|
||||
MagicMock(spec=AsyncSession), query, normal_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertEqual([4, 7], get_page.await_args.args[2])
|
||||
self.assertEqual((3, True), (result.total, result.has_next))
|
||||
row = result.rows[0].model_dump(by_alias=True)
|
||||
self.assertEqual(('B261017-01', 600, '年糕'), (row['batchNo'], row['packageSum'], row['lastOperatorName']))
|
||||
self.assertNotIn('lastFillingId', row)
|
||||
|
||||
async def test_missing_batch_is_hidden_for_normal_user_and_precise_for_admin(self) -> None:
|
||||
query = FillingBatchDetailQueryModel(strainId=3, batchNo='B1')
|
||||
cases = (
|
||||
(normal_user(), PermissionException, '无权访问该生产批次'),
|
||||
(admin_user(), ServiceException, '生产批次不存在'),
|
||||
)
|
||||
for user, error, message in cases:
|
||||
with (
|
||||
self.subTest(admin=user.user.admin),
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(FillingSummaryDao, 'get_batch_detail', new=AsyncMock(return_value=None)) as get_detail,
|
||||
self.assertRaises(error) as raised,
|
||||
):
|
||||
await FillingService.get_filling_batch_detail_services(
|
||||
MagicMock(spec=AsyncSession), query, user, TestBatchScope.formal()
|
||||
)
|
||||
self.assertEqual(message, raised.exception.message)
|
||||
self.assertEqual((3, 'B1'), get_detail.await_args.args[1:3])
|
||||
|
||||
async def test_overview_passes_today_month_and_near_expiry_bounds(self) -> None:
|
||||
overview = {
|
||||
'today_pallet_count': 0,
|
||||
'today_package_sum': 0,
|
||||
'today_batch_count': 0,
|
||||
'month_start': date(2026, 10, 1),
|
||||
'month_pallet_count': 0,
|
||||
'month_batch_count': 0,
|
||||
'month_top_strains': [],
|
||||
'empty_pallet_count': 0,
|
||||
'empty_by_warehouse': [],
|
||||
'near_expiry_days': 14,
|
||||
'near_expiry_pallet_count': 0,
|
||||
'near_expiry_batch_count': 0,
|
||||
'nearest_expiry_batch_no': None,
|
||||
'nearest_expiry_date': None,
|
||||
}
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(FillingSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview,
|
||||
):
|
||||
result = await FillingService.get_filling_overview_services(
|
||||
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertIsInstance(result, FillingOverviewModel)
|
||||
_, warehouse_ids, _, today_start, month_start, near_until, days = get_overview.await_args.args
|
||||
self.assertEqual([7], warehouse_ids)
|
||||
self.assertEqual((0, 0), (today_start.hour, today_start.minute))
|
||||
self.assertEqual((1, today_start.month), (month_start.day, month_start.month))
|
||||
self.assertEqual(today_start.date() + timedelta(days=14), near_until)
|
||||
self.assertEqual(14, days)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -28,6 +28,7 @@ from module_admin.service.log_service import LogQueueService
|
||||
from module_admin.service.login_service import LoginService
|
||||
from module_business.controller import inbound_controller as inbound_controller_module
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inbound_summary_dao import InboundSummaryDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
@@ -45,7 +46,9 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
InboundCreateModel,
|
||||
InboundListItemModel,
|
||||
InboundModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
@@ -741,6 +744,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 3, 7])) as scope,
|
||||
patch.object(OperationDao, 'get_inbound_list', new=get_list),
|
||||
patch.object(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
|
||||
):
|
||||
actual = await InboundService.get_inbound_list_services(
|
||||
self.db, query, self.user, TestBatchScope.formal(), is_page=True
|
||||
@@ -827,6 +831,7 @@ class TestInboundReadService(unittest.IsolatedAsyncioTestCase):
|
||||
) as scope,
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=get_detail),
|
||||
patch.object(AccessService, 'assert_warehouse_access', new=access),
|
||||
patch.object(InboundSummaryDao, 'get_item_identities', new=AsyncMock(return_value={})),
|
||||
):
|
||||
actual = await InboundService.inbound_detail_services(self.db, 11, self.user, TestBatchScope.formal())
|
||||
|
||||
@@ -1900,6 +1905,7 @@ class TestInboundController(unittest.TestCase):
|
||||
schema = app.openapi()
|
||||
expected = {
|
||||
('/business/inbound/list', 'get'),
|
||||
('/business/inbound/summary', 'get'),
|
||||
('/business/inbound/warehouse-options', 'get'),
|
||||
('/business/inbound/scan/{qr_token}', 'get'),
|
||||
('/business/inbound/{inbound_id}', 'get'),
|
||||
@@ -1913,7 +1919,12 @@ class TestInboundController(unittest.TestCase):
|
||||
self.assertIn('warehouseId', {parameter['name'] for parameter in scan_parameters})
|
||||
|
||||
routes = self._routes()
|
||||
self.assertEqual(PageResponseModel[InboundModel], routes[('/business/inbound/list', 'GET')].response_model)
|
||||
self.assertEqual(
|
||||
PageResponseModel[InboundListItemModel], routes[('/business/inbound/list', 'GET')].response_model
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[InboundOverviewModel], routes[('/business/inbound/summary', 'GET')].response_model
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/inbound/warehouse-options', 'GET')].response_model,
|
||||
@@ -1937,12 +1948,14 @@ class TestInboundController(unittest.TestCase):
|
||||
def test_static_routes_are_declared_before_dynamic_detail(self) -> None:
|
||||
paths = [route.path_format for route in inbound_controller_module.inbound_controller.routes]
|
||||
detail_index = paths.index('/business/inbound/{inbound_id}')
|
||||
self.assertLess(paths.index('/business/inbound/summary'), detail_index)
|
||||
self.assertLess(paths.index('/business/inbound/warehouse-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/inbound/scan/{qr_token}'), detail_index)
|
||||
|
||||
def test_each_route_has_exact_permission_and_five_unique_permission_values(self) -> None:
|
||||
expected = {
|
||||
('/business/inbound/list', 'GET'): 'business:inbound:list',
|
||||
('/business/inbound/summary', 'GET'): 'business:inbound:list',
|
||||
('/business/inbound/warehouse-options', 'GET'): 'business:inbound:query',
|
||||
('/business/inbound/scan/{qr_token}', 'GET'): 'business:inbound:query',
|
||||
('/business/inbound/{inbound_id}', 'GET'): 'business:inbound:query',
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
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
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.inbound_summary_dao import InboundSummaryDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
InboundListItemModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
)
|
||||
from module_business.service.inbound_service import InboundService
|
||||
|
||||
|
||||
def compile_sql(statement: Any) -> str:
|
||||
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
|
||||
return ' '.join(str(compiled).split()).replace('%%', '%')
|
||||
|
||||
|
||||
def make_test_scope(batch_id: int) -> TestBatchScope:
|
||||
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
|
||||
|
||||
|
||||
def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.all.return_value = rows
|
||||
return result
|
||||
|
||||
|
||||
def result_one(row: tuple[Any, ...]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.one.return_value = row
|
||||
return result
|
||||
|
||||
|
||||
def result_scalar(value: Any) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.scalar_one.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
def admin_user() -> CurrentUserModel:
|
||||
return CurrentUserModel.model_construct(
|
||||
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
|
||||
)
|
||||
|
||||
|
||||
class InboundListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_empty_ids_skip_database(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
self.assertEqual({}, await InboundSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
|
||||
db.execute.assert_not_called()
|
||||
|
||||
async def test_aggregates_names_and_pallet_counts_within_batch_scope(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_rows([(12, '成品一仓', '李娜', '王强'), (13, None, None, None)]),
|
||||
result_rows([(12, 18)]),
|
||||
]
|
||||
)
|
||||
|
||||
summaries = await InboundSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7))
|
||||
|
||||
self.assertEqual(
|
||||
{'warehouse_name': '成品一仓', 'creator_name': '李娜', 'confirmer_name': '王强', 'pallet_count': 18},
|
||||
summaries[12],
|
||||
)
|
||||
# 没有明细的单据托盘数为 0,名称缺失保持为空
|
||||
self.assertEqual(
|
||||
{'warehouse_name': None, 'creator_name': None, 'confirmer_name': None, 'pallet_count': 0},
|
||||
summaries[13],
|
||||
)
|
||||
name_sql = compile_sql(db.execute.await_args_list[0].args[0])
|
||||
count_sql = compile_sql(db.execute.await_args_list[1].args[0])
|
||||
for fragment in (
|
||||
'biz_inbound_order.inbound_id IN (12, 13)',
|
||||
'biz_inbound_order.test_batch_id = 7',
|
||||
'biz_warehouse.test_batch_id = 7',
|
||||
"creator.del_flag = '0'",
|
||||
'confirmer.user_id = biz_inbound_order.confirmed_by',
|
||||
):
|
||||
self.assertIn(fragment, name_sql)
|
||||
self.assertIn('biz_inbound_item.inbound_id IN (12, 13)', count_sql)
|
||||
self.assertIn('biz_inbound_item.test_batch_id = 7', count_sql)
|
||||
|
||||
async def test_item_identities_join_pallet_and_inventory_in_same_scope(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10501', 'EMPTY_IN_WAREHOUSE'), (32, None, None)]))
|
||||
|
||||
identities = await InboundSummaryDao.get_item_identities(db, 12, make_test_scope(7))
|
||||
|
||||
self.assertEqual({'pallet_code': 'TP-10501', 'flow_state': 'EMPTY_IN_WAREHOUSE'}, identities[31])
|
||||
self.assertEqual({'pallet_code': None, 'flow_state': None}, identities[32])
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
for fragment in (
|
||||
'biz_inbound_item.inbound_id = 12',
|
||||
'biz_inbound_item.test_batch_id = 7',
|
||||
'biz_pallet.test_batch_id = 7',
|
||||
'biz_pallet_inventory.test_batch_id = 7',
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
|
||||
class InboundOverviewDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
def _db(self) -> MagicMock:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_one((3, 26, datetime(2026, 10, 14, 9, 30))),
|
||||
result_one((28, 1240)),
|
||||
result_scalar(54),
|
||||
result_rows([(2, '成品二仓', 126), (1, '成品一仓', 168), (3, None, 78)]),
|
||||
]
|
||||
)
|
||||
return db
|
||||
|
||||
async def test_overview_counts_and_sorts_empty_pallets_by_warehouse(self) -> None:
|
||||
db = self._db()
|
||||
|
||||
overview = await InboundSummaryDao.get_overview(db, [1, 2, 3], make_test_scope(7), datetime(2026, 10, 1))
|
||||
|
||||
self.assertEqual(3, overview['draft_count'])
|
||||
self.assertEqual(26, overview['draft_pallet_count'])
|
||||
self.assertEqual(datetime(2026, 10, 14, 9, 30), overview['oldest_draft_at'])
|
||||
self.assertEqual(28, overview['month_confirmed_count'])
|
||||
self.assertEqual(1240, overview['month_confirmed_pallet_count'])
|
||||
self.assertEqual(54, overview['unstocked_pallet_count'])
|
||||
self.assertEqual(372, overview['empty_pallet_count'])
|
||||
self.assertEqual([1, 2, 3], [row['warehouse_id'] for row in overview['empty_by_warehouse']])
|
||||
InboundOverviewModel.model_validate(overview)
|
||||
|
||||
draft_sql, month_sql, unstocked_sql, empty_sql = (
|
||||
compile_sql(call.args[0]) for call in db.execute.await_args_list
|
||||
)
|
||||
for sql in (draft_sql, month_sql):
|
||||
self.assertIn('biz_inbound_order.test_batch_id = 7', sql)
|
||||
self.assertIn('biz_inbound_order.warehouse_id IN (1, 2, 3)', sql)
|
||||
self.assertIn('biz_inbound_item.test_batch_id = 7', sql)
|
||||
self.assertIn("biz_inbound_order.status = 'DRAFT'", draft_sql)
|
||||
self.assertIn("biz_inbound_order.status = 'CONFIRMED'", month_sql)
|
||||
self.assertIn("biz_inbound_order.confirmed_at >= '2026-10-01 00:00:00'", month_sql)
|
||||
# 未入库托盘没有仓库归属,只受测试批次范围与资产状态约束
|
||||
self.assertIn("biz_pallet_inventory.flow_state = 'UNSTOCKED'", unstocked_sql)
|
||||
self.assertIn("biz_pallet.asset_status = '0'", unstocked_sql)
|
||||
self.assertIn('biz_pallet_inventory.test_batch_id = 7', unstocked_sql)
|
||||
self.assertNotIn('warehouse_id IN', unstocked_sql)
|
||||
self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql)
|
||||
self.assertIn('biz_pallet_inventory.warehouse_id IN (1, 2, 3)', empty_sql)
|
||||
self.assertIn('biz_warehouse.test_batch_id = 7', empty_sql)
|
||||
|
||||
async def test_user_without_warehouses_sees_no_orders_or_empty_pallets(self) -> None:
|
||||
db = self._db()
|
||||
|
||||
await InboundSummaryDao.get_overview(db, [], TestBatchScope.formal(), datetime(2026, 10, 1))
|
||||
|
||||
draft_sql, month_sql, _unstocked_sql, empty_sql = (
|
||||
compile_sql(call.args[0]) for call in db.execute.await_args_list
|
||||
)
|
||||
for sql in (draft_sql, month_sql, empty_sql):
|
||||
self.assertIn('false', sql.lower())
|
||||
|
||||
async def test_admin_overview_has_no_warehouse_filter(self) -> None:
|
||||
db = self._db()
|
||||
|
||||
await InboundSummaryDao.get_overview(db, None, TestBatchScope.formal(), datetime(2026, 10, 1))
|
||||
|
||||
for call in db.execute.await_args_list:
|
||||
self.assertNotIn('warehouse_id IN', compile_sql(call.args[0]))
|
||||
|
||||
|
||||
class InboundServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_list_rows_carry_summary_fields_in_camel_case(self) -> None:
|
||||
record = {'inbound_id': 12, 'inbound_no': 'IN-1', 'warehouse_id': 7, 'status': 'DRAFT'}
|
||||
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
|
||||
summary = {
|
||||
12: {'warehouse_name': '成品一仓', 'creator_name': '李娜', 'confirmer_name': None, 'pallet_count': 18}
|
||||
}
|
||||
with (
|
||||
patch.object(OperationDao, 'get_inbound_list', new=AsyncMock(return_value=page)),
|
||||
patch.object(InboundSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
|
||||
):
|
||||
result = await InboundService.get_inbound_list_services(
|
||||
MagicMock(spec=AsyncSession), InboundPageQueryModel(), admin_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertEqual([12], summaries.await_args.args[1])
|
||||
row = result.rows[0]
|
||||
self.assertIsInstance(row, InboundListItemModel)
|
||||
dumped = row.model_dump(by_alias=True)
|
||||
self.assertEqual('成品一仓', dumped['warehouseName'])
|
||||
self.assertEqual('李娜', dumped['creatorName'])
|
||||
self.assertEqual(18, dumped['palletCount'])
|
||||
|
||||
async def test_detail_items_carry_pallet_code_and_current_state(self) -> None:
|
||||
inbound = {
|
||||
'inbound_id': 12,
|
||||
'inbound_no': 'IN-1',
|
||||
'warehouse_id': 7,
|
||||
'status': 'CONFIRMED',
|
||||
'items': [{'inbound_item_id': 31, 'inbound_id': 12, 'pallet_id': 501}],
|
||||
}
|
||||
identities = {31: {'pallet_code': 'TP-10501', 'flow_state': 'FILLED_IN_WAREHOUSE'}}
|
||||
with (
|
||||
patch.object(OperationDao, 'get_inbound_by_id', new=AsyncMock(return_value=inbound)),
|
||||
patch.object(InboundSummaryDao, 'get_item_identities', new=AsyncMock(return_value=identities)),
|
||||
):
|
||||
result = await InboundService.inbound_detail_services(
|
||||
MagicMock(spec=AsyncSession), 12, admin_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
item = result.model_dump(by_alias=True)['items'][0]
|
||||
self.assertEqual('TP-10501', item['palletCode'])
|
||||
self.assertEqual('FILLED_IN_WAREHOUSE', item['flowState'])
|
||||
|
||||
async def test_overview_uses_warehouse_scope_and_month_start(self) -> None:
|
||||
user = CurrentUserModel.model_construct(
|
||||
permissions=['business:inbound:list'],
|
||||
roles=['common'],
|
||||
user=SimpleNamespace(user_id=5, user_name='ln', admin=False),
|
||||
)
|
||||
overview = {
|
||||
'draft_count': 0,
|
||||
'draft_pallet_count': 0,
|
||||
'oldest_draft_at': None,
|
||||
'month_start': datetime(2026, 10, 1).date(),
|
||||
'month_confirmed_count': 0,
|
||||
'month_confirmed_pallet_count': 0,
|
||||
'unstocked_pallet_count': 0,
|
||||
'empty_pallet_count': 0,
|
||||
'empty_by_warehouse': [],
|
||||
}
|
||||
with (
|
||||
patch.object(InboundService, '_warehouse_scope', new=AsyncMock(return_value=[7])),
|
||||
patch.object(InboundSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview,
|
||||
):
|
||||
result = await InboundService.get_inbound_overview_services(
|
||||
MagicMock(spec=AsyncSession), user, TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertIsInstance(result, InboundOverviewModel)
|
||||
self.assertEqual([7], get_overview.await_args.args[1])
|
||||
month_start = get_overview.await_args.args[3]
|
||||
self.assertEqual((1, 0, 0), (month_start.day, month_start.hour, month_start.minute))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -33,6 +33,14 @@ from module_business.entity.do.master_do import (
|
||||
BizStrain,
|
||||
BizWarehouse,
|
||||
)
|
||||
from module_business.entity.vo.master_summary_vo import (
|
||||
CustomerDetailModel,
|
||||
CustomerListItemModel,
|
||||
LineListItemModel,
|
||||
SiteListItemModel,
|
||||
StrainListItemModel,
|
||||
WarehouseListItemModel,
|
||||
)
|
||||
from module_business.entity.vo.master_vo import (
|
||||
CustomerModel,
|
||||
CustomerPageQueryModel,
|
||||
@@ -1060,17 +1068,24 @@ class TestMasterControllers(unittest.TestCase):
|
||||
def test_route_response_model_contracts(self) -> None:
|
||||
model_by_resource = {
|
||||
'strain': StrainModel,
|
||||
'customer': CustomerModel,
|
||||
'customer': CustomerDetailModel,
|
||||
'site': SiteModel,
|
||||
'line': LineModel,
|
||||
'warehouse': WarehouseModel,
|
||||
}
|
||||
list_model_by_resource = {
|
||||
'strain': StrainListItemModel,
|
||||
'customer': CustomerListItemModel,
|
||||
'site': SiteListItemModel,
|
||||
'line': LineListItemModel,
|
||||
'warehouse': WarehouseListItemModel,
|
||||
}
|
||||
for resource, router in self.ROUTERS.items():
|
||||
for route in router.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
if route.path.endswith('/list'):
|
||||
self.assertEqual(PageResponseModel[model_by_resource[resource]], route.response_model)
|
||||
self.assertEqual(PageResponseModel[list_model_by_resource[resource]], route.response_model)
|
||||
elif route.methods == {'GET'}:
|
||||
self.assertEqual(DataResponseModel[model_by_resource[resource]], route.response_model)
|
||||
else:
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import io
|
||||
import unittest
|
||||
from datetime import date, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from openpyxl import Workbook
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from exceptions.exception import ServiceException
|
||||
from module_business.controller import line_controller, site_controller, tunnel_controller
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.master_summary_dao import MasterSummaryDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.master_summary_vo import (
|
||||
CustomerDetailModel,
|
||||
MasterImportPreviewModel,
|
||||
PalletDetailModel,
|
||||
PalletListItemModel,
|
||||
WarehouseListItemModel,
|
||||
)
|
||||
from module_business.entity.vo.master_vo import (
|
||||
CustomerModel,
|
||||
PalletModel,
|
||||
PalletPageQueryModel,
|
||||
WarehouseModel,
|
||||
)
|
||||
from module_business.service.master_excel_service import MasterExcelService
|
||||
from module_business.service.master_summary_service import MasterSummaryService
|
||||
from tests.module_business.test_batch_fixtures import make_admin, make_test_scope
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
|
||||
def compile_sql(statement: Any) -> str:
|
||||
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
|
||||
return ' '.join(str(compiled).split()).replace('%%', '%')
|
||||
|
||||
|
||||
def result_rows(rows: list[Any]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.all.return_value = rows
|
||||
return result
|
||||
|
||||
|
||||
class _Upload:
|
||||
def __init__(self, content: bytes) -> None:
|
||||
self.content = content
|
||||
|
||||
async def read(self) -> bytes:
|
||||
return self.content
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def workbook_bytes(headers: list[str], rows: list[list[object]]) -> bytes:
|
||||
workbook = Workbook()
|
||||
worksheet = workbook.active
|
||||
worksheet.append(headers)
|
||||
for row in rows:
|
||||
worksheet.append(row)
|
||||
buffer = io.BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
SUMMARY_GETTERS = (
|
||||
'get_strain_summaries',
|
||||
'get_customer_summaries',
|
||||
'get_site_summaries',
|
||||
'get_line_summaries',
|
||||
'get_tunnel_summaries',
|
||||
'get_warehouse_summaries',
|
||||
'get_pallet_summaries',
|
||||
)
|
||||
|
||||
|
||||
class MasterSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_empty_ids_skip_database(self) -> None:
|
||||
for getter in SUMMARY_GETTERS:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
with self.subTest(getter=getter):
|
||||
self.assertEqual({}, await getattr(MasterSummaryDao, getter)(db, [], TestBatchScope.formal()))
|
||||
db.execute.assert_not_called()
|
||||
|
||||
async def test_every_summary_query_is_bound_to_current_batch(self) -> None:
|
||||
for getter in SUMMARY_GETTERS:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([]))
|
||||
with self.subTest(getter=getter):
|
||||
summaries = await getattr(MasterSummaryDao, getter)(db, [3, 3, 1], make_test_scope(7))
|
||||
self.assertEqual([1, 3], sorted(summaries))
|
||||
self.assertGreater(db.execute.await_count, 0)
|
||||
for call in db.execute.await_args_list:
|
||||
sql = compile_sql(call.args[0])
|
||||
self.assertIn('test_batch_id = 7', sql)
|
||||
self.assertNotIn('test_batch_id IS NULL', sql)
|
||||
|
||||
async def test_customer_site_tree_queries_are_bound_to_current_batch(self) -> None:
|
||||
site = SimpleNamespace(site_id=5, site_code='S-01', site_name='南厂', address=None, status='0')
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(side_effect=[result_rows([site])] + [result_rows([])] * 4)
|
||||
tree = await MasterSummaryDao.get_customer_site_tree(db, 2, make_test_scope(7))
|
||||
self.assertEqual(5, db.execute.await_count)
|
||||
self.assertEqual([{'site_id': 5, 'site_code': 'S-01', 'site_name': '南厂', 'address': None, 'status': '0',
|
||||
'lines': [], 'tunnels': [], 'users': []}], tree)
|
||||
for call in db.execute.await_args_list:
|
||||
self.assertIn('test_batch_id = 7', compile_sql(call.args[0]))
|
||||
|
||||
async def test_customer_without_sites_stops_after_first_query(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([]))
|
||||
self.assertEqual([], await MasterSummaryDao.get_customer_site_tree(db, 2, TestBatchScope.formal()))
|
||||
self.assertEqual(1, db.execute.await_count)
|
||||
|
||||
async def test_pallet_summary_merges_state_cycles_and_return_target(self) -> None:
|
||||
shipped_at = datetime(2026, 10, 1, 9, 0)
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_rows(
|
||||
[
|
||||
(11, 'RETURNING', 0, None, '金禾菌业', '南厂', None, None, None, None, None),
|
||||
(12, 'FILLED_IN_WAREHOUSE', 100, '成品一仓', None, None, None, None, '香菇', 'B-1',
|
||||
date(2026, 10, 30)),
|
||||
]
|
||||
),
|
||||
result_rows([(11, 3, shipped_at, shipped_at), (12, 0, None, shipped_at)]),
|
||||
result_rows([(11, '周转仓')]),
|
||||
]
|
||||
)
|
||||
summaries = await MasterSummaryDao.get_pallet_summaries(db, [12, 11], TestBatchScope.formal())
|
||||
self.assertEqual('RETURNING', summaries[11]['flow_state'])
|
||||
self.assertEqual(3, summaries[11]['cycle_count'])
|
||||
self.assertEqual('周转仓', summaries[11]['return_warehouse_name'])
|
||||
self.assertEqual('成品一仓', summaries[12]['warehouse_name'])
|
||||
self.assertEqual('香菇', summaries[12]['strain_name'])
|
||||
self.assertNotIn('return_warehouse_name', summaries[12])
|
||||
sql = compile_sql(db.execute.await_args_list[2].args[0])
|
||||
self.assertIn("biz_return_order.status = 'REQUESTED'", sql)
|
||||
|
||||
|
||||
class ParentOptionTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_customer_options_only_list_active_customers_in_batch(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([(2, 'C-01', '金禾菌业')]))
|
||||
options = await MasterSummaryDao.list_customer_options(db, make_test_scope(7))
|
||||
self.assertEqual([{'id': 2, 'code': 'C-01', 'name': '金禾菌业'}], options)
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
self.assertIn("biz_customer.status = '0'", sql)
|
||||
self.assertIn('biz_customer.test_batch_id = 7', sql)
|
||||
self.assertIn('ORDER BY biz_customer.customer_code', sql)
|
||||
|
||||
async def test_site_options_require_active_site_and_active_customer(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([(5, 'S-01', '南厂', '金禾菌业')]))
|
||||
options = await MasterSummaryDao.list_site_options(db, TestBatchScope.formal())
|
||||
self.assertEqual([{'id': 5, 'code': 'S-01', 'name': '南厂', 'parent_name': '金禾菌业'}], options)
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
self.assertIn("biz_customer_site.status = '0'", sql)
|
||||
self.assertIn("biz_customer.status = '0'", sql)
|
||||
self.assertIn('biz_customer_site.test_batch_id IS NULL', sql)
|
||||
self.assertIn('biz_customer.test_batch_id IS NULL', sql)
|
||||
|
||||
async def test_service_wraps_options_in_camel_case_models(self) -> None:
|
||||
rows = [{'id': 5, 'code': 'S-01', 'name': '南厂', 'parent_name': '金禾菌业'}]
|
||||
with patch.object(MasterSummaryDao, 'list_site_options', new=AsyncMock(return_value=rows)):
|
||||
options = await MasterSummaryService.site_options(MagicMock(spec=AsyncSession), TestBatchScope.formal())
|
||||
self.assertEqual({'id': 5, 'code': 'S-01', 'name': '南厂', 'parentName': '金禾菌业'}, options[0].model_dump(by_alias=True))
|
||||
|
||||
def test_option_routes_are_registered_before_detail_routes(self) -> None:
|
||||
for module, path, permission in (
|
||||
(site_controller, '/master/site/customer-options', 'master:site:list'),
|
||||
(line_controller, '/master/line/site-options', 'master:line:list'),
|
||||
(tunnel_controller, '/master/tunnel/site-options', 'master:tunnel:list'),
|
||||
):
|
||||
resource = path.split('/')[2]
|
||||
option_router = getattr(module, f'{resource}_option_controller')
|
||||
main_router = getattr(module, f'{resource}_controller')
|
||||
with self.subTest(path=path):
|
||||
self.assertLess(option_router.order_num, main_router.order_num)
|
||||
(route,) = [route for route in option_router.routes if getattr(route, 'path', '') == path]
|
||||
self.assertEqual({'GET'}, route.methods)
|
||||
self.assertEqual(
|
||||
[permission],
|
||||
[dep.dependency.perm for dep in route.dependencies if hasattr(dep.dependency, 'perm')],
|
||||
)
|
||||
|
||||
|
||||
class PalletListFilterTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def _capture(self, query: PalletPageQueryModel, scope: TestBatchScope) -> str:
|
||||
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
|
||||
await MasterDao.list_pallet(MagicMock(spec=AsyncSession), query, scope, is_page=True)
|
||||
return compile_sql(paginate.await_args.args[1])
|
||||
|
||||
async def test_flow_group_filters_by_inventory_state_in_same_batch(self) -> None:
|
||||
sql = await self._capture(PalletPageQueryModel(flowGroup='site'), make_test_scope(7))
|
||||
self.assertIn('biz_pallet.pallet_id IN (SELECT biz_pallet_inventory.pallet_id', sql)
|
||||
self.assertIn("'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY'", sql)
|
||||
self.assertIn('biz_pallet_inventory.test_batch_id = 7', sql)
|
||||
self.assertNotIn('NOT IN', sql)
|
||||
|
||||
async def test_unstocked_group_includes_pallets_without_inventory(self) -> None:
|
||||
sql = await self._capture(PalletPageQueryModel(flowGroup='unstocked'), TestBatchScope.formal())
|
||||
self.assertIn("biz_pallet_inventory.flow_state IN ('UNSTOCKED')", sql)
|
||||
self.assertIn('biz_pallet.pallet_id NOT IN (SELECT biz_pallet_inventory.pallet_id', sql)
|
||||
|
||||
async def test_code_search_also_matches_scanned_qr_token(self) -> None:
|
||||
sql = await self._capture(PalletPageQueryModel(palletCode='tok-1'), TestBatchScope.formal())
|
||||
self.assertIn("biz_pallet.pallet_code LIKE '%tok-1%' OR biz_pallet.qr_token = 'tok-1'", sql)
|
||||
|
||||
def test_flow_group_rejects_unknown_value(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
PalletPageQueryModel(flowGroup='lost')
|
||||
|
||||
|
||||
class MasterSummaryServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.db = MagicMock(spec=AsyncSession)
|
||||
self.scope = TestBatchScope.formal()
|
||||
|
||||
async def test_enrich_page_merges_summaries_into_list_rows(self) -> None:
|
||||
page = PageModel[Any](
|
||||
rows=[
|
||||
WarehouseModel(warehouseId=1, warehouseCode='W-01', warehouseName='成品一仓'),
|
||||
WarehouseModel(warehouseId=2, warehouseCode='W-02', warehouseName='周转仓'),
|
||||
],
|
||||
pageNum=1,
|
||||
pageSize=10,
|
||||
total=2,
|
||||
hasNext=False,
|
||||
)
|
||||
loader = AsyncMock(return_value={1: {'filled_pallet_count': 4, 'users': [{'user_id': 3, 'user_name': 'li'}]}})
|
||||
with patch.object(MasterSummaryDao, 'get_warehouse_summaries', new=loader), patch.dict(
|
||||
'module_business.service.master_summary_service._ENRICHERS',
|
||||
{'warehouse': ('warehouse_id', loader, WarehouseListItemModel)},
|
||||
):
|
||||
result = await MasterSummaryService.enrich_page(self.db, 'warehouse', page, self.scope)
|
||||
|
||||
loader.assert_awaited_once_with(self.db, [1, 2], self.scope)
|
||||
self.assertEqual(2, result.total)
|
||||
self.assertIsInstance(result.rows[0], WarehouseListItemModel)
|
||||
self.assertEqual(4, result.rows[0].filled_pallet_count)
|
||||
self.assertEqual('li', result.rows[0].users[0].user_name)
|
||||
self.assertEqual(0, result.rows[1].filled_pallet_count)
|
||||
dumped = result.model_dump(by_alias=True)['rows'][0]
|
||||
self.assertEqual(4, dumped['filledPalletCount'])
|
||||
self.assertEqual('成品一仓', dumped['warehouseName'])
|
||||
|
||||
async def test_customer_detail_carries_site_tree(self) -> None:
|
||||
tree = [{'site_id': 5, 'site_code': 'S-01', 'site_name': '南厂', 'status': '0', 'unused_pallet_count': 2}]
|
||||
with patch.object(MasterSummaryDao, 'get_customer_site_tree', new=AsyncMock(return_value=tree)) as loader:
|
||||
detail = await MasterSummaryService.customer_detail(
|
||||
self.db,
|
||||
CustomerModel(customerId=2, customerCode='C-01', customerName='金禾菌业'),
|
||||
self.scope,
|
||||
)
|
||||
loader.assert_awaited_once_with(self.db, 2, self.scope)
|
||||
self.assertIsInstance(detail, CustomerDetailModel)
|
||||
self.assertEqual('C-01', detail.customer_code)
|
||||
self.assertEqual(2, detail.sites[0].unused_pallet_count)
|
||||
|
||||
async def test_pallet_detail_adds_state_and_recent_events(self) -> None:
|
||||
pallet = PalletModel(palletId=9, palletCode='P-9', qrToken='tok', capacityPackages=100, assetStatus='0')
|
||||
event = {
|
||||
'event_id': 1,
|
||||
'event_type': 'USAGE_RECORDED',
|
||||
'occurred_at': datetime(2026, 10, 16, 9, 12),
|
||||
'after_state': 'AT_SITE_PARTIAL',
|
||||
'before_remaining_packages': 50,
|
||||
'after_remaining_packages': 38,
|
||||
}
|
||||
with patch.object(
|
||||
MasterSummaryDao,
|
||||
'get_pallet_summaries',
|
||||
new=AsyncMock(return_value={9: {'flow_state': 'AT_SITE_PARTIAL', 'remaining_packages': 38}}),
|
||||
), patch.object(MasterSummaryDao, 'get_pallet_recent_events', new=AsyncMock(return_value=[event])):
|
||||
detail = await MasterSummaryService.pallet_detail(self.db, pallet, self.scope)
|
||||
self.assertIsInstance(detail, PalletDetailModel)
|
||||
self.assertIsInstance(detail, PalletListItemModel)
|
||||
self.assertEqual('tok', detail.qr_token)
|
||||
self.assertEqual(38, detail.remaining_packages)
|
||||
self.assertEqual('USAGE_RECORDED', detail.recent_events[0].event_type)
|
||||
|
||||
|
||||
class MasterImportPreviewTest(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.db = AsyncMock(spec=AsyncSession)
|
||||
self.db.execute = AsyncMock()
|
||||
self.headers = ['菌种编码', '菌种名称', '状态', '备注']
|
||||
|
||||
async def _preview(self, rows: list[list[object]], *, update_support: bool = False) -> MasterImportPreviewModel:
|
||||
return await MasterExcelService.preview_import(
|
||||
self.db,
|
||||
'strain',
|
||||
_Upload(workbook_bytes(self.headers, rows)), # type: ignore[arg-type]
|
||||
update_support,
|
||||
make_admin(),
|
||||
TestBatchScope.formal(),
|
||||
)
|
||||
|
||||
async def test_preview_reports_each_row_and_never_commits(self) -> None:
|
||||
existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None)
|
||||
|
||||
async def find(_db: object, code: str) -> object:
|
||||
return existing if code == 'S001' else None
|
||||
|
||||
with patch.object(MasterDao, 'find_strain_by_code', new=find), patch.object(
|
||||
MasterDao, 'get_strain', new=AsyncMock(return_value=existing)
|
||||
), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_strain, patch.object(
|
||||
MasterDao, 'update_strain', new=AsyncMock()
|
||||
):
|
||||
preview = await self._preview(
|
||||
[['S001', '香菇', '正常', ''], ['S002', '平菇', '正常', ''], ['S003', '金针菇', '启用', '']]
|
||||
)
|
||||
|
||||
self.assertEqual(['菌种编码', '菌种名称', '状态', '备注'], preview.headers)
|
||||
self.assertEqual(3, preview.total_rows)
|
||||
self.assertEqual((1, 0, 1, 1), (preview.add_count, preview.update_count, preview.skip_count, preview.error_count))
|
||||
self.assertEqual(['skip', 'add', 'error'], [row.outcome for row in preview.rows])
|
||||
self.assertEqual([2, 3, 4], [row.excel_row for row in preview.rows])
|
||||
self.assertIn('状态只能填写正常或停用', preview.rows[2].message)
|
||||
self.assertEqual(['S002', '平菇', '正常', ''], preview.rows[1].values)
|
||||
add_strain.assert_awaited_once()
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_preview_with_update_support_marks_existing_rows_as_update(self) -> None:
|
||||
existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None)
|
||||
with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=existing)), patch.object(
|
||||
MasterDao, 'get_strain', new=AsyncMock(return_value=existing)
|
||||
), patch.object(MasterDao, 'update_strain', new=AsyncMock()):
|
||||
preview = await self._preview([['S001', '新香菇', '停用', '']], update_support=True)
|
||||
self.assertEqual('update', preview.rows[0].outcome)
|
||||
self.assertTrue(preview.update_support)
|
||||
self.db.commit.assert_not_awaited()
|
||||
self.db.rollback.assert_awaited_once()
|
||||
|
||||
async def test_whole_file_errors_are_raised_before_any_row_is_checked(self) -> None:
|
||||
with self.assertRaises(ServiceException) as raised:
|
||||
await self._preview([['S001', '香菇', '正常', ''], ['S001', '香菇', '正常', '']])
|
||||
self.assertIn('重复编码', raised.exception.message)
|
||||
self.db.execute.assert_not_awaited()
|
||||
self.db.commit.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -34,6 +34,7 @@ from module_business.entity.do.master_do import (
|
||||
BizWarehouse,
|
||||
)
|
||||
from module_business.entity.do.operation_do import BizPalletInventory
|
||||
from module_business.entity.vo.master_summary_vo import PalletDetailModel, PalletListItemModel
|
||||
from module_business.entity.vo.master_vo import (
|
||||
BusinessUserOptionModel,
|
||||
DeletePalletModel,
|
||||
@@ -1285,11 +1286,11 @@ class TestPalletAndAssignmentControllers(unittest.IsolatedAsyncioTestCase):
|
||||
def test_route_response_models_are_exact(self) -> None:
|
||||
routes = self._routes()
|
||||
self.assertEqual(
|
||||
PageResponseModel[PalletModel],
|
||||
PageResponseModel[PalletListItemModel],
|
||||
routes[('/master/pallet/list', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[PalletModel],
|
||||
DataResponseModel[PalletDetailModel],
|
||||
routes[('/master/pallet/{pallet_id}', 'GET')].response_model,
|
||||
)
|
||||
for path, method in (
|
||||
|
||||
@@ -38,10 +38,13 @@ from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
PalletScanResponseModel,
|
||||
ReturnConfirmModel,
|
||||
ReturnListItemModel,
|
||||
ReturnModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnRequestModel,
|
||||
ReturnScanQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.test_batch_access_service import TestBatchAccessService
|
||||
@@ -440,6 +443,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
schema = app.openapi()
|
||||
expected = {
|
||||
('/business/return/list', 'get'),
|
||||
('/business/return/summary', 'get'),
|
||||
('/business/return/site-options', 'get'),
|
||||
('/business/return/warehouse-options', 'get'),
|
||||
('/business/return/scan/{qr_token}', 'get'),
|
||||
('/business/return/{return_id}', 'get'),
|
||||
@@ -450,7 +455,17 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
parameters = schema['paths']['/business/return/scan/{qr_token}']['get']['parameters']
|
||||
self.assertEqual({'qr_token', 'mode', 'returnId'}, {parameter['name'] for parameter in parameters})
|
||||
routes = self._routes()
|
||||
self.assertEqual(PageResponseModel[ReturnModel], routes[('/business/return/list', 'GET')].response_model)
|
||||
self.assertEqual(
|
||||
PageResponseModel[ReturnListItemModel], routes[('/business/return/list', 'GET')].response_model
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[ReturnOverviewModel],
|
||||
routes[('/business/return/summary', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[ReturnSiteOptionModel]],
|
||||
routes[('/business/return/site-options', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/return/warehouse-options', 'GET')].response_model,
|
||||
@@ -472,6 +487,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
detail_index = paths.index('/business/return/{return_id}')
|
||||
for static_path in (
|
||||
'/business/return/list',
|
||||
'/business/return/summary',
|
||||
'/business/return/site-options',
|
||||
'/business/return/warehouse-options',
|
||||
'/business/return/scan/{qr_token}',
|
||||
'/business/return/request',
|
||||
@@ -480,6 +497,8 @@ class ReturnControllerContractRedTest(unittest.TestCase):
|
||||
|
||||
expected = {
|
||||
('/business/return/list', 'GET'): 'business:return:list',
|
||||
('/business/return/summary', 'GET'): 'business:return:list',
|
||||
('/business/return/site-options', 'GET'): 'business:return:list',
|
||||
('/business/return/warehouse-options', 'GET'): 'business:return:query',
|
||||
('/business/return/scan/{qr_token}', 'GET'): 'business:return:query',
|
||||
('/business/return/{return_id}', 'GET'): 'business:return:query',
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
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
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.return_summary_dao import ReturnSummaryDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.operation_do import BizReturnItem, BizReturnOrder
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
ReturnListItemModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
)
|
||||
from module_business.service.return_service import ReturnService
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
|
||||
def compile_sql(statement: Any) -> str:
|
||||
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
|
||||
return ' '.join(str(compiled).split()).replace('%%', '%')
|
||||
|
||||
|
||||
def make_test_scope(batch_id: int) -> TestBatchScope:
|
||||
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
|
||||
|
||||
|
||||
def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.all.return_value = rows
|
||||
return result
|
||||
|
||||
|
||||
def result_one(row: tuple[Any, ...]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.one.return_value = row
|
||||
return result
|
||||
|
||||
|
||||
def admin_user() -> CurrentUserModel:
|
||||
return CurrentUserModel.model_construct(
|
||||
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
|
||||
)
|
||||
|
||||
|
||||
def normal_user() -> CurrentUserModel:
|
||||
return CurrentUserModel.model_construct(
|
||||
permissions=['business:return:list'],
|
||||
roles=['common'],
|
||||
user=SimpleNamespace(user_id=5, user_name='ln', admin=False),
|
||||
)
|
||||
|
||||
|
||||
class ReturnListFilterTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def _capture(self, query: ReturnPageQueryModel, scope: TestBatchScope) -> str:
|
||||
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
|
||||
await OperationDao.get_return_list(
|
||||
MagicMock(spec=AsyncSession),
|
||||
query,
|
||||
site_ids=[9],
|
||||
warehouse_ids=[7],
|
||||
scope=scope,
|
||||
)
|
||||
return compile_sql(paginate.await_args.args[1])
|
||||
|
||||
async def test_keyword_matches_number_names_and_pallet_code_within_same_batch_scope(self) -> None:
|
||||
sql = await self._capture(ReturnPageQueryModel(keyword='TP-104'), make_test_scope(7))
|
||||
for fragment in (
|
||||
"biz_return_order.return_no LIKE '%TP-104%'",
|
||||
"source_site.site_name LIKE '%TP-104%'",
|
||||
"source_customer.customer_name LIKE '%TP-104%'",
|
||||
"target_warehouse.warehouse_name LIKE '%TP-104%'",
|
||||
"biz_pallet.pallet_code LIKE '%TP-104%'",
|
||||
'EXISTS (SELECT biz_return_item.return_item_id',
|
||||
'biz_return_item.return_id = biz_return_order.return_id',
|
||||
'biz_return_item.test_batch_id = 7',
|
||||
'biz_pallet.test_batch_id = 7',
|
||||
'source_site.test_batch_id = 7',
|
||||
'source_customer.test_batch_id = 7',
|
||||
'target_warehouse.test_batch_id = 7',
|
||||
# 关键字只在原可见范围内匹配
|
||||
'biz_return_order.source_site_id IN (9)',
|
||||
'biz_return_order.target_warehouse_id IN (7)',
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
async def test_without_keyword_list_does_not_join_master_tables(self) -> None:
|
||||
sql = await self._capture(ReturnPageQueryModel(), TestBatchScope.formal())
|
||||
self.assertNotIn('JOIN', sql)
|
||||
self.assertNotIn('EXISTS', sql)
|
||||
|
||||
def test_keyword_rejects_blank_value(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
ReturnPageQueryModel(keyword=' ')
|
||||
|
||||
|
||||
class ReturnListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_empty_ids_skip_database(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
self.assertEqual({}, await ReturnSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
|
||||
db.execute.assert_not_called()
|
||||
|
||||
async def test_aggregates_names_and_snapshot_totals_within_batch_scope(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_rows([(12, '南厂', '金禾菌业', '成品一仓', '赵强', None), (13, None, None, None, None, None)]),
|
||||
result_rows([(12, 12, 520, 7)]),
|
||||
]
|
||||
)
|
||||
|
||||
summaries = await ReturnSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7))
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
'source_site_name': '南厂',
|
||||
'customer_name': '金禾菌业',
|
||||
'target_warehouse_name': '成品一仓',
|
||||
'requester_name': '赵强',
|
||||
'confirmer_name': None,
|
||||
'pallet_count': 12,
|
||||
'package_sum': 520,
|
||||
'filled_count': 7,
|
||||
},
|
||||
summaries[12],
|
||||
)
|
||||
# 没有明细的单据汇总为 0,名称缺失保持为空
|
||||
self.assertEqual(0, summaries[13]['pallet_count'])
|
||||
self.assertEqual(0, summaries[13]['package_sum'])
|
||||
self.assertIsNone(summaries[13]['source_site_name'])
|
||||
|
||||
name_sql = compile_sql(db.execute.await_args_list[0].args[0])
|
||||
item_sql = compile_sql(db.execute.await_args_list[1].args[0])
|
||||
for fragment in (
|
||||
'biz_return_order.return_id IN (12, 13)',
|
||||
'biz_return_order.test_batch_id = 7',
|
||||
'source_site.test_batch_id = 7',
|
||||
'source_customer.test_batch_id = 7',
|
||||
'target_warehouse.test_batch_id = 7',
|
||||
'requester.user_id = biz_return_order.requested_by',
|
||||
'confirmer.user_id = biz_return_order.confirmed_by',
|
||||
"requester.del_flag = '0'",
|
||||
):
|
||||
self.assertIn(fragment, name_sql)
|
||||
self.assertIn('biz_return_item.return_id IN (12, 13)', item_sql)
|
||||
self.assertIn('biz_return_item.test_batch_id = 7', item_sql)
|
||||
self.assertIn('WHEN (biz_return_item.remaining_packages_snapshot > 0) THEN 1 ELSE 0', item_sql)
|
||||
|
||||
async def test_item_identities_join_pallet_and_snapshot_tunnel_in_same_scope(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10466', '2 号隧道'), (32, 'TP-10468', None)]))
|
||||
|
||||
identities = await ReturnSummaryDao.get_item_identities(db, 12, make_test_scope(7))
|
||||
|
||||
self.assertEqual({'pallet_code': 'TP-10466', 'tunnel_name': '2 号隧道'}, identities[31])
|
||||
self.assertEqual({'pallet_code': 'TP-10468', 'tunnel_name': None}, identities[32])
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
for fragment in (
|
||||
'biz_return_item.return_id = 12',
|
||||
'biz_return_item.test_batch_id = 7',
|
||||
'biz_pallet.test_batch_id = 7',
|
||||
'biz_tunnel.tunnel_id = biz_return_item.tunnel_id_snapshot',
|
||||
'biz_tunnel.test_batch_id = 7',
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
|
||||
class ReturnSiteOptionsDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_user_sees_assigned_sites_and_sources_of_visible_returns(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([(9, 'JH-S', '南厂', '金禾菌业')]))
|
||||
|
||||
options = await ReturnSummaryDao.get_site_options(db, [9, 9], [7], make_test_scope(4))
|
||||
|
||||
self.assertEqual([{'id': 9, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业'}], options)
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
for fragment in (
|
||||
'biz_customer_site.test_batch_id = 4',
|
||||
'biz_customer.test_batch_id = 4',
|
||||
'biz_customer_site.site_id IN (9)',
|
||||
'biz_customer_site.site_id IN (SELECT biz_return_order.source_site_id',
|
||||
'biz_return_order.test_batch_id = 4',
|
||||
'biz_return_order.target_warehouse_id IN (7)',
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
async def test_user_without_mappings_gets_no_sites(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([]))
|
||||
|
||||
await ReturnSummaryDao.get_site_options(db, [], [], TestBatchScope.formal())
|
||||
|
||||
sql = compile_sql(db.execute.await_args.args[0]).lower()
|
||||
self.assertIn('false', sql)
|
||||
self.assertNotIn('site_id in (9)', sql)
|
||||
|
||||
async def test_admin_sees_all_sites_in_batch_scope(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([]))
|
||||
|
||||
await ReturnSummaryDao.get_site_options(db, None, None, TestBatchScope.formal())
|
||||
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
self.assertIn('biz_customer_site.test_batch_id IS NULL', sql)
|
||||
self.assertNotIn('biz_return_order', sql)
|
||||
|
||||
|
||||
class ReturnOverviewDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
def _db(self) -> MagicMock:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_one((3, 26, 9, 610, datetime(2026, 10, 15, 9, 5))),
|
||||
result_rows(
|
||||
[
|
||||
(4, datetime(2026, 10, 14, 16, 0), datetime(2026, 10, 15, 8, 0), 16, 4, 210),
|
||||
(5, datetime(2026, 10, 12, 11, 0), datetime(2026, 10, 13, 11, 0), 10, 0, None),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
return db
|
||||
|
||||
async def test_overview_totals_pending_and_month_outcome(self) -> None:
|
||||
db = self._db()
|
||||
|
||||
overview = await ReturnSummaryDao.get_overview(db, [9], [7], make_test_scope(7), datetime(2026, 10, 1))
|
||||
|
||||
self.assertEqual(3, overview['pending_count'])
|
||||
self.assertEqual(26, overview['pending_pallet_count'])
|
||||
self.assertEqual(9, overview['pending_filled_count'])
|
||||
self.assertEqual(610, overview['pending_package_sum'])
|
||||
self.assertEqual(datetime(2026, 10, 15, 9, 5), overview['oldest_pending_at'])
|
||||
self.assertEqual(2, overview['month_confirmed_count'])
|
||||
self.assertEqual(26, overview['month_confirmed_pallet_count'])
|
||||
self.assertEqual(4, overview['month_filled_pallet_count'])
|
||||
self.assertEqual(210, overview['month_package_sum'])
|
||||
# (16 + 24) / 2 小时
|
||||
self.assertEqual(20.0, overview['month_avg_confirm_hours'])
|
||||
ReturnOverviewModel.model_validate(overview)
|
||||
|
||||
pending_sql, month_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
|
||||
for sql in (pending_sql, month_sql):
|
||||
self.assertIn('biz_return_order.test_batch_id = 7', sql)
|
||||
self.assertIn('biz_return_item.test_batch_id = 7', sql)
|
||||
self.assertIn(
|
||||
'(biz_return_order.source_site_id IN (9) OR biz_return_order.target_warehouse_id IN (7))',
|
||||
sql,
|
||||
)
|
||||
self.assertIn("biz_return_order.status = 'REQUESTED'", pending_sql)
|
||||
self.assertIn("biz_return_order.status = 'CONFIRMED'", month_sql)
|
||||
self.assertIn("biz_return_order.confirmed_at >= '2026-10-01 00:00:00'", month_sql)
|
||||
|
||||
async def test_empty_month_has_no_average(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(side_effect=[result_one((0, 0, None, None, None)), result_rows([])])
|
||||
|
||||
overview = await ReturnSummaryDao.get_overview(db, [], [], TestBatchScope.formal(), datetime(2026, 10, 1))
|
||||
|
||||
self.assertEqual(0, overview['pending_filled_count'])
|
||||
self.assertEqual(0, overview['month_confirmed_count'])
|
||||
self.assertIsNone(overview['month_avg_confirm_hours'])
|
||||
ReturnOverviewModel.model_validate(overview)
|
||||
for call in db.execute.await_args_list:
|
||||
self.assertIn('false', compile_sql(call.args[0]).lower())
|
||||
|
||||
async def test_admin_overview_has_no_visibility_filter(self) -> None:
|
||||
db = self._db()
|
||||
|
||||
await ReturnSummaryDao.get_overview(db, None, None, TestBatchScope.formal(), datetime(2026, 10, 1))
|
||||
|
||||
for call in db.execute.await_args_list:
|
||||
sql = compile_sql(call.args[0])
|
||||
self.assertNotIn('source_site_id IN', sql)
|
||||
self.assertNotIn('target_warehouse_id IN', sql)
|
||||
|
||||
|
||||
class ReturnServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_list_rows_carry_summary_fields_in_camel_case(self) -> None:
|
||||
record = {
|
||||
'return_id': 12,
|
||||
'return_no': 'RE-1',
|
||||
'source_site_id': 9,
|
||||
'target_warehouse_id': 7,
|
||||
'status': 'REQUESTED',
|
||||
'request_idempotency_key': 'k',
|
||||
'request_hash': 'h' * 64,
|
||||
'requested_by': 8,
|
||||
'requested_at': datetime(2026, 10, 17, 10, 20),
|
||||
}
|
||||
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
|
||||
summary = {
|
||||
12: {
|
||||
'source_site_name': '南厂',
|
||||
'customer_name': '金禾菌业',
|
||||
'target_warehouse_name': '成品一仓',
|
||||
'requester_name': '赵强',
|
||||
'confirmer_name': None,
|
||||
'pallet_count': 12,
|
||||
'package_sum': 520,
|
||||
'filled_count': 7,
|
||||
}
|
||||
}
|
||||
with (
|
||||
patch.object(OperationDao, 'get_return_list', new=AsyncMock(return_value=page)),
|
||||
patch.object(ReturnSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
|
||||
):
|
||||
result = await ReturnService.get_return_list_services(
|
||||
MagicMock(spec=AsyncSession), ReturnPageQueryModel(), admin_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertEqual([12], summaries.await_args.args[1])
|
||||
row = result.rows[0]
|
||||
self.assertIsInstance(row, ReturnListItemModel)
|
||||
dumped = row.model_dump(by_alias=True)
|
||||
self.assertEqual('南厂', dumped['sourceSiteName'])
|
||||
self.assertEqual('金禾菌业', dumped['customerName'])
|
||||
self.assertEqual('成品一仓', dumped['targetWarehouseName'])
|
||||
self.assertEqual(520, dumped['packageSum'])
|
||||
self.assertEqual(7, dumped['filledCount'])
|
||||
|
||||
async def test_detail_items_carry_pallet_code_and_tunnel_name(self) -> None:
|
||||
order = BizReturnOrder(
|
||||
return_id=12,
|
||||
return_no='RE-1',
|
||||
source_site_id=9,
|
||||
target_warehouse_id=7,
|
||||
status='REQUESTED',
|
||||
request_idempotency_key='k',
|
||||
request_hash='h' * 64,
|
||||
requested_by=8,
|
||||
requested_at=datetime(2026, 10, 17, 10, 20),
|
||||
test_batch_id=None,
|
||||
)
|
||||
order.items = [
|
||||
BizReturnItem(return_item_id=31, return_id=12, pallet_id=501, remaining_packages_snapshot=60),
|
||||
]
|
||||
identities = {31: {'pallet_code': 'TP-10466', 'tunnel_name': '2 号隧道'}}
|
||||
with (
|
||||
patch.object(OperationDao, 'get_return_by_id', new=AsyncMock(return_value=order)),
|
||||
patch.object(ReturnSummaryDao, 'get_item_identities', new=AsyncMock(return_value=identities)),
|
||||
):
|
||||
result = await ReturnService.return_detail_services(
|
||||
MagicMock(spec=AsyncSession), 12, admin_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
item = result.model_dump(by_alias=True)['items'][0]
|
||||
self.assertEqual(501, item['palletId'])
|
||||
self.assertEqual('TP-10466', item['palletCode'])
|
||||
self.assertEqual('2 号隧道', item['tunnelName'])
|
||||
|
||||
async def test_overview_uses_site_and_warehouse_scope_and_month_start(self) -> None:
|
||||
overview = {
|
||||
'pending_count': 0,
|
||||
'pending_pallet_count': 0,
|
||||
'pending_filled_count': 0,
|
||||
'pending_package_sum': 0,
|
||||
'oldest_pending_at': None,
|
||||
'month_start': datetime(2026, 10, 1).date(),
|
||||
'month_confirmed_count': 0,
|
||||
'month_confirmed_pallet_count': 0,
|
||||
'month_filled_pallet_count': 0,
|
||||
'month_package_sum': 0,
|
||||
'month_avg_confirm_hours': None,
|
||||
}
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])),
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(ReturnSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview,
|
||||
):
|
||||
result = await ReturnService.get_return_overview_services(
|
||||
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertIsInstance(result, ReturnOverviewModel)
|
||||
self.assertEqual([9], get_overview.await_args.args[1])
|
||||
self.assertEqual([7], get_overview.await_args.args[2])
|
||||
month_start = get_overview.await_args.args[4]
|
||||
self.assertEqual((1, 0, 0), (month_start.day, month_start.hour, month_start.minute))
|
||||
|
||||
async def test_site_options_return_models_with_customer_name(self) -> None:
|
||||
rows = [{'id': 9, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业'}]
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[])),
|
||||
patch.object(ReturnSummaryDao, 'get_site_options', new=AsyncMock(return_value=rows)) as get_options,
|
||||
):
|
||||
result = await ReturnService.get_site_options_services(
|
||||
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertEqual(([9], []), get_options.await_args.args[1:3])
|
||||
self.assertIsInstance(result[0], ReturnSiteOptionModel)
|
||||
self.assertEqual(
|
||||
{'id': 9, 'code': 'JH-S', 'name': '南厂', 'customerName': '金禾菌业'}, result[0].model_dump(by_alias=True)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,186 @@
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.shipment_summary_dao import ShipmentSummaryDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import ShipmentListItemModel, ShipmentPageQueryModel
|
||||
from module_business.service.shipment_service import ShipmentService
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
|
||||
def compile_sql(statement: Any) -> str:
|
||||
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
|
||||
return ' '.join(str(compiled).split()).replace('%%', '%')
|
||||
|
||||
|
||||
def make_test_scope(batch_id: int) -> TestBatchScope:
|
||||
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
|
||||
|
||||
|
||||
def result_rows(rows: list[tuple[Any, ...]]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.all.return_value = rows
|
||||
return result
|
||||
|
||||
|
||||
class ShipmentListFilterTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def _capture(self, query: ShipmentPageQueryModel, scope: TestBatchScope) -> str:
|
||||
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
|
||||
await OperationDao.get_shipment_list(
|
||||
MagicMock(spec=AsyncSession),
|
||||
query,
|
||||
warehouse_ids=[7],
|
||||
site_ids=[9],
|
||||
scope=scope,
|
||||
)
|
||||
return compile_sql(paginate.await_args.args[1])
|
||||
|
||||
async def test_receipt_state_filter_only_matches_confirmed_shipments(self) -> None:
|
||||
sql = await self._capture(ShipmentPageQueryModel(receipt_state='PENDING'), TestBatchScope.formal())
|
||||
self.assertIn("biz_shipment_order.status = 'CONFIRMED'", sql)
|
||||
self.assertIn("biz_shipment_order.receipt_state = 'PENDING'", sql)
|
||||
|
||||
async def test_keyword_matches_number_and_names_within_same_batch_scope(self) -> None:
|
||||
sql = await self._capture(ShipmentPageQueryModel(keyword='金禾'), make_test_scope(7))
|
||||
for fragment in (
|
||||
"biz_shipment_order.shipment_no LIKE '%金禾%'",
|
||||
"source_warehouse.warehouse_name LIKE '%金禾%'",
|
||||
"target_site.site_name LIKE '%金禾%'",
|
||||
"target_customer.customer_name LIKE '%金禾%'",
|
||||
'source_warehouse.test_batch_id = 7',
|
||||
'target_site.test_batch_id = 7',
|
||||
'target_customer.test_batch_id = 7',
|
||||
'biz_shipment_order.source_warehouse_id IN (7)',
|
||||
'biz_shipment_order.target_site_id IN (9)',
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
async def test_without_keyword_list_does_not_join_master_tables(self) -> None:
|
||||
sql = await self._capture(ShipmentPageQueryModel(), TestBatchScope.formal())
|
||||
self.assertNotIn('JOIN', sql)
|
||||
|
||||
def test_keyword_rejects_blank_value(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
ShipmentPageQueryModel(keyword=' ')
|
||||
|
||||
|
||||
class ShipmentSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_empty_ids_skip_database(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
self.assertEqual({}, await ShipmentSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
|
||||
db.execute.assert_not_called()
|
||||
|
||||
async def test_aggregates_names_packages_and_strains_per_shipment(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_rows([(12, '成品一仓', '南厂', '金禾菌业', '李娜'), (13, '成品二仓', None, None, None)]),
|
||||
result_rows(
|
||||
[
|
||||
(12, 100, None, 'A15', 'A15'),
|
||||
(12, 100, None, 'S130', None),
|
||||
(12, 100, None, 'A15', 'A15'),
|
||||
(13, None, 40, 'B22', 'B22'),
|
||||
(13, None, None, None, None),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
summaries = await ShipmentSummaryDao.get_list_summaries(db, [13, 12, 12], make_test_scope(7))
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
'source_warehouse_name': '成品一仓',
|
||||
'target_site_name': '南厂',
|
||||
'customer_name': '金禾菌业',
|
||||
'creator_name': '李娜',
|
||||
'pallet_count': 3,
|
||||
'package_qty': 300,
|
||||
'strains': [{'strain_name': 'A15', 'pallet_count': 2}, {'strain_name': 'S130', 'pallet_count': 1}],
|
||||
},
|
||||
summaries[12],
|
||||
)
|
||||
# 草稿中已不可发货的托盘计入托盘数,但不计入包数
|
||||
self.assertEqual(2, summaries[13]['pallet_count'])
|
||||
self.assertEqual(40, summaries[13]['package_qty'])
|
||||
self.assertEqual([{'strain_name': 'B22', 'pallet_count': 1}], summaries[13]['strains'])
|
||||
|
||||
name_sql = compile_sql(db.execute.await_args_list[0].args[0])
|
||||
item_sql = compile_sql(db.execute.await_args_list[1].args[0])
|
||||
self.assertIn('biz_shipment_order.shipment_id IN (12, 13)', name_sql)
|
||||
for fragment in (
|
||||
'biz_shipment_order.test_batch_id = 7',
|
||||
'source_warehouse.test_batch_id = 7',
|
||||
'target_site.test_batch_id = 7',
|
||||
'target_customer.test_batch_id = 7',
|
||||
):
|
||||
self.assertIn(fragment, name_sql)
|
||||
for fragment in (
|
||||
'biz_shipment_item.shipment_id IN (12, 13)',
|
||||
'biz_shipment_item.test_batch_id = 7',
|
||||
'biz_pallet_inventory.test_batch_id = 7',
|
||||
'biz_filling_record.test_batch_id = 7',
|
||||
"biz_shipment_order.status = 'DRAFT'",
|
||||
"biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'",
|
||||
):
|
||||
self.assertIn(fragment, item_sql)
|
||||
|
||||
|
||||
class ShipmentListServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_rows_carry_summary_fields_in_camel_case(self) -> None:
|
||||
record = {
|
||||
'shipment_id': 12,
|
||||
'shipment_no': 'SH-1',
|
||||
'source_warehouse_id': 7,
|
||||
'target_site_id': 9,
|
||||
'status': 'CONFIRMED',
|
||||
'receipt_state': 'PENDING',
|
||||
}
|
||||
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
|
||||
summary = {
|
||||
12: {
|
||||
'source_warehouse_name': '成品一仓',
|
||||
'target_site_name': '南厂',
|
||||
'customer_name': '金禾菌业',
|
||||
'creator_name': '李娜',
|
||||
'pallet_count': 2,
|
||||
'package_qty': 200,
|
||||
'strains': [{'strain_name': 'A15', 'pallet_count': 2}],
|
||||
}
|
||||
}
|
||||
user = CurrentUserModel.model_construct(
|
||||
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, admin=True)
|
||||
)
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9])),
|
||||
patch.object(OperationDao, 'get_shipment_list', new=AsyncMock(return_value=page)),
|
||||
patch.object(ShipmentSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
|
||||
patch.object(ShipmentService, '_scopes', new=AsyncMock(return_value=([7], [9]))),
|
||||
):
|
||||
result = await ShipmentService.get_shipment_list_services(
|
||||
MagicMock(spec=AsyncSession), ShipmentPageQueryModel(), user, TestBatchScope.formal()
|
||||
)
|
||||
|
||||
summaries.assert_awaited_once()
|
||||
self.assertEqual([12], summaries.await_args.args[1])
|
||||
row = result.rows[0]
|
||||
self.assertIsInstance(row, ShipmentListItemModel)
|
||||
dumped = row.model_dump(by_alias=True)
|
||||
self.assertEqual('成品一仓', dumped['sourceWarehouseName'])
|
||||
self.assertEqual(200, dumped['packageQty'])
|
||||
self.assertEqual([{'strainName': 'A15', 'palletCount': 2}], dumped['strains'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -31,6 +31,8 @@ from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.shipment_label_dao import ShipmentLabelDao
|
||||
from module_business.dao.shipment_summary_dao import ShipmentSummaryDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.pallet_state_machine import PalletStateMachine
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
@@ -47,6 +49,7 @@ from module_business.entity.vo.operation_vo import (
|
||||
ConfirmCommandModel,
|
||||
PalletScanResponseModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentListItemModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
@@ -425,7 +428,7 @@ class ShipmentControllerContractTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
routes = self._routes()
|
||||
self.assertEqual(PageResponseModel[ShipmentModel], routes[('/business/shipment/list', 'GET')].response_model)
|
||||
self.assertEqual(PageResponseModel[ShipmentListItemModel], routes[('/business/shipment/list', 'GET')].response_model)
|
||||
for path in ('warehouse-options', 'site-options'):
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
@@ -545,6 +548,8 @@ class ShipmentReadServiceTest(unittest.IsolatedAsyncioTestCase):
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[9, 9])),
|
||||
patch.object(OperationDao, 'get_shipment_list', new=get_list),
|
||||
patch.object(OperationDao, 'get_shipment_by_id', new=get_detail),
|
||||
patch.object(ShipmentSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
|
||||
patch.object(ShipmentLabelDao, 'get_label_sources', new=AsyncMock(return_value=[])),
|
||||
):
|
||||
listed = await ShipmentService.get_shipment_list_services(
|
||||
self.db,
|
||||
|
||||
@@ -12,6 +12,7 @@ from module_business.dao.usage_fifo_dao import UsageFifoDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import BizPallet
|
||||
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
|
||||
from module_business.service.access_service import AccessService
|
||||
from module_business.service.usage_fifo_service import UsageFifoService
|
||||
from module_business.service.usage_service import UsageService
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
@@ -188,3 +189,53 @@ class TestUsageFifo(unittest.IsolatedAsyncioTestCase):
|
||||
self.db, 'qr-2', MagicMock(), TestBatchScope.formal(), tunnel_id=13)
|
||||
self.assertEqual(result.recommended.pallet_id, 1)
|
||||
self.assertTrue(result.requires_skip_reason)
|
||||
|
||||
async def test_queue_groups_by_strain_in_recommendation_order(self) -> None:
|
||||
# 同菌种按实际收货时间排序;已入隧和不可用的托盘不进入待使用队列
|
||||
self.add(1, datetime(2026, 10, 8))
|
||||
self.add(2, datetime(2026, 10, 6), remaining=80, state='AT_SITE_PARTIAL')
|
||||
self.add(3, datetime(2026, 10, 1), tunnel=13)
|
||||
self.add(4, None)
|
||||
self.add(5, datetime(2026, 10, 2), strain=8)
|
||||
self.add(6, datetime(2026, 10, 1), state='RETURNING')
|
||||
self.add(7, datetime(2026, 10, 1), site=10)
|
||||
|
||||
groups = await UsageFifoDao.queue(self.db, site_id=9, scope=TestBatchScope.formal())
|
||||
|
||||
self.assertEqual([4, 8], [group['strain_id'] for group in groups])
|
||||
main = groups[0]
|
||||
self.assertEqual([2, 1], [pallet['pallet_id'] for pallet in main['pallets']])
|
||||
self.assertEqual((2, 1), (main['pallet_count'], main['unknown_arrival_count']))
|
||||
self.assertEqual(80, main['pallets'][0]['remaining_packages'])
|
||||
self.assertEqual([5], [pallet['pallet_id'] for pallet in groups[1]['pallets']])
|
||||
candidate, unknown = await self.query()
|
||||
self.assertEqual((main['pallets'][0]['pallet_id'], main['unknown_arrival_count']), (candidate['pallet_id'], unknown))
|
||||
|
||||
async def test_queue_limits_listed_pallets_but_counts_all_and_respects_test_scope(self) -> None:
|
||||
for pallet_id in range(1, 6):
|
||||
self.add(pallet_id, datetime(2026, 10, pallet_id))
|
||||
self.add(9, datetime(2026, 9, 1), batch=7)
|
||||
|
||||
groups = await UsageFifoDao.queue(self.db, site_id=9, scope=TestBatchScope.formal(), limit_per_strain=2)
|
||||
self.assertEqual(5, groups[0]['pallet_count'])
|
||||
self.assertEqual([1, 2], [pallet['pallet_id'] for pallet in groups[0]['pallets']])
|
||||
test_groups = await UsageFifoDao.queue(self.db, site_id=9, scope=make_test_scope(7))
|
||||
self.assertEqual([9], [pallet['pallet_id'] for pallet in test_groups[0]['pallets']])
|
||||
|
||||
async def test_queue_service_checks_site_access_before_query(self) -> None:
|
||||
with (
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock(side_effect=PermissionException(data='', message='无权访问该客户现场'))),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await UsageFifoService.queue(self.db, 9, MagicMock(), TestBatchScope.formal())
|
||||
self.db.execute.assert_not_awaited()
|
||||
|
||||
self.add(1, datetime(2026, 10, 1))
|
||||
with (
|
||||
patch.object(AccessService, 'assert_site_access', new=AsyncMock()),
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=SimpleNamespace(site_id=9, test_batch_id=None))),
|
||||
):
|
||||
result = await UsageFifoService.queue(self.db, 9, MagicMock(), TestBatchScope.formal())
|
||||
dumped = result.model_dump(by_alias=True)
|
||||
self.assertEqual(9, dumped['siteId'])
|
||||
self.assertEqual('P-1', dumped['strains'][0]['pallets'][0]['palletCode'])
|
||||
|
||||
@@ -30,6 +30,7 @@ from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.inventory_dao import InventoryDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.usage_summary_dao import UsageSummaryDao
|
||||
from module_business.domain.enums import PalletEventType, PalletFlowState
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.do.master_do import BizCustomerSite, BizPallet, BizProductionLine, BizTunnel
|
||||
@@ -45,8 +46,11 @@ from module_business.entity.vo.operation_vo import (
|
||||
PalletScanResponseModel,
|
||||
TunnelEntrySuccessResponseModel,
|
||||
UsageCreateModel,
|
||||
UsageListItemModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
UsageSuccessResponseModel,
|
||||
)
|
||||
from module_business.service.access_service import AccessService
|
||||
@@ -381,6 +385,7 @@ class TestUsageReadServices(unittest.IsolatedAsyncioTestCase):
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[11, 9, 9])),
|
||||
patch.object(OperationDao, 'get_usage_list', new=get_list),
|
||||
patch.object(UsageSummaryDao, 'get_list_summaries', new=AsyncMock(return_value={})),
|
||||
):
|
||||
result = await UsageService.get_usage_list_services(self.db, query, self.user, TestBatchScope.formal())
|
||||
|
||||
@@ -648,6 +653,8 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
('/business/usage/list', 'get'),
|
||||
('/business/usage/line-options', 'get'),
|
||||
('/business/usage/tunnel-options', 'get'),
|
||||
('/business/usage/site-options', 'get'),
|
||||
('/business/usage/site-board', 'get'),
|
||||
('/business/usage/enter-tunnel', 'post'),
|
||||
('/business/usage/scan/{qr_token}', 'get'),
|
||||
('/business/usage/{usage_id}', 'get'),
|
||||
@@ -674,7 +681,15 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
)
|
||||
|
||||
routes = self._routes()
|
||||
self.assertEqual(PageResponseModel[UsageModel], routes[('/business/usage/list', 'GET')].response_model)
|
||||
self.assertEqual(PageResponseModel[UsageListItemModel], routes[('/business/usage/list', 'GET')].response_model)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[UsageSiteOptionModel]],
|
||||
routes[('/business/usage/site-options', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[UsageSiteBoardModel],
|
||||
routes[('/business/usage/site-board', 'GET')].response_model,
|
||||
)
|
||||
self.assertEqual(
|
||||
DataResponseModel[list[BusinessOptionModel]],
|
||||
routes[('/business/usage/line-options', 'GET')].response_model,
|
||||
@@ -717,6 +732,8 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
detail_index = paths.index('/business/usage/{usage_id}')
|
||||
self.assertLess(paths.index('/business/usage/line-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/usage/tunnel-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/usage/site-options'), detail_index)
|
||||
self.assertLess(paths.index('/business/usage/site-board'), detail_index)
|
||||
self.assertLess(paths.index('/business/usage/enter-tunnel'), detail_index)
|
||||
self.assertLess(paths.index('/business/usage/scan/{qr_token}'), detail_index)
|
||||
|
||||
@@ -725,6 +742,8 @@ class TestUsageControllerContract(unittest.TestCase):
|
||||
('/business/usage/list', 'GET'): 'business:usage:list',
|
||||
('/business/usage/line-options', 'GET'): 'business:usage:query',
|
||||
('/business/usage/tunnel-options', 'GET'): 'business:usage:query',
|
||||
('/business/usage/site-options', 'GET'): 'business:usage:list',
|
||||
('/business/usage/site-board', 'GET'): 'business:usage:query',
|
||||
('/business/usage/enter-tunnel', 'POST'): 'business:usage:add',
|
||||
('/business/usage/scan/{qr_token}', 'GET'): 'business:usage:query',
|
||||
('/business/usage/{usage_id}', 'GET'): 'business:usage:query',
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
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
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from common.vo import PageModel
|
||||
from exceptions.exception import PermissionException, ServiceException
|
||||
from module_admin.entity.vo.user_vo import CurrentUserModel
|
||||
from module_business.dao.access_dao import AccessDao
|
||||
from module_business.dao.master_dao import MasterDao
|
||||
from module_business.dao.operation_dao import OperationDao
|
||||
from module_business.dao.usage_summary_dao import UsageSummaryDao
|
||||
from module_business.domain.test_batch_scope import TestBatchScope
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
UsageListItemModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
)
|
||||
from module_business.service.usage_service import UsageService
|
||||
from utils.page_util import PageUtil
|
||||
|
||||
|
||||
def compile_sql(statement: Any) -> str:
|
||||
compiled = statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})
|
||||
return ' '.join(str(compiled).split()).replace('%%', '%')
|
||||
|
||||
|
||||
def make_test_scope(batch_id: int) -> TestBatchScope:
|
||||
return TestBatchScope(batch_id=batch_id, mode='TEST', status=SimpleNamespace(value='ACTIVE'), writable=True)
|
||||
|
||||
|
||||
def result_rows(rows: list[Any]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.all.return_value = rows
|
||||
return result
|
||||
|
||||
|
||||
def result_one(row: tuple[Any, ...]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.one.return_value = row
|
||||
return result
|
||||
|
||||
|
||||
def result_scalar(value: Any) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.scalar_one_or_none.return_value = value
|
||||
return result
|
||||
|
||||
|
||||
def admin_user() -> CurrentUserModel:
|
||||
return CurrentUserModel.model_construct(
|
||||
permissions=['*:*:*'], roles=['admin'], user=SimpleNamespace(user_id=1, user_name='admin', admin=True)
|
||||
)
|
||||
|
||||
|
||||
def normal_user() -> CurrentUserModel:
|
||||
return CurrentUserModel.model_construct(
|
||||
permissions=['business:usage:list'],
|
||||
roles=['common'],
|
||||
user=SimpleNamespace(user_id=5, user_name='zq', admin=False),
|
||||
)
|
||||
|
||||
|
||||
class UsageListFilterTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def _capture(self, query: UsagePageQueryModel) -> str:
|
||||
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
|
||||
await OperationDao.get_usage_list(
|
||||
MagicMock(spec=AsyncSession), query, site_ids=[9], scope=make_test_scope(4)
|
||||
)
|
||||
return compile_sql(paginate.await_args.args[1])
|
||||
|
||||
async def test_keyword_matches_usage_no_or_pallet_code_and_skip_filter_requires_reason(self) -> None:
|
||||
sql = await self._capture(UsagePageQueryModel(keyword='TP-104', fifoSkipped=True))
|
||||
for fragment in (
|
||||
'JOIN biz_pallet ON biz_pallet.pallet_id = biz_usage_record.pallet_id',
|
||||
"biz_usage_record.usage_no LIKE '%TP-104%'",
|
||||
"biz_pallet.pallet_code LIKE '%TP-104%'",
|
||||
'biz_usage_record.fifo_skip_reason IS NOT NULL',
|
||||
'biz_usage_record.site_id IN (9)',
|
||||
'biz_usage_record.test_batch_id = 4',
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
async def test_plain_list_does_not_join_or_filter_skips(self) -> None:
|
||||
sql = await self._capture(UsagePageQueryModel(fifoSkipped=False))
|
||||
self.assertNotIn('JOIN', sql)
|
||||
self.assertNotIn('fifo_skip_reason IS NOT NULL', sql)
|
||||
|
||||
def test_keyword_rejects_blank_value(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
UsagePageQueryModel(keyword=' ')
|
||||
|
||||
|
||||
class UsageListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_empty_ids_skip_database(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
self.assertEqual({}, await UsageSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
|
||||
db.execute.assert_not_called()
|
||||
|
||||
async def test_names_and_skipped_pallet_come_from_same_scope_and_snapshot(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
snapshot = {'palletId': 100, 'palletCode': 'TP-10495', 'siteArrivedAt': '2026-09-10T08:30:00'}
|
||||
db.execute = AsyncMock(
|
||||
return_value=result_rows(
|
||||
[
|
||||
(71, 'TP-10501', '南厂', 'JH-S-T03', '3 号隧道', '一号线', '赵强', snapshot),
|
||||
(72, 'TP-10502', '南厂', None, None, None, None, None),
|
||||
(73, 'TP-10503', '南厂', None, None, None, None, {'palletCode': ' '}),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
summaries = await UsageSummaryDao.get_list_summaries(db, [73, 72, 71], make_test_scope(4))
|
||||
|
||||
self.assertEqual(
|
||||
{
|
||||
'pallet_code': 'TP-10501',
|
||||
'site_name': '南厂',
|
||||
'tunnel_code': 'JH-S-T03',
|
||||
'tunnel_name': '3 号隧道',
|
||||
'line_name': '一号线',
|
||||
'operator_name': '赵强',
|
||||
'skipped_pallet_code': 'TP-10495',
|
||||
},
|
||||
summaries[71],
|
||||
)
|
||||
self.assertIsNone(summaries[72]['skipped_pallet_code'])
|
||||
self.assertIsNone(summaries[73]['skipped_pallet_code'])
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
for fragment in (
|
||||
'biz_usage_record.usage_id IN (71, 72, 73)',
|
||||
'biz_usage_record.test_batch_id = 4',
|
||||
'biz_pallet.test_batch_id = 4',
|
||||
'biz_customer_site.test_batch_id = 4',
|
||||
'biz_tunnel.test_batch_id = 4',
|
||||
'biz_production_line.test_batch_id = 4',
|
||||
"sys_user.del_flag = '0'",
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
self.assertNotIn('trace_no', sql)
|
||||
|
||||
|
||||
class UsageSiteOptionsDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_user_sites_are_enabled_and_carry_counts(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([(4, 'JH-S', '南厂', '金禾菌业', 4, None)]))
|
||||
|
||||
options = await UsageSummaryDao.get_site_options(db, [5, 4, 4], make_test_scope(4))
|
||||
|
||||
self.assertEqual(
|
||||
[
|
||||
{
|
||||
'id': 4,
|
||||
'code': 'JH-S',
|
||||
'name': '南厂',
|
||||
'customer_name': '金禾菌业',
|
||||
'tunnel_count': 4,
|
||||
'line_count': 0,
|
||||
}
|
||||
],
|
||||
options,
|
||||
)
|
||||
sql = compile_sql(db.execute.await_args.args[0])
|
||||
for fragment in (
|
||||
'biz_customer_site.site_id IN (4, 5)',
|
||||
"biz_customer_site.status = '0'",
|
||||
'biz_customer_site.test_batch_id = 4',
|
||||
"biz_tunnel.status = '0'",
|
||||
'biz_tunnel.test_batch_id = 4',
|
||||
"biz_production_line.status = '0'",
|
||||
):
|
||||
self.assertIn(fragment, sql)
|
||||
|
||||
async def test_without_sites_matches_nothing_and_admin_is_unfiltered(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(return_value=result_rows([]))
|
||||
await UsageSummaryDao.get_site_options(db, [], TestBatchScope.formal())
|
||||
self.assertIn('false', compile_sql(db.execute.await_args.args[0]).lower())
|
||||
await UsageSummaryDao.get_site_options(db, None, TestBatchScope.formal())
|
||||
self.assertNotIn('biz_customer_site.site_id IN', compile_sql(db.execute.await_args.args[0]))
|
||||
|
||||
|
||||
class UsageSiteBoardDaoTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_board_combines_stock_today_usage_tunnels_and_skips(self) -> None:
|
||||
db = MagicMock(spec=AsyncSession)
|
||||
db.execute = AsyncMock(
|
||||
side_effect=[
|
||||
result_scalar('金禾菌业'),
|
||||
result_rows([('AT_SITE_UNUSED', 22, 2200), ('AT_SITE_PARTIAL', 4, 205), ('AT_SITE_EMPTY', 8, 0)]),
|
||||
result_rows([(4, '一号线', 3, 120), (None, None, 1, 20), (5, '二号线', 2, 200)]),
|
||||
result_one((1, 3)),
|
||||
result_rows(
|
||||
[
|
||||
(2, 'JH-S-T01', '1 号隧道', True, datetime(2026, 9, 7, 7, 59)),
|
||||
(3, 'JH-S-T02', '2 号隧道', False, datetime(2026, 9, 1)),
|
||||
]
|
||||
),
|
||||
result_rows([(2, 1, 75)]),
|
||||
result_rows([(2, datetime(2026, 10, 17, 10, 55), '一号线')]),
|
||||
result_rows([(4, 'JH-S-L1', '一号线'), (5, 'JH-S-L2', '二号线')]),
|
||||
]
|
||||
)
|
||||
site = SimpleNamespace(site_id=4, site_code='JH-S', site_name='南厂', customer_id=3)
|
||||
|
||||
board = await UsageSummaryDao.get_site_board(
|
||||
db, site, make_test_scope(4), datetime(2026, 10, 18), datetime(2026, 10, 12)
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
(26, 2405, 22, 4, 8),
|
||||
(
|
||||
board['pallet_count'],
|
||||
board['package_sum'],
|
||||
board['unused_pallet_count'],
|
||||
board['partial_pallet_count'],
|
||||
board['empty_pallet_count'],
|
||||
),
|
||||
)
|
||||
self.assertEqual((6, 340), (board['today_usage_count'], board['today_used_packages']))
|
||||
self.assertEqual([200, 120, 20], [row['used_packages'] for row in board['today_by_line']])
|
||||
self.assertEqual((2, 1), (board['tunnel_count'], board['active_tunnel_count']))
|
||||
self.assertEqual((1, 3), (board['today_fifo_skip_count'], board['week_fifo_skip_count']))
|
||||
first, idle = board['tunnels']
|
||||
self.assertEqual(
|
||||
(1, 75, '一号线'), (first['pallet_count'], first['remaining_packages'], first['last_line_name'])
|
||||
)
|
||||
self.assertEqual(
|
||||
(False, 0, None, None), (idle['in_use'], idle['pallet_count'], idle['started_at'], idle['last_used_at'])
|
||||
)
|
||||
self.assertNotIn('trace_no', board['tunnels'][0])
|
||||
UsageSiteBoardModel.model_validate(board)
|
||||
|
||||
statements = [compile_sql(call.args[0]) for call in db.execute.await_args_list]
|
||||
_, stock_sql, today_sql, skip_sql, tunnel_sql, load_sql, last_sql, line_sql = statements
|
||||
self.assertIn('biz_pallet_inventory.site_id = 4', stock_sql)
|
||||
self.assertIn('biz_pallet_inventory.warehouse_id IS NULL', stock_sql)
|
||||
self.assertIn("biz_usage_record.occurred_at >= '2026-10-18 00:00:00'", today_sql)
|
||||
self.assertIn("biz_usage_record.occurred_at >= '2026-10-12 00:00:00'", skip_sql)
|
||||
self.assertIn('biz_usage_record.fifo_skip_reason IS NOT NULL', skip_sql)
|
||||
# 只暴露隧道是否在用,不返回内部溯源号的值
|
||||
self.assertIn('biz_tunnel.current_trace_no IS NOT NULL AS in_use', tunnel_sql)
|
||||
self.assertIn("biz_tunnel.status = '0'", tunnel_sql)
|
||||
self.assertIn('biz_pallet_inventory.remaining_packages > 0', load_sql)
|
||||
self.assertIn('max(biz_usage_record.usage_id)', last_sql)
|
||||
for sql in statements[1:]:
|
||||
self.assertRegex(sql, r'test_batch_id = 4')
|
||||
self.assertIn("biz_production_line.status = '0'", line_sql)
|
||||
|
||||
|
||||
class UsageServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_list_rows_carry_names_in_camel_case(self) -> None:
|
||||
record = {
|
||||
'usage_id': 71,
|
||||
'usage_no': 'US-1',
|
||||
'pallet_id': 501,
|
||||
'active_filling_id': 9,
|
||||
'site_id': 4,
|
||||
'tunnel_id': 2,
|
||||
'used_packages': 20,
|
||||
'remaining_packages_after': 80,
|
||||
'idempotency_key': 'k',
|
||||
'request_hash': 'h',
|
||||
'operator_id': 2,
|
||||
'occurred_at': datetime(2026, 10, 17, 10, 55),
|
||||
'fifo_skip_reason': '建议托盘外包装破损',
|
||||
}
|
||||
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
|
||||
summary = {
|
||||
71: {
|
||||
'pallet_code': 'TP-10501',
|
||||
'site_name': '南厂',
|
||||
'tunnel_code': 'JH-S-T01',
|
||||
'tunnel_name': '1 号隧道',
|
||||
'line_name': '一号线',
|
||||
'operator_name': '赵强',
|
||||
'skipped_pallet_code': 'TP-10495',
|
||||
}
|
||||
}
|
||||
with (
|
||||
patch.object(OperationDao, 'get_usage_list', new=AsyncMock(return_value=page)),
|
||||
patch.object(UsageSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
|
||||
):
|
||||
result = await UsageService.get_usage_list_services(
|
||||
MagicMock(spec=AsyncSession), UsagePageQueryModel(), admin_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertEqual([71], summaries.await_args.args[1])
|
||||
self.assertIsInstance(result.rows[0], UsageListItemModel)
|
||||
dumped = result.rows[0].model_dump(by_alias=True)
|
||||
self.assertEqual(
|
||||
('TP-10501', '1 号隧道', '一号线', 'TP-10495'),
|
||||
(dumped['palletCode'], dumped['tunnelName'], dumped['lineName'], dumped['skippedPalletCode']),
|
||||
)
|
||||
|
||||
async def test_site_options_use_site_scope(self) -> None:
|
||||
rows = [
|
||||
{'id': 4, 'code': 'JH-S', 'name': '南厂', 'customer_name': '金禾菌业', 'tunnel_count': 4, 'line_count': 3}
|
||||
]
|
||||
with (
|
||||
patch.object(AccessDao, 'list_user_site_ids', new=AsyncMock(return_value=[5, 4, 4])),
|
||||
patch.object(UsageSummaryDao, 'get_site_options', new=AsyncMock(return_value=rows)) as get_options,
|
||||
):
|
||||
result = await UsageService.get_site_options_services(
|
||||
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertEqual([4, 5], get_options.await_args.args[1])
|
||||
self.assertIsInstance(result[0], UsageSiteOptionModel)
|
||||
self.assertEqual(4, result[0].model_dump(by_alias=True)['tunnelCount'])
|
||||
|
||||
async def test_board_requires_site_access_before_reading(self) -> None:
|
||||
get_board = AsyncMock()
|
||||
with (
|
||||
patch.object(AccessDao, 'user_has_site', new=AsyncMock(return_value=False)),
|
||||
patch.object(UsageSummaryDao, 'get_site_board', new=get_board),
|
||||
self.assertRaises(PermissionException),
|
||||
):
|
||||
await UsageService.get_site_board_services(
|
||||
MagicMock(spec=AsyncSession), 7, normal_user(), TestBatchScope.formal()
|
||||
)
|
||||
get_board.assert_not_awaited()
|
||||
|
||||
async def test_board_rejects_missing_site_and_passes_today_and_week_bounds(self) -> None:
|
||||
with (
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=None)),
|
||||
self.assertRaises(ServiceException) as raised,
|
||||
):
|
||||
await UsageService.get_site_board_services(
|
||||
MagicMock(spec=AsyncSession), 4, admin_user(), TestBatchScope.formal()
|
||||
)
|
||||
self.assertEqual('客户现场不存在', raised.exception.message)
|
||||
|
||||
site = SimpleNamespace(site_id=4, test_batch_id=None)
|
||||
board = {
|
||||
'site_id': 4,
|
||||
'pallet_count': 0,
|
||||
'package_sum': 0,
|
||||
'unused_pallet_count': 0,
|
||||
'partial_pallet_count': 0,
|
||||
'empty_pallet_count': 0,
|
||||
'today_usage_count': 0,
|
||||
'today_used_packages': 0,
|
||||
'tunnel_count': 0,
|
||||
'active_tunnel_count': 0,
|
||||
'today_fifo_skip_count': 0,
|
||||
'week_fifo_skip_count': 0,
|
||||
}
|
||||
with (
|
||||
patch.object(MasterDao, 'get_site', new=AsyncMock(return_value=site)),
|
||||
patch.object(UsageSummaryDao, 'get_site_board', new=AsyncMock(return_value=board)) as get_board,
|
||||
):
|
||||
result = await UsageService.get_site_board_services(
|
||||
MagicMock(spec=AsyncSession), 4, admin_user(), TestBatchScope.formal()
|
||||
)
|
||||
|
||||
self.assertIsInstance(result, UsageSiteBoardModel)
|
||||
today_start, week_start = get_board.await_args.args[3:5]
|
||||
self.assertEqual((0, 0), (today_start.hour, today_start.minute))
|
||||
self.assertEqual(6, (today_start - week_start).days)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -30,31 +30,52 @@ from module_business.entity.vo.master_vo import (
|
||||
from module_business.entity.vo.operation_vo import (
|
||||
BusinessOptionModel,
|
||||
ConfirmCommandModel,
|
||||
FillingBatchDetailModel,
|
||||
FillingBatchDetailQueryModel,
|
||||
FillingBatchModel,
|
||||
FillingBatchPageQueryModel,
|
||||
FillingBatchRecordModel,
|
||||
FillingBatchStateCountModel,
|
||||
FillingCreateModel,
|
||||
FillingListItemModel,
|
||||
FillingModel,
|
||||
FillingOverviewModel,
|
||||
FillingPageQueryModel,
|
||||
InboundCreateModel,
|
||||
InboundItemModel,
|
||||
InboundListItemModel,
|
||||
InboundModel,
|
||||
InboundOverviewModel,
|
||||
InboundPageQueryModel,
|
||||
InboundScanQueryModel,
|
||||
InboundUpdateModel,
|
||||
InboundWarehouseCountModel,
|
||||
PalletScanResponseModel,
|
||||
ReturnConfirmModel,
|
||||
ReturnItemModel,
|
||||
ReturnListItemModel,
|
||||
ReturnModel,
|
||||
ReturnOverviewModel,
|
||||
ReturnPageQueryModel,
|
||||
ReturnRequestModel,
|
||||
ReturnScanQueryModel,
|
||||
ReturnSiteOptionModel,
|
||||
ShipmentCreateModel,
|
||||
ShipmentItemModel,
|
||||
ShipmentListItemModel,
|
||||
ShipmentModel,
|
||||
ShipmentPageQueryModel,
|
||||
ShipmentScanQueryModel,
|
||||
ShipmentStrainSummaryModel,
|
||||
ShipmentUpdateModel,
|
||||
UsageBoardTunnelModel,
|
||||
UsageCreateModel,
|
||||
UsageLinePackagesModel,
|
||||
UsageListItemModel,
|
||||
UsageModel,
|
||||
UsagePageQueryModel,
|
||||
UsageSiteBoardModel,
|
||||
UsageSiteOptionModel,
|
||||
)
|
||||
from module_business.entity.vo.report_vo import (
|
||||
ConsumptionReportModel,
|
||||
@@ -170,7 +191,7 @@ MASTER_MODEL_FIELDS = {
|
||||
},
|
||||
PalletCreateModel: {'pallet_code', 'capacity_packages', 'remark'},
|
||||
PalletEditModel: {'pallet_id', 'capacity_packages', 'asset_status', 'remark'},
|
||||
PalletPageQueryModel: {'page_num', 'page_size', 'pallet_code', 'asset_status', 'ids'},
|
||||
PalletPageQueryModel: {'page_num', 'page_size', 'pallet_code', 'asset_status', 'flow_group', 'ids'},
|
||||
DeletePalletModel: {'pallet_ids'},
|
||||
UserAssignModel: {'user_ids'},
|
||||
}
|
||||
@@ -195,7 +216,15 @@ OPERATION_MODEL_FIELDS = {
|
||||
UsageCreateModel: {'qr_token', 'tunnel_id', 'line_id', 'used_packages', 'idempotency_key', 'fifo_skip_reason'},
|
||||
ReturnRequestModel: {'source_site_id', 'target_warehouse_id', 'pallet_ids', 'idempotency_key'},
|
||||
ReturnConfirmModel: {'scanned_pallet_ids', 'idempotency_key'},
|
||||
InboundItemModel: {'inbound_item_id', 'inbound_id', 'pallet_id', 'create_by', 'create_time'},
|
||||
InboundItemModel: {
|
||||
'inbound_item_id',
|
||||
'inbound_id',
|
||||
'pallet_id',
|
||||
'pallet_code',
|
||||
'flow_state',
|
||||
'create_by',
|
||||
'create_time',
|
||||
},
|
||||
InboundModel: {
|
||||
'inbound_id',
|
||||
'inbound_no',
|
||||
@@ -212,6 +241,38 @@ OPERATION_MODEL_FIELDS = {
|
||||
'remark',
|
||||
'items',
|
||||
},
|
||||
InboundListItemModel: {
|
||||
'inbound_id',
|
||||
'inbound_no',
|
||||
'warehouse_id',
|
||||
'status',
|
||||
'confirm_idempotency_key',
|
||||
'confirm_request_hash',
|
||||
'confirmed_by',
|
||||
'confirmed_at',
|
||||
'create_by',
|
||||
'create_time',
|
||||
'update_by',
|
||||
'update_time',
|
||||
'remark',
|
||||
'items',
|
||||
'warehouse_name',
|
||||
'creator_name',
|
||||
'confirmer_name',
|
||||
'pallet_count',
|
||||
},
|
||||
InboundWarehouseCountModel: {'warehouse_id', 'warehouse_name', 'pallet_count'},
|
||||
InboundOverviewModel: {
|
||||
'draft_count',
|
||||
'draft_pallet_count',
|
||||
'oldest_draft_at',
|
||||
'month_start',
|
||||
'month_confirmed_count',
|
||||
'month_confirmed_pallet_count',
|
||||
'unstocked_pallet_count',
|
||||
'empty_pallet_count',
|
||||
'empty_by_warehouse',
|
||||
},
|
||||
FillingModel: {
|
||||
'filling_id',
|
||||
'filling_no',
|
||||
@@ -237,6 +298,88 @@ OPERATION_MODEL_FIELDS = {
|
||||
'update_time',
|
||||
'remark',
|
||||
},
|
||||
FillingListItemModel: {
|
||||
'filling_id',
|
||||
'filling_no',
|
||||
'pallet_id',
|
||||
'warehouse_id',
|
||||
'strain_id',
|
||||
'strain_code_snapshot',
|
||||
'strain_name_snapshot',
|
||||
'batch_no',
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'package_spec',
|
||||
'initial_packages',
|
||||
'active_slot',
|
||||
'closed_at',
|
||||
'idempotency_key',
|
||||
'request_hash',
|
||||
'operator_id',
|
||||
'operated_at',
|
||||
'create_by',
|
||||
'create_time',
|
||||
'update_by',
|
||||
'update_time',
|
||||
'remark',
|
||||
'pallet_code',
|
||||
'warehouse_name',
|
||||
'operator_name',
|
||||
},
|
||||
FillingBatchModel: {
|
||||
'strain_id',
|
||||
'strain_code',
|
||||
'strain_name',
|
||||
'batch_no',
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'package_spec',
|
||||
'pallet_count',
|
||||
'package_sum',
|
||||
'last_operated_at',
|
||||
'last_operator_name',
|
||||
},
|
||||
FillingBatchStateCountModel: {'state', 'pallet_count'},
|
||||
FillingBatchRecordModel: {
|
||||
'filling_id',
|
||||
'filling_no',
|
||||
'pallet_id',
|
||||
'pallet_code',
|
||||
'warehouse_name',
|
||||
'operator_name',
|
||||
'operated_at',
|
||||
},
|
||||
FillingBatchDetailModel: {
|
||||
'strain_id',
|
||||
'strain_code',
|
||||
'strain_name',
|
||||
'batch_no',
|
||||
'production_date',
|
||||
'expiry_date',
|
||||
'package_spec',
|
||||
'pallet_count',
|
||||
'package_sum',
|
||||
'last_operated_at',
|
||||
'last_operator_name',
|
||||
'distribution',
|
||||
'recent',
|
||||
},
|
||||
FillingOverviewModel: {
|
||||
'today_pallet_count',
|
||||
'today_package_sum',
|
||||
'today_batch_count',
|
||||
'month_start',
|
||||
'month_pallet_count',
|
||||
'month_batch_count',
|
||||
'month_top_strains',
|
||||
'empty_pallet_count',
|
||||
'empty_by_warehouse',
|
||||
'near_expiry_days',
|
||||
'near_expiry_pallet_count',
|
||||
'near_expiry_batch_count',
|
||||
'nearest_expiry_batch_no',
|
||||
'nearest_expiry_date',
|
||||
},
|
||||
ShipmentItemModel: {
|
||||
'shipment_item_id',
|
||||
'shipment_id',
|
||||
@@ -244,6 +387,10 @@ OPERATION_MODEL_FIELDS = {
|
||||
'active_filling_id',
|
||||
'package_qty',
|
||||
'remaining_packages',
|
||||
'pallet_code',
|
||||
'strain_code',
|
||||
'strain_name',
|
||||
'batch_no',
|
||||
'create_by',
|
||||
'create_time',
|
||||
},
|
||||
@@ -267,6 +414,37 @@ OPERATION_MODEL_FIELDS = {
|
||||
'remark',
|
||||
'items',
|
||||
},
|
||||
ShipmentStrainSummaryModel: {
|
||||
'strain_name',
|
||||
'pallet_count',
|
||||
},
|
||||
ShipmentListItemModel: {
|
||||
'shipment_id',
|
||||
'shipment_no',
|
||||
'source_warehouse_id',
|
||||
'target_site_id',
|
||||
'status',
|
||||
'confirm_idempotency_key',
|
||||
'confirm_request_hash',
|
||||
'confirmed_by',
|
||||
'confirmed_at',
|
||||
'receipt_state',
|
||||
'received_by',
|
||||
'received_at',
|
||||
'create_by',
|
||||
'create_time',
|
||||
'update_by',
|
||||
'update_time',
|
||||
'remark',
|
||||
'items',
|
||||
'source_warehouse_name',
|
||||
'target_site_name',
|
||||
'customer_name',
|
||||
'creator_name',
|
||||
'pallet_count',
|
||||
'package_qty',
|
||||
'strains',
|
||||
},
|
||||
UsageModel: {
|
||||
'usage_id',
|
||||
'fifo_skip_reason',
|
||||
@@ -285,6 +463,63 @@ OPERATION_MODEL_FIELDS = {
|
||||
'operator_id',
|
||||
'occurred_at',
|
||||
},
|
||||
UsageListItemModel: {
|
||||
'usage_id',
|
||||
'fifo_skip_reason',
|
||||
'usage_no',
|
||||
'pallet_id',
|
||||
'active_filling_id',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
'batch_no_snapshot',
|
||||
'line_id',
|
||||
'used_packages',
|
||||
'remaining_packages_after',
|
||||
'idempotency_key',
|
||||
'request_hash',
|
||||
'operator_id',
|
||||
'occurred_at',
|
||||
'pallet_code',
|
||||
'site_name',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'line_name',
|
||||
'operator_name',
|
||||
'skipped_pallet_code',
|
||||
},
|
||||
UsageBoardTunnelModel: {
|
||||
'tunnel_id',
|
||||
'tunnel_code',
|
||||
'tunnel_name',
|
||||
'in_use',
|
||||
'pallet_count',
|
||||
'remaining_packages',
|
||||
'started_at',
|
||||
'last_used_at',
|
||||
'last_line_name',
|
||||
},
|
||||
UsageLinePackagesModel: {'line_id', 'line_name', 'used_packages'},
|
||||
UsageSiteBoardModel: {
|
||||
'site_id',
|
||||
'site_code',
|
||||
'site_name',
|
||||
'customer_name',
|
||||
'pallet_count',
|
||||
'package_sum',
|
||||
'unused_pallet_count',
|
||||
'partial_pallet_count',
|
||||
'empty_pallet_count',
|
||||
'today_usage_count',
|
||||
'today_used_packages',
|
||||
'today_by_line',
|
||||
'tunnel_count',
|
||||
'active_tunnel_count',
|
||||
'today_fifo_skip_count',
|
||||
'week_fifo_skip_count',
|
||||
'tunnels',
|
||||
'lines',
|
||||
},
|
||||
ReturnItemModel: {
|
||||
'return_item_id',
|
||||
'return_id',
|
||||
@@ -294,6 +529,8 @@ OPERATION_MODEL_FIELDS = {
|
||||
'trace_no_snapshot',
|
||||
'batch_no_snapshot',
|
||||
'remaining_packages_snapshot',
|
||||
'pallet_code',
|
||||
'tunnel_name',
|
||||
'create_by',
|
||||
'create_time',
|
||||
},
|
||||
@@ -318,6 +555,48 @@ OPERATION_MODEL_FIELDS = {
|
||||
'remark',
|
||||
'items',
|
||||
},
|
||||
ReturnListItemModel: {
|
||||
'return_id',
|
||||
'return_no',
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
'status',
|
||||
'request_idempotency_key',
|
||||
'request_hash',
|
||||
'confirm_idempotency_key',
|
||||
'confirm_request_hash',
|
||||
'requested_by',
|
||||
'requested_at',
|
||||
'confirmed_by',
|
||||
'confirmed_at',
|
||||
'create_by',
|
||||
'create_time',
|
||||
'update_by',
|
||||
'update_time',
|
||||
'remark',
|
||||
'items',
|
||||
'source_site_name',
|
||||
'customer_name',
|
||||
'target_warehouse_name',
|
||||
'requester_name',
|
||||
'confirmer_name',
|
||||
'pallet_count',
|
||||
'package_sum',
|
||||
'filled_count',
|
||||
},
|
||||
ReturnOverviewModel: {
|
||||
'pending_count',
|
||||
'pending_pallet_count',
|
||||
'pending_filled_count',
|
||||
'pending_package_sum',
|
||||
'oldest_pending_at',
|
||||
'month_start',
|
||||
'month_confirmed_count',
|
||||
'month_confirmed_pallet_count',
|
||||
'month_filled_pallet_count',
|
||||
'month_package_sum',
|
||||
'month_avg_confirm_hours',
|
||||
},
|
||||
InboundPageQueryModel: {
|
||||
'page_num',
|
||||
'page_size',
|
||||
@@ -331,6 +610,7 @@ OPERATION_MODEL_FIELDS = {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'filling_no',
|
||||
'keyword',
|
||||
'warehouse_id',
|
||||
'strain_id',
|
||||
'batch_no',
|
||||
@@ -338,13 +618,24 @@ OPERATION_MODEL_FIELDS = {
|
||||
'begin_time',
|
||||
'end_time',
|
||||
},
|
||||
FillingBatchPageQueryModel: {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'keyword',
|
||||
'strain_id',
|
||||
'begin_time',
|
||||
'end_time',
|
||||
},
|
||||
FillingBatchDetailQueryModel: {'strain_id', 'batch_no'},
|
||||
ShipmentPageQueryModel: {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'shipment_no',
|
||||
'keyword',
|
||||
'source_warehouse_id',
|
||||
'target_site_id',
|
||||
'status',
|
||||
'receipt_state',
|
||||
'begin_time',
|
||||
'end_time',
|
||||
},
|
||||
@@ -352,6 +643,8 @@ OPERATION_MODEL_FIELDS = {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'usage_no',
|
||||
'keyword',
|
||||
'fifo_skipped',
|
||||
'site_id',
|
||||
'tunnel_id',
|
||||
'trace_no',
|
||||
@@ -364,6 +657,7 @@ OPERATION_MODEL_FIELDS = {
|
||||
'page_num',
|
||||
'page_size',
|
||||
'return_no',
|
||||
'keyword',
|
||||
'source_site_id',
|
||||
'target_warehouse_id',
|
||||
'status',
|
||||
@@ -374,6 +668,8 @@ OPERATION_MODEL_FIELDS = {
|
||||
ShipmentScanQueryModel: {'source_warehouse_id', 'target_site_id'},
|
||||
ReturnScanQueryModel: {'mode', 'return_id'},
|
||||
BusinessOptionModel: {'id', 'code', 'name'},
|
||||
ReturnSiteOptionModel: {'id', 'code', 'name', 'customer_name'},
|
||||
UsageSiteOptionModel: {'id', 'code', 'name', 'customer_name', 'tunnel_count', 'line_count'},
|
||||
PalletScanResponseModel: {
|
||||
'pallet_id',
|
||||
'pallet_code',
|
||||
@@ -726,6 +1022,7 @@ WRITE_MODELS = (
|
||||
OPERATION_PAGE_QUERY_MODELS = (
|
||||
InboundPageQueryModel,
|
||||
FillingPageQueryModel,
|
||||
FillingBatchPageQueryModel,
|
||||
ShipmentPageQueryModel,
|
||||
UsagePageQueryModel,
|
||||
ReturnPageQueryModel,
|
||||
|
||||
Reference in New Issue
Block a user