"""报表隧道回归:检查真实生成的查询与服务输出,全程不连接数据库。""" 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()