import io import unittest from types import SimpleNamespace from unittest.mock import AsyncMock, patch from openpyxl import Workbook, load_workbook from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from exceptions.exception import PermissionException, ServiceException from module_business.dao.master_dao import MasterDao from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizPallet from module_business.entity.do.operation_do import BizPalletInventory from module_business.entity.vo.master_vo import StrainModel, StrainPageQueryModel from module_business.service.master_excel_service import MasterExcelService, _LISTERS from module_business.service.master_service import MasterService from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope class _Upload: def __init__(self, content: bytes) -> None: self.content = content self.closed = False async def read(self) -> bytes: return self.content async def close(self) -> None: self.closed = True def workbook_bytes(headers: list[str], rows: list[list[object]]) -> bytes: workbook = Workbook() worksheet = workbook.active worksheet.append(headers) for row in rows: worksheet.append(row) buffer = io.BytesIO() workbook.save(buffer) return buffer.getvalue() def sheet_rows(binary: bytes) -> list[tuple[object, ...]]: workbook = load_workbook(io.BytesIO(binary), data_only=False) try: return [tuple(cell.value for cell in row) for row in workbook.active.iter_rows()] finally: workbook.close() def compile_sql(statement: object) -> str: return ' '.join(str(statement.compile()).lower().split()) class MasterExcelServiceTest(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.db = AsyncMock(spec=AsyncSession) self.admin = make_admin() self.scope = TestBatchScope.formal() self.executed: list[object] = [] async def execute(statement: object) -> None: self.executed.append(statement) self.db.execute = execute async def _import(self, resource: str, headers: list[str], rows: list[list[object]], *, update_support: bool = False, scope=None, user=None): return await MasterExcelService.import_resource( self.db, resource, # type: ignore[arg-type] _Upload(workbook_bytes(headers, rows)), update_support, user or self.admin, scope or self.scope, ) async def test_add_skip_update_and_other_scope(self) -> None: headers = ['菌种编码', '菌种名称', '状态', '备注'] with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)), patch.object( MasterDao, 'add_strain', new=AsyncMock() ) as add_strain: added = await self._import('strain', headers, [[1001, '香菇', '正常', '首批']]) self.assertIn('新增1条', added.message) saved = add_strain.await_args.args[1] self.assertEqual('1001', saved.strain_code) self.assertEqual('香菇', saved.strain_name) self.assertEqual('0', saved.status) self.assertIsNone(add_strain.await_args.args[2]) self.db.commit.assert_awaited_once() self.db.commit.reset_mock() existing = SimpleNamespace(strain_id=3, strain_code='S001', test_batch_id=None) with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=existing)), patch.object( MasterDao, 'get_strain', new=AsyncMock(return_value=existing) ), patch.object(MasterDao, 'update_strain', new=AsyncMock()) as update_strain: skipped = await self._import('strain', headers, [['S001', '香菇', '正常', '']]) self.assertIn('跳过1条', skipped.message) self.assertIn('已跳过', skipped.message) update_strain.assert_not_awaited() self.db.rollback.assert_awaited() self.db.commit.assert_not_awaited() self.db.rollback.reset_mock() with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=existing)), patch.object( MasterDao, 'get_strain', new=AsyncMock(return_value=existing) ), patch.object(MasterDao, 'update_strain', new=AsyncMock()) as update_strain: updated = await self._import('strain', headers, [['S001', '新香菇', '停用', '改备注']], update_support=True) self.assertIn('更新1条', updated.message) model = update_strain.await_args.args[1] self.assertEqual(3, model.strain_id) self.assertEqual('S001', model.strain_code) self.assertEqual('新香菇', model.strain_name) self.assertEqual('1', model.status) self.db.commit.assert_awaited_once() self.db.commit.reset_mock() outside = SimpleNamespace(strain_id=9, strain_code='S009', test_batch_id=4) with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=outside)), patch.object( MasterDao, 'get_strain', new=AsyncMock(return_value=None) ), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_strain: rejected = await self._import('strain', headers, [['S009', '越界', '正常', '']]) self.assertIn('已存在于其他数据范围', rejected.message) add_strain.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_awaited() async def test_reordered_headers_and_row_error_still_commit_success_rows(self) -> None: headers = ['备注', '状态', '菌种名称', '菌种编码'] with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)), patch.object( MasterDao, 'add_strain', new=AsyncMock() ) as add_strain: result = await self._import('strain', headers, [['备注', '正常', '香菇', 'S001'], ['坏', '启用', '平菇', 'S002']]) self.assertEqual(1, add_strain.await_count) self.assertIn('新增1条', result.message) self.assertIn('第3行', result.message) self.assertIn('状态只能填写正常或停用', result.message) self.db.commit.assert_awaited_once() async def test_duplicate_identity_rejects_whole_file_before_write(self) -> None: headers = ['菌种编码', '菌种名称', '状态', '备注'] with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock()) as find_strain: with self.assertRaises(ServiceException) as caught: await self._import('strain', headers, [['S001', '甲', '正常', ''], ['s001', '乙', '停用', '']]) self.assertIn('重复编码', caught.exception.message) find_strain.assert_not_awaited() self.db.commit.assert_not_awaited() self.db.rollback.assert_not_awaited() async def test_header_mismatch_empty_file_and_size_limit(self) -> None: with self.assertRaises(ServiceException) as mismatch: await self._import('strain', ['菌种编码', '多余'], [['S001', '甲']]) self.assertIn('表头不匹配', mismatch.exception.message) with self.assertRaises(ServiceException) as empty: await MasterExcelService.import_resource( self.db, 'strain', _Upload(b''), False, self.admin, self.scope ) self.assertEqual('导入文件不能为空', empty.exception.message) with self.assertRaises(ServiceException) as oversized: await MasterExcelService.import_resource( self.db, 'strain', _Upload(b'x' * (5 * 1024 * 1024 + 1)), False, self.admin, self.scope ) self.assertIn('5MB', oversized.exception.message) self.db.commit.assert_not_awaited() async def test_missing_parent_and_ambiguous_site_code_are_row_errors(self) -> None: site_headers = ['客户编码', '现场编码', '现场名称', '现场地址', '状态', '备注'] with patch.object(MasterDao, 'find_customer_by_code', new=AsyncMock(return_value=None)), patch.object( MasterDao, 'add_site', new=AsyncMock() ) as add_site: missing = await self._import('site', site_headers, [['C404', 'SITE1', '现场', '地址', '正常', '']]) self.assertIn('目标客户C404不存在', missing.message) add_site.assert_not_awaited() line_headers = ['现场编码', '产线编码', '产线名称', '状态', '备注'] sites = [ SimpleNamespace(site_id=1, site_code='SITE1', status='0', test_batch_id=None), SimpleNamespace(site_id=2, site_code='SITE1', status='0', test_batch_id=None), ] with patch.object(MasterDao, 'list_sites_by_code', new=AsyncMock(return_value=sites)), patch.object( MasterDao, 'add_line', new=AsyncMock() ) as add_line: ambiguous = await self._import('line', line_headers, [['SITE1', 'L001', '一线', '正常', '']]) self.assertIn('现场编码SITE1不唯一', ambiguous.message) add_line.assert_not_awaited() self.db.commit.assert_not_awaited() async def test_database_error_rolls_back_and_permission_error_is_not_wrapped(self) -> None: headers = ['菌种编码', '菌种名称', '状态', '备注'] with patch.object(MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None)), patch.object( MasterDao, 'add_strain', new=AsyncMock(side_effect=IntegrityError('statement', {}, RuntimeError('duplicate'))) ): with self.assertRaises(ServiceException) as caught: await self._import('strain', headers, [['S001', '香菇', '正常', '']]) self.assertEqual('导入失败,数据已全部回滚', caught.exception.message) self.db.rollback.assert_awaited() self.db.commit.assert_not_awaited() self.db.rollback.reset_mock() with self.assertRaises(PermissionException) as denied: await self._import( 'strain', headers, [['S001', '香菇', '正常', '']], scope=make_test_scope(9), user=make_current_user(admin=False), ) self.assertEqual('仅超级管理员可执行测试批次管理操作', denied.exception.message) self.db.rollback.assert_awaited() async def test_import_uses_current_test_batch(self) -> None: headers = ['菌种编码', '菌种名称', '状态', '备注'] scope = make_test_scope(9) with patch.object(MasterService, '_prepare_test_write', new=AsyncMock()) as prepare, patch.object( MasterDao, 'find_strain_by_code', new=AsyncMock(return_value=None) ), patch.object(MasterDao, 'add_strain', new=AsyncMock()) as add_strain: await self._import('strain', headers, [['S001', '香菇', '正常', '']], scope=scope, user=self.admin) prepare.assert_awaited_once() self.assertIs(scope, prepare.await_args.args[2]) self.assertEqual(9, add_strain.await_args.args[2]) async def test_new_pallet_gets_fixed_capacity_qr_and_empty_inventory(self) -> None: async def flush() -> None: for call in self.db.add.call_args_list: record = call.args[0] if isinstance(record, BizPallet) and record.pallet_id is None: record.pallet_id = 15 self.db.flush = flush with patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=None)): result = await self._import('pallet', ['托盘编号', '状态', '备注'], [['P001', '停用', '新盘']]) added = [call.args[0] for call in self.db.add.call_args_list] pallet = next(record for record in added if isinstance(record, BizPallet)) inventory = next(record for record in added if isinstance(record, BizPalletInventory)) self.assertEqual(100, pallet.capacity_packages) self.assertEqual('1', pallet.asset_status) self.assertTrue(pallet.qr_token) self.assertNotEqual('P001', pallet.qr_token) self.assertEqual(15, inventory.pallet_id) self.assertEqual(0, inventory.remaining_packages) self.assertEqual('UNSTOCKED', inventory.flow_state) self.assertIsNone(inventory.test_batch_id) self.assertIn('新增1条', result.message) self.db.commit.assert_awaited_once() async def test_pallet_update_keeps_capacity_and_qr_token(self) -> None: pallet = SimpleNamespace( pallet_id=5, pallet_code='P001', qr_token='qr-keep', capacity_packages=80, asset_status='0', remark='旧', test_batch_id=None, ) inventory = SimpleNamespace(pallet_id=5, remaining_packages=3, test_batch_id=None) with patch.object(MasterDao, 'find_pallet_by_code', new=AsyncMock(return_value=pallet)), patch.object( MasterDao, 'get_pallet', new=AsyncMock(return_value=pallet) ), patch.object(MasterDao, 'get_pallet_inventory', new=AsyncMock(return_value=inventory)): result = await self._import( 'pallet', ['托盘编号', '状态', '备注'], [['P001', '停用', '新备注']], update_support=True ) self.assertIn('更新1条', result.message) self.assertEqual('qr-keep', pallet.qr_token) self.assertEqual(80, pallet.capacity_packages) sql = compile_sql(self.executed[-1]) self.assertNotIn('qr_token', sql) self.assertNotIn('pallet_code', sql) self.assertIn('capacity_packages', sql) self.assertIn('asset_status', sql) self.db.commit.assert_awaited_once() async def test_tunnel_update_sql_does_not_touch_current_trace(self) -> None: site = SimpleNamespace(site_id=2, site_code='SITE1', status='0', test_batch_id=None) tunnel = SimpleNamespace(tunnel_id=8, tunnel_code='T001', site_id=2, status='0', test_batch_id=None) with patch.object(MasterDao, 'list_sites_by_code', new=AsyncMock(return_value=[site])), patch.object( MasterDao, 'find_tunnel_by_code', new=AsyncMock(return_value=tunnel) ), patch.object(MasterDao, 'get_tunnel', new=AsyncMock(return_value=tunnel)), patch.object( MasterDao, 'get_site', new=AsyncMock(return_value=site) ): result = await self._import( 'tunnel', ['现场编码', '隧道编号', '隧道名称', '状态', '备注'], [['SITE1', 'T001', '新隧道', '停用', '改备注']], update_support=True, ) self.assertIn('更新1条', result.message) sql = compile_sql(self.executed[-1]) self.assertNotIn('current_trace_no', sql) self.assertNotIn('current_trace_started_at', sql) self.assertIn('tunnel_name', sql) self.assertIn('status', sql) self.db.commit.assert_awaited_once() async def test_export_uses_chinese_status_and_empty_result_keeps_headers(self) -> None: rows = [ StrainModel(strainId=1, strainCode='S001', strainName='香菇', status='0', remark='甲'), StrainModel(strainId=2, strainCode='S002', strainName='平菇', status='1', remark=None), ] query = StrainPageQueryModel() list_strain = AsyncMock(return_value=rows) with patch.dict(_LISTERS, {'strain': list_strain}): binary = await MasterExcelService.export_resource(self.db, 'strain', query, self.scope) self.assertFalse(list_strain.await_args.kwargs['is_page']) self.assertEqual( [ ('菌种编码', '菌种名称', '状态', '备注'), ('S001', '香菇', '正常', '甲'), ('S002', '平菇', '停用', None), ], [row[:4] for row in sheet_rows(binary)], ) with patch.dict(_LISTERS, {'strain': AsyncMock(return_value=[])}): empty = await MasterExcelService.export_resource(self.db, 'strain', query, self.scope) exported = sheet_rows(empty) self.assertEqual([('菌种编码', '菌种名称', '状态', '备注')], [row[:4] for row in exported]) self.assertEqual(1, len(exported)) async def test_export_passes_query_with_ids(self) -> None: rows = [ StrainModel(strainId=1, strainCode='S001', strainName='香菇', status='0', remark='甲'), ] query = StrainPageQueryModel(ids='1') list_strain = AsyncMock(return_value=rows) with patch.dict(_LISTERS, {'strain': list_strain}): binary = await MasterExcelService.export_resource(self.db, 'strain', query, self.scope) self.assertFalse(list_strain.await_args.kwargs['is_page']) self.assertEqual(query, list_strain.await_args.args[1]) self.assertEqual('1', list_strain.await_args.args[1].ids) self.assertEqual( [ ('菌种编码', '菌种名称', '状态', '备注'), ('S001', '香菇', '正常', '甲'), ], [row[:4] for row in sheet_rows(binary)], )