Files
ShiErFeng/shierfeng-fastapi-backend/tests/module_business/test_usage_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

369 lines
16 KiB
Python

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