为 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>
369 lines
16 KiB
Python
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()
|