diff --git a/shierfeng-fastapi-backend/module_business/dao/report_dao.py b/shierfeng-fastapi-backend/module_business/dao/report_dao.py index 77dc438..fd6f2c0 100644 --- a/shierfeng-fastapi-backend/module_business/dao/report_dao.py +++ b/shierfeng-fastapi-backend/module_business/dao/report_dao.py @@ -8,6 +8,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute from common.vo import PageModel from module_admin.entity.do.user_do import SysUser +from module_business.domain.enums import DocumentStatus from module_business.entity.do.master_do import ( BizCustomer, BizCustomerSite, @@ -234,7 +235,7 @@ class ReportDao: source_warehouse = aliased(BizWarehouse, name='source_warehouse') target_site = aliased(BizCustomerSite, name='target_site') target_customer = aliased(BizCustomer, name='target_customer') - conditions = [] + conditions = [BizShipmentOrder.status == DocumentStatus.CONFIRMED.value] warehouse_scope = cls._scope_condition(BizShipmentOrder.source_warehouse_id, warehouse_ids) site_scope = cls._scope_condition(BizShipmentOrder.target_site_id, site_ids) if warehouse_scope is not None: diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/report_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/report_vo.py index 848a8f3..82b786a 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/report_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/report_vo.py @@ -1,18 +1,40 @@ from datetime import datetime -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic.alias_generators import to_camel from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus +def _parse_report_query_date(value: str) -> datetime: + try: + parsed = datetime.strptime(value, '%Y-%m-%d') + except ValueError as exc: + raise ValueError('日期必须为有效的 YYYY-MM-DD 格式') from exc + if parsed.strftime('%Y-%m-%d') != value: + raise ValueError('日期必须为有效的 YYYY-MM-DD 格式') + return parsed + + +def _validate_report_query_date_range(value: BaseModel) -> BaseModel: + begin_value = value.begin_time + end_value = value.end_time + begin_date = _parse_report_query_date(begin_value) if begin_value else None + end_date = _parse_report_query_date(end_value) if end_value else None + if end_date and end_date.date() == datetime.max.date(): + raise ValueError('结束日期必须早于 9999-12-31') + if begin_date and end_date and begin_date > end_date: + raise ValueError('开始日期不能晚于结束日期') + return value + + class PalletInventoryReportQueryModel(BaseModel): """托盘库存报表查询模型。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) page_num: int = Field(default=1, gt=0, description='当前页码') - page_size: int = Field(default=10, gt=0, description='每页记录数') + page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') @@ -23,6 +45,8 @@ class PalletInventoryReportQueryModel(BaseModel): strain_id: int | None = Field(default=None, gt=0, description='菌种ID') batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='生产批次') + _validate_date_range = model_validator(mode='after')(_validate_report_query_date_range) + class PalletInventoryReportModel(BaseModel): """托盘库存报表行模型。""" @@ -53,7 +77,7 @@ class FillingReportQueryModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) page_num: int = Field(default=1, gt=0, description='当前页码') - page_size: int = Field(default=10, gt=0, description='每页记录数') + page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID') @@ -61,6 +85,8 @@ class FillingReportQueryModel(BaseModel): strain_id: int | None = Field(default=None, gt=0, description='菌种ID') batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='生产批次') + _validate_date_range = model_validator(mode='after')(_validate_report_query_date_range) + class FillingReportModel(BaseModel): """装填记录报表行模型。""" @@ -90,7 +116,7 @@ class ShipmentReportQueryModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) page_num: int = Field(default=1, gt=0, description='当前页码') - page_size: int = Field(default=10, gt=0, description='每页记录数') + page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') shipment_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='发货单号') @@ -99,6 +125,8 @@ class ShipmentReportQueryModel(BaseModel): pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') status: DocumentStatus | None = Field(default=None, description='单据状态') + _validate_date_range = model_validator(mode='after')(_validate_report_query_date_range) + class ShipmentReportModel(BaseModel): """发货出库报表行模型。""" @@ -126,7 +154,7 @@ class ConsumptionReportQueryModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) page_num: int = Field(default=1, gt=0, description='当前页码') - page_size: int = Field(default=10, gt=0, description='每页记录数') + page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') customer_id: int | None = Field(default=None, gt=0, description='客户ID') @@ -136,6 +164,8 @@ class ConsumptionReportQueryModel(BaseModel): strain_id: int | None = Field(default=None, gt=0, description='菌种ID') batch_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='生产批次') + _validate_date_range = model_validator(mode='after')(_validate_report_query_date_range) + class ConsumptionReportModel(BaseModel): """产线消耗报表行模型。""" @@ -169,7 +199,7 @@ class ReturnReportQueryModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) page_num: int = Field(default=1, gt=0, description='当前页码') - page_size: int = Field(default=10, gt=0, description='每页记录数') + page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') return_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回单号') @@ -178,6 +208,8 @@ class ReturnReportQueryModel(BaseModel): pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') status: ReturnStatus | None = Field(default=None, description='退回状态') + _validate_date_range = model_validator(mode='after')(_validate_report_query_date_range) + class ReturnReportModel(BaseModel): """退回仓库报表行模型。""" @@ -205,12 +237,14 @@ class PalletTraceReportQueryModel(BaseModel): model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) page_num: int = Field(default=1, gt=0, description='当前页码') - page_size: int = Field(default=10, gt=0, description='每页记录数') + page_size: int = Field(default=10, gt=0, le=100, description='每页记录数') begin_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='开始时间') end_time: str | None = Field(default=None, min_length=1, pattern=r'\S', description='结束时间') pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') event_type: PalletEventType | None = Field(default=None, description='事件类型') + _validate_date_range = model_validator(mode='after')(_validate_report_query_date_range) + class PalletTraceReportModel(BaseModel): """托盘追溯报表行模型。""" diff --git a/shierfeng-fastapi-backend/tests/module_business/test_report_service.py b/shierfeng-fastapi-backend/tests/module_business/test_report_service.py index 29c9e4e..678481f 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_report_service.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_report_service.py @@ -8,7 +8,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, patch from fastapi.routing import APIRoute -from pydantic import BaseModel +from pydantic import BaseModel, ValidationError from sqlalchemy import Select from sqlalchemy.dialects import mysql, postgresql from sqlalchemy.ext.asyncio import AsyncSession @@ -44,6 +44,15 @@ ReportDao = getattr(report_dao_module, 'ReportDao', None) ReportService = getattr(report_service_module, 'ReportService', None) report_controller = getattr(report_controller_module, 'report_controller', None) +REPORT_QUERY_MODELS = ( + PalletInventoryReportQueryModel, + FillingReportQueryModel, + ShipmentReportQueryModel, + ConsumptionReportQueryModel, + ReturnReportQueryModel, + PalletTraceReportQueryModel, +) + def make_user(user_id: int = 7) -> CurrentUserModel: return CurrentUserModel( @@ -255,6 +264,58 @@ class TestReportPublicContract(unittest.TestCase): self.assertTrue(forbidden_calls.isdisjoint(called_attributes)) +class TestReportQueryValidation(unittest.TestCase): + def test_dates_require_strict_real_calendar_values(self) -> None: + for model_class in REPORT_QUERY_MODELS: + with self.subTest(model=model_class.__name__, case='begin-only'): + model = model_class.model_validate({'beginTime': '2026-07-01'}) + self.assertEqual('2026-07-01', model.begin_time) + self.assertIsNone(model.end_time) + with self.subTest(model=model_class.__name__, case='end-only'): + model = model_class.model_validate({'endTime': '2026-07-31'}) + self.assertIsNone(model.begin_time) + self.assertEqual('2026-07-31', model.end_time) + for field_name in ('beginTime', 'endTime'): + for invalid_date in ('not-a-date', '2026-02-30', '2026-7-01', '2026-07-1'): + with ( + self.subTest( + model=model_class.__name__, + field=field_name, + value=invalid_date, + ), + self.assertRaises(ValidationError), + ): + model_class.model_validate({field_name: invalid_date}) + + def test_date_range_allows_none_and_equal_but_rejects_inverted_or_overflowing_end(self) -> None: + for model_class in REPORT_QUERY_MODELS: + with self.subTest(model=model_class.__name__, case='none'): + model = model_class.model_validate({'beginTime': None, 'endTime': None}) + self.assertIsNone(model.begin_time) + self.assertIsNone(model.end_time) + with self.subTest(model=model_class.__name__, case='equal'): + model = model_class.model_validate({'beginTime': '2026-07-02', 'endTime': '2026-07-02'}) + self.assertEqual(model.begin_time, model.end_time) + with self.subTest(model=model_class.__name__, case='max-begin'): + model = model_class.model_validate({'beginTime': '9999-12-31'}) + self.assertEqual('9999-12-31', model.begin_time) + with self.subTest(model=model_class.__name__, case='inverted'), self.assertRaises(ValidationError): + model_class.model_validate({'beginTime': '2026-07-03', 'endTime': '2026-07-02'}) + with self.subTest(model=model_class.__name__, case='max-end'), self.assertRaises(ValidationError): + model_class.model_validate({'endTime': '9999-12-31'}) + + def test_page_size_is_capped_at_100(self) -> None: + for model_class in REPORT_QUERY_MODELS: + with self.subTest(model=model_class.__name__, page_size=100): + self.assertEqual(100, model_class.model_validate({'pageSize': 100}).page_size) + for invalid_size in (101, 10**9): + with ( + self.subTest(model=model_class.__name__, page_size=invalid_size), + self.assertRaises(ValidationError), + ): + model_class.model_validate({'pageSize': invalid_size}) + + @unittest.skipUnless(ReportDao is not None, 'ReportDao 尚未实现') class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: @@ -417,6 +478,19 @@ class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase): sql, ) + async def test_shipment_query_requires_confirmed_fact_in_addition_to_optional_status(self) -> None: + statement = await self._capture_statement( + ReportDao.get_shipment_report_list, + ShipmentReportQueryModel(status=DocumentStatus.DRAFT), + warehouse_ids=None, + site_ids=None, + ) + + for sql in self._assert_compiles_for_supported_databases(statement): + self.assertIn("biz_shipment_order.status = 'draft'", sql) + self.assertIn("biz_shipment_order.status = 'confirmed'", sql) + self.assertEqual(2, sql.count('biz_shipment_order.status =')) + async def test_consumption_query_uses_usage_filling_snapshots_all_filters_and_site_scope(self) -> None: query = ConsumptionReportQueryModel( customerId=3, @@ -917,9 +991,14 @@ class TestReportControllerContract(unittest.TestCase): } for path, parameters in expected_parameters.items(): with self.subTest(path=path): - actual = {parameter['name'] for parameter in schema['paths'][path]['get']['parameters']} + operation_parameters = schema['paths'][path]['get']['parameters'] + actual = {parameter['name'] for parameter in operation_parameters} self.assertEqual(parameters, actual) self.assertNotIn('mode', actual) + page_size_parameter = next( + parameter for parameter in operation_parameters if parameter['name'] == 'pageSize' + ) + self.assertEqual(100, page_size_parameter['schema'].get('maximum')) def test_routes_have_exact_permissions_response_models_current_user_and_no_log(self) -> None: expected = {