为 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>
352 lines
17 KiB
Python
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()
|