"""标签只读回归:执行真实服务/DAO,以记录型会话替代数据库连接。""" import base64 import importlib import importlib.util import json import unittest from datetime import date from typing import Any from unittest.mock import MagicMock, patch from fastapi import FastAPI, Request from sqlalchemy.dialects import mysql from common.aspect.interface_auth import CheckUserInterfaceAuth from exceptions.exception import PermissionException, ServiceException from module_business.controller import shipment_controller as shipment_controller_module from module_business.dependency.test_batch_dependency import TestBatchScopeResolver from module_business.domain.test_batch_scope import TestBatchScope from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet from module_business.entity.do.operation_do import BizFillingRecord, BizShipmentItem, BizShipmentOrder from module_business.service.pallet_service import PalletService from tests.module_business.test_batch_fixtures import make_admin, make_current_user, make_test_scope LabelRecords = tuple[BizShipmentOrder, BizShipmentItem, BizPallet, BizFillingRecord, BizCustomerSite, BizCustomer] def sql(statement: Any) -> str: return ' '.join(str(statement.compile(dialect=mysql.dialect(), compile_kwargs={'literal_binds': True})).split()) class ReadOnlySession: """仅支持 SELECT,不提供写入、提交或真实连接能力。""" def __init__(self, *results: Any) -> None: self.results = list(results) self.statements = [] async def execute(self, statement: Any) -> MagicMock: assert statement.is_select, '标签不得产生业务写入' self.statements.append(sql(statement)) assert self.results, '出现非预期查询' value = self.results.pop(0) result = MagicMock() result.scalar_one_or_none.return_value = value result.scalars.return_value.all.return_value = value result.all.return_value = value return result class ShipmentLabelsTest(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: spec = importlib.util.find_spec('module_business.service.shipment_label_service') self.assertIsNotNone(spec, '缺少可执行的发货标签服务') self.service = importlib.import_module('module_business.service.shipment_label_service').ShipmentLabelService def records(self, status: str = 'CONFIRMED', batch_id: int | None = None, dates: bool = True) -> LabelRecords: order = BizShipmentOrder(shipment_id=12, shipment_no='原发货-12', source_warehouse_id=7, target_site_id=9, status=status, test_batch_id=batch_id) item = BizShipmentItem(shipment_item_id=1, shipment_id=12, pallet_id=5, active_filling_id=105, package_qty=37, test_batch_id=batch_id) pallet = BizPallet(pallet_id=5, pallet_code='原托盘-005', qr_token='fixed-qr-005', asset_status='0', capacity_packages=100, test_batch_id=batch_id) filling = BizFillingRecord(filling_id=105, pallet_id=5, strain_id=3, strain_name_snapshot='', strain_code_snapshot='S03', batch_no='原生产批号-03', active_slot='ACTIVE' if status == 'DRAFT' else None, production_date=date(2026, 9, 1) if dates else None, expiry_date=date(2027, 9, 1) if dates else None, test_batch_id=batch_id) site = BizCustomerSite(site_id=9, customer_id=4, site_name='原现场', test_batch_id=batch_id) customer = BizCustomer(customer_id=4, customer_name='客户 ', test_batch_id=batch_id) order.items = [item] return order, item, pallet, filling, site, customer def session(self, records: LabelRecords, *, admin: bool = True, qty: int | None = None) -> ReadOnlySession: order, item, pallet, filling, site, customer = records current_qty = 62 if qty is None else qty detail_items = [(item, current_qty)] if order.status == 'DRAFT' else [item] rows = [(item, pallet, filling, current_qty if order.status == 'DRAFT' else item.package_qty)] values = [order, detail_items, site, customer, rows] return ReadOnlySession(*([] if admin else [[7], [9]]), *values) async def test_confirmed_uses_original_filling_and_package_snapshot_without_inventory(self) -> None: records = self.records() db = self.session(records) labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal()) self.assertEqual(1, len(labels)) payload = labels[0].model_dump(mode='json', by_alias=True) self.assertEqual('原托盘-005', payload['palletCode']) self.assertEqual('原发货-12', payload['shipmentNo']) self.assertEqual('原生产批号-03', payload['batchNo']) self.assertEqual('客户 ', payload['customerName']) self.assertEqual('原现场', payload['siteName']) self.assertEqual('', payload['strainName']) self.assertEqual(37, payload['packageQty']) self.assertEqual('2026-09-01', payload['productionDate']) self.assertEqual('2027-09-01', payload['expiryDate']) prefix, encoded = payload['qrCodeDataUrl'].split(',', 1) self.assertEqual('data:image/png;base64', prefix) self.assertEqual(PalletService.render_qr_png('fixed-qr-005'), base64.b64decode(encoded)) self.assertNotIn('biz_pallet_inventory', ' '.join(db.statements)) self.assertIn('biz_shipment_item.active_filling_id = biz_filling_record.filling_id', db.statements[-1]) self.assertNotIn("active_slot = 'ACTIVE'", db.statements[-1]) self.assertEqual([], db.results) async def test_draft_uses_current_quantity_and_active_filling_with_null_dates(self) -> None: records = self.records('DRAFT', dates=False) records[1].active_filling_id = None records[1].package_qty = None db = self.session(records) labels = await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal()) self.assertEqual(62, labels[0].package_qty) self.assertIsNone(labels[0].production_date) self.assertIsNone(labels[0].expiry_date) statement = db.statements[-1] for fragment in ( 'biz_pallet_inventory.active_filling_id = biz_filling_record.filling_id', 'biz_pallet_inventory.warehouse_id = 7', "biz_pallet_inventory.flow_state = 'FILLED_IN_WAREHOUSE'", 'biz_pallet_inventory.site_id IS NULL', 'biz_pallet_inventory.current_line_id IS NULL', 'biz_pallet_inventory.remaining_packages > 0', "biz_filling_record.active_slot = 'ACTIVE'", ): self.assertIn(fragment, statement) async def test_both_business_scopes_and_each_related_batch_are_in_real_queries(self) -> None: for scope in (TestBatchScope.formal(), make_test_scope(31)): for status in ('DRAFT', 'CONFIRMED'): with self.subTest(scope=scope, status=status): db = self.session(self.records(status, scope.batch_id), admin=False) await self.service.get_labels_services(db, 12, make_current_user(), scope) self.assertIn('source_warehouse_id IN (7)', db.statements[2]) self.assertIn('target_site_id IN (9)', db.statements[2]) combined = ' '.join(db.statements) tables = ['biz_shipment_order', 'biz_shipment_item', 'biz_pallet', 'biz_filling_record', 'biz_customer_site', 'biz_customer'] if status == 'DRAFT': tables.append('biz_pallet_inventory') for table in tables: predicate = 'IS NULL' if scope.batch_id is None else '= 31' self.assertIn(f'{table}.test_batch_id {predicate}', combined) self.assertNotIn('FOR UPDATE', combined) async def test_inaccessible_order_stops_before_related_queries_or_qr_rendering(self) -> None: for warehouses, sites in (([], [9]), ([7], []), ([8], [10])): with self.subTest(warehouses=warehouses, sites=sites): db = ReadOnlySession(warehouses, sites, None) with ( patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未授权生成二维码')), self.assertRaises(PermissionException), ): await self.service.get_labels_services(db, 12, make_current_user(), TestBatchScope.formal()) self.assertEqual(3, len(db.statements)) async def test_cross_batch_relations_are_rejected_before_qr_generation(self) -> None: for index in range(6): with self.subTest(record=index): records = self.records() records[index].test_batch_id = 31 db = self.session(records) with ( patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('跨批次生成二维码')), self.assertRaises(ServiceException), ): await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal()) async def test_incomplete_label_does_not_print_partial_or_fall_back_to_current_inventory(self) -> None: for status in ('DRAFT', 'CONFIRMED'): for missing in ('pallet', 'filling', 'quantity', 'snapshot'): with self.subTest(status=status, missing=missing): records = self.records(status) if missing == 'snapshot': records[3].pallet_id = 99 db = self.session(records) row = list(db.results[-1][0]) if missing != 'snapshot': row[{'pallet': 1, 'filling': 2, 'quantity': 3}[missing]] = None db.results[-1] = [tuple(row)] with self.assertRaises(ServiceException): await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal()) async def test_missing_later_pallet_prevents_any_qr_generation(self) -> None: records = self.records() db = self.session(records) second = BizShipmentItem(shipment_item_id=2, shipment_id=12, pallet_id=6, active_filling_id=106, package_qty=20, test_batch_id=None) db.results[1].append(second) db.results[-1].append((second, None, None, 20)) with ( patch.object(PalletService, 'render_qr_png', side_effect=AssertionError('未完成校验已生成二维码')), self.assertRaises(ServiceException), ): await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal()) async def test_empty_shipment_is_not_printable(self) -> None: records = self.records() db = self.session(records) db.results[1] = [] db.results[-1] = [] with self.assertRaises(ServiceException): await self.service.get_labels_services(db, 12, make_admin(), TestBatchScope.formal()) async def test_route_serializes_label_dates_and_prevents_cross_scope_browser_cache(self) -> None: db = self.session(self.records()) response = await shipment_controller_module.get_business_shipment_labels( Request({'type': 'http'}), 12, db, make_admin(), TestBatchScope.formal(), ) payload = json.loads(response.body) self.assertEqual(200, response.status_code) self.assertEqual('2026-09-01', payload['data'][0]['productionDate']) self.assertEqual('CONFIRMED', payload['data'][0]['shipmentStatus']) self.assertTrue(payload['data'][0]['qrCodeDataUrl'].startswith('data:image/png;base64,')) self.assertEqual('no-store', response.headers.get('Cache-Control')) async def test_draft_rejects_disabled_closed_or_excess_quantity(self) -> None: for field in ('asset_status', 'active_slot', 'capacity_packages'): with self.subTest(field=field): records = self.records('DRAFT') target = records[3] if field == 'active_slot' else records[2] setattr(target, field, {'asset_status': '1', 'active_slot': None, 'capacity_packages': 20}[field]) with self.assertRaises(ServiceException): await self.service.get_labels_services( self.session(records), 12, make_admin(), TestBatchScope.formal(), ) class ShipmentLabelRouteTest(unittest.TestCase): def test_label_route_reuses_query_permission_and_read_scope(self) -> None: app = FastAPI() app.include_router(shipment_controller_module.shipment_controller) routes = [route for route in app.routes if route.path == '/business/shipment/{shipment_id}/labels'] self.assertEqual(1, len(routes), '缺少标签 GET 路由') route = routes[0] self.assertEqual({'GET'}, route.methods) checks = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, CheckUserInterfaceAuth)] self.assertEqual(['business:shipment:query'], [check.perm for check in checks]) scopes = [dep.call for dep in route.dependant.dependencies if isinstance(dep.call, TestBatchScopeResolver)] self.assertEqual([False], [scope.write for scope in scopes]) if __name__ == '__main__': unittest.main()