165 lines
9.9 KiB
Python
165 lines
9.9 KiB
Python
"""报表隧道回归:检查真实生成的查询与服务输出,全程不连接数据库。"""
|
|
|
|
import unittest
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
from common.vo import PageModel
|
|
from pydantic import ValidationError
|
|
from sqlalchemy.dialects import mysql
|
|
from sqlalchemy.sql.selectable import Join
|
|
|
|
from module_business.dao.report_dao import ReportDao
|
|
from module_business.domain.enums import PalletEventType
|
|
from module_business.domain.test_batch_scope import TestBatchScope
|
|
from module_business.entity.vo.report_vo import (
|
|
ConsumptionReportModel, ConsumptionReportQueryModel,
|
|
PalletInventoryReportQueryModel, PalletTraceReportModel,
|
|
PalletTraceReportQueryModel, ReturnReportQueryModel,
|
|
)
|
|
from module_business.service.report_service import ReportService
|
|
from tests.module_business.test_report_service import make_user, report_row
|
|
|
|
|
|
def sql(expression):
|
|
return ' '.join(str(expression.compile(
|
|
dialect=mysql.dialect(), compile_kwargs={'literal_binds': True},
|
|
)).split())
|
|
|
|
|
|
def joins(statement):
|
|
def walk(node):
|
|
if isinstance(node, Join):
|
|
yield from walk(node.left)
|
|
yield node
|
|
return list(walk(statement.get_final_froms()[0]))
|
|
|
|
|
|
class TestReportTunnelQueries(unittest.IsolatedAsyncioTestCase):
|
|
async def statement(self, method, query, scope=None, **access):
|
|
# 只替换分页执行边界,保留 DAO 的真实 SQLAlchemy 查询生成。
|
|
paginate = AsyncMock(return_value=PageModel(rows=[], total=0, pageNum=1, pageSize=10, hasNext=False))
|
|
with patch.object(ReportDao, '_paginate', paginate):
|
|
await method(None, query, scope or TestBatchScope.formal(), **access)
|
|
return paginate.await_args.args[1]
|
|
|
|
async def test_usage_without_line_is_not_removed_by_inner_join(self):
|
|
statement = await self.statement(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel())
|
|
line_join = next(join for join in joins(statement) if join.right.name == 'biz_production_line')
|
|
self.assertTrue(line_join.isouter, 'line_id=NULL 的使用记录会被 INNER JOIN 丢弃')
|
|
|
|
async def test_optional_line_rejects_existing_cross_batch_association(self):
|
|
for batch_id, condition in ((None, 'IS NULL'), (41, '= 41')):
|
|
with self.subTest(batch_id=batch_id):
|
|
scope = TestBatchScope(batch_id=batch_id, mode='TEST' if batch_id else 'FORMAL', status=None, writable=False)
|
|
statement = await self.statement(
|
|
ReportDao.get_consumption_report_list, ConsumptionReportQueryModel(), scope, site_ids=[9],
|
|
)
|
|
where = sql(statement.whereclause)
|
|
self.assertIn(f'(biz_production_line.line_id IS NULL OR biz_production_line.test_batch_id {condition})', where)
|
|
self.assertIn(f'biz_usage_record.test_batch_id {condition}', where)
|
|
self.assertIn('biz_usage_record.site_id IN (9)', where)
|
|
|
|
async def test_four_reports_filter_tunnel_and_trace_without_requiring_pallet(self):
|
|
for method, query_type, trace_column in (
|
|
(ReportDao.get_pallet_inventory_list, PalletInventoryReportQueryModel, 'biz_pallet_inventory.trace_no'),
|
|
(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel, 'biz_usage_record.trace_no'),
|
|
(ReportDao.get_return_report_list, ReturnReportQueryModel, 'biz_return_item.trace_no_snapshot'),
|
|
(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel, 'coalesce(biz_pallet_event.trace_no, trace_return_item.trace_no_snapshot)'),
|
|
):
|
|
with self.subTest(report=method.__name__):
|
|
query = query_type(tunnelId=17, tunnelCode='T-01', tunnelName='一号隧道', traceNo='TR-MIX')
|
|
statement = await self.statement(method, query)
|
|
where = sql(statement.whereclause)
|
|
self.assertIn(f"{trace_column} = 'TR-MIX'", where)
|
|
self.assertIn("tunnel_code LIKE '%%T-01%%'", where)
|
|
self.assertIn("tunnel_name LIKE '%%一号隧道%%'", where)
|
|
self.assertIn('tunnel_id', where)
|
|
self.assertIn('= 17', where)
|
|
self.assertNotIn('pallet_code LIKE', where)
|
|
self.assertTrue({'tunnel_id', 'tunnel_code', 'tunnel_name', 'trace_no'}.issubset(statement.selected_columns.keys()))
|
|
for join in joins(statement):
|
|
if 'tunnel' in join.right.name:
|
|
self.assertTrue(join.isouter)
|
|
self.assertIn(f'({join.right.name}.tunnel_id IS NULL OR {join.right.name}.test_batch_id IS NULL)', where)
|
|
|
|
async def test_usage_batch_filter_and_display_prefer_record_snapshot(self):
|
|
statement = await self.statement(ReportDao.get_consumption_report_list, ConsumptionReportQueryModel(batchNo='B-OLD'))
|
|
self.assertEqual(
|
|
'coalesce(biz_usage_record.batch_no_snapshot, biz_filling_record.batch_no)',
|
|
sql(statement.selected_columns.batch_no.element),
|
|
)
|
|
self.assertIn("coalesce(biz_usage_record.batch_no_snapshot, biz_filling_record.batch_no) LIKE '%%B-OLD%%'", sql(statement.whereclause))
|
|
|
|
async def test_return_reads_frozen_tunnel_trace_and_batch_after_inventory_changes(self):
|
|
statement = await self.statement(ReportDao.get_return_report_list, ReturnReportQueryModel(batchNo='B-OLD'))
|
|
self.assertIn('tunnel_id', statement.selected_columns.keys())
|
|
self.assertEqual('biz_return_item.tunnel_id_snapshot', sql(statement.selected_columns.get('tunnel_id')).split(' AS ')[0])
|
|
self.assertEqual('biz_return_item.trace_no_snapshot', sql(statement.selected_columns.get('trace_no')).split(' AS ')[0])
|
|
self.assertIn('coalesce(biz_return_item.batch_no_snapshot, return_filling.batch_no)', sql(statement))
|
|
self.assertIn('return_filling.filling_id = biz_return_item.active_filling_id_snapshot', sql(statement))
|
|
self.assertIn('(return_filling.filling_id IS NULL OR return_filling.test_batch_id IS NULL)', sql(statement.whereclause))
|
|
self.assertNotIn('biz_pallet_inventory', sql(statement))
|
|
|
|
async def test_trace_filters_both_historical_tunnels_and_event_batch(self):
|
|
statement = await self.statement(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel(tunnelId=17, batchNo='B-OLD'))
|
|
where = sql(statement.whereclause)
|
|
self.assertIn('(coalesce(biz_pallet_event.before_tunnel_id, trace_return_item.tunnel_id_snapshot) = 17 OR biz_pallet_event.after_tunnel_id = 17)', where)
|
|
self.assertIn("coalesce(biz_pallet_event.batch_no_snapshot, trace_return_item.batch_no_snapshot) LIKE '%%B-OLD%%'", where)
|
|
self.assertIn('biz_pallet_event.batch_no_snapshot', sql(statement))
|
|
self.assertNotIn('biz_pallet_inventory', sql(statement))
|
|
|
|
async def test_return_event_missing_tunnel_reads_same_pallet_return_snapshot(self):
|
|
statement = await self.statement(ReportDao.get_pallet_trace_list, PalletTraceReportQueryModel())
|
|
self.assertEqual(
|
|
'coalesce(biz_pallet_event.before_tunnel_id, trace_return_item.tunnel_id_snapshot)',
|
|
sql(statement.selected_columns.before_tunnel_id.element),
|
|
)
|
|
query = sql(statement)
|
|
self.assertIn("biz_pallet_event.source_type = 'RETURN'", query)
|
|
self.assertIn('trace_return_item.return_id = biz_pallet_event.source_id', query)
|
|
self.assertIn('trace_return_item.pallet_id = biz_pallet_event.pallet_id', query)
|
|
self.assertIn('(trace_return_item.return_item_id IS NULL OR trace_return_item.test_batch_id IS NULL)', query)
|
|
|
|
|
|
class TestReportTunnelOutput(unittest.IsolatedAsyncioTestCase):
|
|
async def test_consumption_service_keeps_line_less_mixed_pallet_details(self):
|
|
rows = [dict(report_row(ConsumptionReportModel), usage_id=i, pallet_id=i,
|
|
pallet_code=f'P-{i}', line_id=None, line_name=None, tunnel_id=17,
|
|
tunnel_code='T-01', tunnel_name='一号隧道', trace_no='TR-MIX',
|
|
batch_no=batch, batch_no_snapshot=batch, used_packages=qty)
|
|
for i, batch, qty in ((1, 'B-A', 30), (2, 'B-B', 20))]
|
|
page = PageModel(rows=rows, total=2, pageNum=1, pageSize=10, hasNext=False)
|
|
with patch.object(ReportDao, 'get_consumption_report_list', AsyncMock(return_value=page)):
|
|
try:
|
|
result = await ReportService.get_consumption_report_list_services(
|
|
None, ConsumptionReportQueryModel(traceNo='TR-MIX'), make_user(1), TestBatchScope.formal(),
|
|
)
|
|
except ValidationError as exc:
|
|
self.fail(f'合法的无产线使用记录被响应模型拒绝:{exc}')
|
|
actual = [row.model_dump(by_alias=True) for row in result.rows]
|
|
self.assertEqual([30, 20], [row['usedPackages'] for row in actual])
|
|
self.assertEqual(['B-A', 'B-B'], [row['batchNoSnapshot'] for row in actual])
|
|
self.assertEqual(['TR-MIX', 'TR-MIX'], [row['traceNo'] for row in actual])
|
|
self.assertEqual([None, None], [row['lineId'] for row in actual])
|
|
|
|
def test_trace_quantities_separate_usage_and_confirmed_return(self):
|
|
for event, before, after, used, returned in (
|
|
('TUNNEL_ENTERED', 100, 100, 0, 0),
|
|
('USAGE_RECORDED', 100, 70, 30, 0),
|
|
('USAGE_RECORDED', 100, 80, 20, 0),
|
|
('RETURN_REQUESTED', 70, 70, 0, 0),
|
|
('RETURN_CONFIRMED', 70, 70, 0, 70),
|
|
('RETURN_CONFIRMED', 0, 0, 0, 0),
|
|
):
|
|
with self.subTest(event=event, before=before):
|
|
model = PalletTraceReportModel.model_validate(dict(
|
|
report_row(PalletTraceReportModel), event_type=PalletEventType(event),
|
|
before_remaining_packages=before, after_remaining_packages=after,
|
|
)).model_dump(by_alias=True)
|
|
self.assertEqual(used, model.get('usedPackages'))
|
|
self.assertEqual(returned, model.get('returnedPackages'))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|