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:
2026-10-18 05:03:10 -07:00
co-authored by Claude Opus 5.5
parent 80a71b48ce
commit 3050ccdc4e
51 changed files with 6545 additions and 103 deletions
@@ -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()