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:
@@ -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()
|
||||
Reference in New Issue
Block a user