Files
ShiErFeng/shierfeng-fastapi-backend/tests/module_business/test_master_summary.py
T
yujiangandClaude Opus 5.5 3050ccdc4e 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>
2026-10-18 05:03:10 -07:00

352 lines
17 KiB
Python

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()