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:
2026-10-18 05:03:10 -07:00
co-authored by Claude Opus 5.5
parent 80a71b48ce
commit 3050ccdc4e
51 changed files with 6545 additions and 103 deletions
@@ -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,