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,431 @@
import unittest
from datetime import date, datetime, timedelta
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.filling_summary_dao import FillingSummaryDao
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 (
FillingBatchDetailModel,
FillingBatchDetailQueryModel,
FillingBatchModel,
FillingBatchPageQueryModel,
FillingListItemModel,
FillingOverviewModel,
FillingPageQueryModel,
)
from module_business.service.filling_service import FillingService
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
result.first.return_value = rows[0] if rows else None
return result
def result_scalar(value: Any) -> MagicMock:
result = MagicMock()
result.scalar_one.return_value = value
return result
def batch_row(**overrides: Any) -> SimpleNamespace:
values = {
'strain_id': 3,
'batch_no': 'B261017-01',
'strain_code': 'A15',
'strain_name': '平菇 A15',
'production_date': date(2026, 10, 16),
'expiry_date': date(2026, 11, 30),
'pallet_count': 6,
'package_sum': 600,
'last_operated_at': datetime(2026, 10, 17, 9, 10),
'last_filling_id': 88,
}
values.update(overrides)
return SimpleNamespace(**values)
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:filling:list'],
roles=['common'],
user=SimpleNamespace(user_id=5, user_name='ln', admin=False),
)
class FillingListFilterTest(unittest.IsolatedAsyncioTestCase):
async def _capture(self, query: FillingPageQueryModel) -> str:
with patch.object(PageUtil, 'paginate', new=AsyncMock(return_value=object())) as paginate:
await OperationDao.get_filling_list(
MagicMock(spec=AsyncSession),
query,
warehouse_ids=[7],
scope=make_test_scope(4),
)
return compile_sql(paginate.await_args.args[1])
async def test_keyword_matches_filling_no_pallet_code_or_batch_within_scope(self) -> None:
sql = await self._capture(FillingPageQueryModel(keyword='B2610'))
for fragment in (
'JOIN biz_pallet ON biz_pallet.pallet_id = biz_filling_record.pallet_id',
"biz_filling_record.filling_no LIKE '%B2610%'",
"biz_pallet.pallet_code LIKE '%B2610%'",
"biz_filling_record.batch_no LIKE '%B2610%'",
'biz_filling_record.test_batch_id = 4',
'biz_filling_record.warehouse_id IN (7)',
):
self.assertIn(fragment, sql)
async def test_without_keyword_or_pallet_code_list_does_not_join_pallets(self) -> None:
sql = await self._capture(FillingPageQueryModel(batchNo='B1'))
self.assertNotIn('JOIN', sql)
def test_keyword_rejects_blank_value(self) -> None:
for model in (FillingPageQueryModel, FillingBatchPageQueryModel):
with self.subTest(model=model.__name__), self.assertRaises(ValueError):
model(keyword=' ')
class FillingListSummaryDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_empty_ids_skip_database(self) -> None:
db = MagicMock(spec=AsyncSession)
self.assertEqual({}, await FillingSummaryDao.get_list_summaries(db, [], TestBatchScope.formal()))
db.execute.assert_not_called()
async def test_names_are_joined_in_same_batch_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([(31, 'TP-10401', '成品一仓', '年糕')]))
summaries = await FillingSummaryDao.get_list_summaries(db, [32, 31, 31], make_test_scope(4))
self.assertEqual(
{'pallet_code': 'TP-10401', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}, summaries[31]
)
self.assertEqual({'pallet_code': None, 'warehouse_name': None, 'operator_name': None}, summaries[32])
sql = compile_sql(db.execute.await_args.args[0])
for fragment in (
'biz_filling_record.filling_id IN (31, 32)',
'biz_filling_record.test_batch_id = 4',
'biz_pallet.test_batch_id = 4',
'biz_warehouse.test_batch_id = 4',
'sys_user.user_id = biz_filling_record.operator_id',
"sys_user.del_flag = '0'",
):
self.assertIn(fragment, sql)
class FillingBatchPageDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_groups_by_strain_and_batch_newest_first_within_scope(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_scalar(18),
result_rows([batch_row(), batch_row(batch_no='B261016-01', last_filling_id=80)]),
result_rows([(88, '1kg/包', '年糕'), (80, '1.2kg/包', None)]),
]
)
query = FillingBatchPageQueryModel(
pageNum=2, pageSize=5, keyword='A15', strainId=3, beginTime='2026-10-01', endTime='2026-10-17'
)
batches, total = await FillingSummaryDao.get_batch_page(db, query, [7, 6], make_test_scope(4))
self.assertEqual(18, total)
self.assertEqual(['B261017-01', 'B261016-01'], [batch['batch_no'] for batch in batches])
self.assertEqual(('1kg/包', '年糕'), (batches[0]['package_spec'], batches[0]['last_operator_name']))
self.assertEqual(('1.2kg/包', None), (batches[1]['package_spec'], batches[1]['last_operator_name']))
FillingBatchModel.model_validate(batches[0])
count_sql, page_sql, latest_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
for sql in (count_sql, page_sql):
for fragment in (
'GROUP BY biz_filling_record.strain_id, biz_filling_record.batch_no',
'biz_filling_record.test_batch_id = 4',
'biz_filling_record.warehouse_id IN (6, 7)',
"biz_filling_record.batch_no LIKE '%A15%'",
"biz_filling_record.strain_name_snapshot LIKE '%A15%'",
'biz_filling_record.strain_id = 3',
"biz_filling_record.operated_at >= '2026-10-01 00:00:00'",
"biz_filling_record.operated_at < '2026-10-18 00:00:00'",
):
self.assertIn(fragment, sql)
self.assertIn(
'ORDER BY max(biz_filling_record.operated_at) DESC, max(biz_filling_record.filling_id) DESC', page_sql
)
self.assertIn('LIMIT 5, 5', page_sql)
self.assertIn('biz_filling_record.filling_id IN (80, 88)', latest_sql)
async def test_empty_page_skips_latest_record_lookup(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(side_effect=[result_scalar(0), result_rows([])])
batches, total = await FillingSummaryDao.get_batch_page(
db, FillingBatchPageQueryModel(), [], TestBatchScope.formal()
)
self.assertEqual(([], 0), (batches, total))
self.assertEqual(2, db.execute.await_count)
self.assertIn('false', compile_sql(db.execute.await_args_list[0].args[0]).lower())
class FillingBatchDetailDaoTest(unittest.IsolatedAsyncioTestCase):
async def test_missing_batch_returns_none_without_further_queries(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(return_value=result_rows([]))
self.assertIsNone(await FillingSummaryDao.get_batch_detail(db, 3, 'B1', [7], TestBatchScope.formal()))
self.assertEqual(1, db.execute.await_count)
async def test_distribution_uses_active_cycle_and_closed_cycles_count_as_consumed(self) -> None:
db = MagicMock(spec=AsyncSession)
recent = [
(90, 'FL-90', 501, 'TP-10501', '成品一仓', '李娜', datetime(2026, 10, 17, 9, 10), '1kg/包'),
(89, 'FL-89', 500, None, None, None, datetime(2026, 10, 17, 9, 5), '0.8kg/包'),
]
db.execute = AsyncMock(
side_effect=[
result_rows([batch_row(pallet_count=30)]),
result_rows([('AT_SITE_UNUSED', 15), (None, 2), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5)]),
result_rows(recent),
]
)
detail = await FillingSummaryDao.get_batch_detail(db, 3, 'B261017-01', [7], make_test_scope(4))
self.assertEqual(
[('AT_SITE_UNUSED', 15), ('IN_TRANSIT', 8), ('FILLED_IN_WAREHOUSE', 5), ('CONSUMED', 2)],
[(item['state'], item['pallet_count']) for item in detail['distribution']],
)
self.assertEqual(['TP-10501', None], [item['pallet_code'] for item in detail['recent']])
# 包装规格与操作人取批次内最新一条记录
self.assertEqual(('1kg/包', '李娜'), (detail['package_spec'], detail['last_operator_name']))
FillingBatchDetailModel.model_validate(detail)
header_sql, distribution_sql, recent_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
for sql in (header_sql, distribution_sql, recent_sql):
for fragment in (
'biz_filling_record.strain_id = 3',
"biz_filling_record.batch_no = 'B261017-01'",
'biz_filling_record.warehouse_id IN (7)',
'biz_filling_record.test_batch_id = 4',
):
self.assertIn(fragment, sql)
self.assertIn('biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id', distribution_sql)
self.assertIn('biz_pallet_inventory.test_batch_id = 4', distribution_sql)
self.assertIn('ORDER BY biz_filling_record.operated_at DESC, biz_filling_record.filling_id DESC', recent_sql)
self.assertIn('LIMIT 10', recent_sql)
class FillingOverviewDaoTest(unittest.IsolatedAsyncioTestCase):
def _db(self) -> MagicMock:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(
side_effect=[
result_rows([(3, 'B261017-01', '平菇 A15', 'A15', 6, 600)]),
result_rows(
[
(3, 'B261017-01', '平菇 A15', 'A15', 6, 600),
(3, 'B261016-01', '平菇 A15', 'A15', 10, 1000),
(4, 'B261015-01', '白灵菇 B22', 'B22', 27, 2700),
(5, 'B261010-01', None, 'S130', 18, 1800),
(6, 'B261008-01', '金针菇 J61', 'J61', 8, 800),
]
),
result_rows([(6, '周转仓', 10), (4, '成品一仓', 13)]),
result_rows([('B260930-01', date(2026, 10, 28), 3), ('B260923-01', date(2026, 10, 25), 4)]),
]
)
return db
async def test_overview_totals_top_strains_empty_pallets_and_nearest_expiry(self) -> None:
db = self._db()
overview = await FillingSummaryDao.get_overview(
db,
[4, 6],
make_test_scope(4),
datetime(2026, 10, 17),
datetime(2026, 10, 1),
date(2026, 10, 31),
14,
)
self.assertEqual(
(6, 600, 1), (overview['today_pallet_count'], overview['today_package_sum'], overview['today_batch_count'])
)
self.assertEqual((69, 5), (overview['month_pallet_count'], overview['month_batch_count']))
self.assertEqual(
[('白灵菇 B22', 27), ('S130', 18), ('平菇 A15', 16)],
[(item['strain_name'], item['pallet_count']) for item in overview['month_top_strains']],
)
self.assertEqual(23, overview['empty_pallet_count'])
self.assertEqual([4, 6], [row['warehouse_id'] for row in overview['empty_by_warehouse']])
self.assertEqual((7, 2), (overview['near_expiry_pallet_count'], overview['near_expiry_batch_count']))
self.assertEqual(
('B260923-01', date(2026, 10, 25)), (overview['nearest_expiry_batch_no'], overview['nearest_expiry_date'])
)
FillingOverviewModel.model_validate(overview)
today_sql, month_sql, empty_sql, near_sql = (compile_sql(call.args[0]) for call in db.execute.await_args_list)
self.assertIn("biz_filling_record.operated_at >= '2026-10-17 00:00:00'", today_sql)
self.assertIn("biz_filling_record.operated_at >= '2026-10-01 00:00:00'", month_sql)
for sql in (today_sql, month_sql):
self.assertIn('biz_filling_record.warehouse_id IN (4, 6)', sql)
self.assertIn('biz_filling_record.test_batch_id = 4', sql)
self.assertIn("biz_pallet_inventory.flow_state = 'EMPTY_IN_WAREHOUSE'", empty_sql)
self.assertIn("biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'", near_sql)
self.assertIn("biz_filling_record.expiry_date <= '2026-10-31'", near_sql)
self.assertIn('biz_filling_record.filling_id = biz_pallet_inventory.active_filling_id', near_sql)
for sql in (empty_sql, near_sql):
self.assertIn('biz_pallet_inventory.warehouse_id IN (4, 6)', sql)
self.assertIn('biz_pallet_inventory.test_batch_id = 4', sql)
async def test_user_without_warehouses_sees_nothing(self) -> None:
db = MagicMock(spec=AsyncSession)
db.execute = AsyncMock(side_effect=[result_rows([]), result_rows([]), result_rows([]), result_rows([])])
overview = await FillingSummaryDao.get_overview(
db, [], TestBatchScope.formal(), datetime(2026, 10, 17), datetime(2026, 10, 1), date(2026, 10, 31), 14
)
self.assertEqual(0, overview['month_pallet_count'])
self.assertIsNone(overview['nearest_expiry_date'])
FillingOverviewModel.model_validate(overview)
for call in db.execute.await_args_list:
self.assertIn('false', compile_sql(call.args[0]).lower())
class FillingServiceSummaryTest(unittest.IsolatedAsyncioTestCase):
async def test_list_rows_carry_names_in_camel_case(self) -> None:
record = {
'filling_id': 31,
'filling_no': 'FL-1',
'pallet_id': 501,
'warehouse_id': 4,
'strain_id': 3,
'strain_code_snapshot': 'A15',
'strain_name_snapshot': '平菇 A15',
'batch_no': 'B1',
'package_spec': '1kg/包',
'initial_packages': 100,
'idempotency_key': 'k',
'request_hash': 'h',
'operator_id': 2,
'operated_at': datetime(2026, 10, 17, 9, 10),
}
page = PageModel(rows=[record], pageNum=1, pageSize=10, total=1, hasNext=False)
summary = {31: {'pallet_code': 'TP-10501', 'warehouse_name': '成品一仓', 'operator_name': '年糕'}}
with (
patch.object(OperationDao, 'get_filling_list', new=AsyncMock(return_value=page)),
patch.object(FillingSummaryDao, 'get_list_summaries', new=AsyncMock(return_value=summary)) as summaries,
):
result = await FillingService.get_filling_list_services(
MagicMock(spec=AsyncSession), FillingPageQueryModel(), admin_user(), TestBatchScope.formal()
)
self.assertEqual([31], summaries.await_args.args[1])
self.assertIsInstance(result.rows[0], FillingListItemModel)
dumped = result.rows[0].model_dump(by_alias=True)
self.assertEqual(
('TP-10501', '成品一仓', '年糕'), (dumped['palletCode'], dumped['warehouseName'], dumped['operatorName'])
)
async def test_batches_page_uses_warehouse_scope_and_reports_has_next(self) -> None:
batch = {**vars(batch_row()), 'package_spec': '1kg/包', 'last_operator_name': '年糕'}
query = FillingBatchPageQueryModel(pageNum=1, pageSize=1)
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7, 4, 7])),
patch.object(FillingSummaryDao, 'get_batch_page', new=AsyncMock(return_value=([batch], 3))) as get_page,
):
result = await FillingService.get_filling_batches_services(
MagicMock(spec=AsyncSession), query, normal_user(), TestBatchScope.formal()
)
self.assertEqual([4, 7], get_page.await_args.args[2])
self.assertEqual((3, True), (result.total, result.has_next))
row = result.rows[0].model_dump(by_alias=True)
self.assertEqual(('B261017-01', 600, '年糕'), (row['batchNo'], row['packageSum'], row['lastOperatorName']))
self.assertNotIn('lastFillingId', row)
async def test_missing_batch_is_hidden_for_normal_user_and_precise_for_admin(self) -> None:
query = FillingBatchDetailQueryModel(strainId=3, batchNo='B1')
cases = (
(normal_user(), PermissionException, '无权访问该生产批次'),
(admin_user(), ServiceException, '生产批次不存在'),
)
for user, error, message in cases:
with (
self.subTest(admin=user.user.admin),
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(FillingSummaryDao, 'get_batch_detail', new=AsyncMock(return_value=None)) as get_detail,
self.assertRaises(error) as raised,
):
await FillingService.get_filling_batch_detail_services(
MagicMock(spec=AsyncSession), query, user, TestBatchScope.formal()
)
self.assertEqual(message, raised.exception.message)
self.assertEqual((3, 'B1'), get_detail.await_args.args[1:3])
async def test_overview_passes_today_month_and_near_expiry_bounds(self) -> None:
overview = {
'today_pallet_count': 0,
'today_package_sum': 0,
'today_batch_count': 0,
'month_start': date(2026, 10, 1),
'month_pallet_count': 0,
'month_batch_count': 0,
'month_top_strains': [],
'empty_pallet_count': 0,
'empty_by_warehouse': [],
'near_expiry_days': 14,
'near_expiry_pallet_count': 0,
'near_expiry_batch_count': 0,
'nearest_expiry_batch_no': None,
'nearest_expiry_date': None,
}
with (
patch.object(AccessDao, 'list_user_warehouse_ids', new=AsyncMock(return_value=[7])),
patch.object(FillingSummaryDao, 'get_overview', new=AsyncMock(return_value=overview)) as get_overview,
):
result = await FillingService.get_filling_overview_services(
MagicMock(spec=AsyncSession), normal_user(), TestBatchScope.formal()
)
self.assertIsInstance(result, FillingOverviewModel)
_, warehouse_ids, _, today_start, month_start, near_until, days = get_overview.await_args.args
self.assertEqual([7], warehouse_ids)
self.assertEqual((0, 0), (today_start.hour, today_start.minute))
self.assertEqual((1, today_start.month), (month_start.day, month_start.month))
self.assertEqual(today_start.date() + timedelta(days=14), near_until)
self.assertEqual(14, days)
if __name__ == '__main__':
unittest.main()