修复:完善报表查询校验

This commit is contained in:
焦龙言
2026-07-27 02:04:45 +08:00
parent dfd720359a
commit 6b97bb9f49
3 changed files with 124 additions and 10 deletions
@@ -8,6 +8,7 @@ from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel from common.vo import PageModel
from module_admin.entity.do.user_do import SysUser from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import DocumentStatus
from module_business.entity.do.master_do import ( from module_business.entity.do.master_do import (
BizCustomer, BizCustomer,
BizCustomerSite, BizCustomerSite,
@@ -234,7 +235,7 @@ class ReportDao:
source_warehouse = aliased(BizWarehouse, name='source_warehouse') source_warehouse = aliased(BizWarehouse, name='source_warehouse')
target_site = aliased(BizCustomerSite, name='target_site') target_site = aliased(BizCustomerSite, name='target_site')
target_customer = aliased(BizCustomer, name='target_customer') 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) warehouse_scope = cls._scope_condition(BizShipmentOrder.source_warehouse_id, warehouse_ids)
site_scope = cls._scope_condition(BizShipmentOrder.target_site_id, site_ids) site_scope = cls._scope_condition(BizShipmentOrder.target_site_id, site_ids)
if warehouse_scope is not None: if warehouse_scope is not None:
@@ -1,18 +1,40 @@
from datetime import datetime 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 pydantic.alias_generators import to_camel
from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus 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): class PalletInventoryReportQueryModel(BaseModel):
"""托盘库存报表查询模型。""" """托盘库存报表查询模型。"""
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='开始时间') 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='结束时间') 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='托盘编号') 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') 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='生产批次') 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): class PalletInventoryReportModel(BaseModel):
"""托盘库存报表行模型。""" """托盘库存报表行模型。"""
@@ -53,7 +77,7 @@ class FillingReportQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='开始时间') 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='结束时间') 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') 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') 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='生产批次') 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): class FillingReportModel(BaseModel):
"""装填记录报表行模型。""" """装填记录报表行模型。"""
@@ -90,7 +116,7 @@ class ShipmentReportQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='开始时间') 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='结束时间') 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='发货单号') 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='托盘编号') pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
status: DocumentStatus | None = Field(default=None, description='单据状态') status: DocumentStatus | None = Field(default=None, description='单据状态')
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
class ShipmentReportModel(BaseModel): class ShipmentReportModel(BaseModel):
"""发货出库报表行模型。""" """发货出库报表行模型。"""
@@ -126,7 +154,7 @@ class ConsumptionReportQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='开始时间') 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='结束时间') 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') 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') 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='生产批次') 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): class ConsumptionReportModel(BaseModel):
"""产线消耗报表行模型。""" """产线消耗报表行模型。"""
@@ -169,7 +199,7 @@ class ReturnReportQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='开始时间') 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='结束时间') 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='退回单号') 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='托盘编号') pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号')
status: ReturnStatus | None = Field(default=None, description='退回状态') status: ReturnStatus | None = Field(default=None, description='退回状态')
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
class ReturnReportModel(BaseModel): class ReturnReportModel(BaseModel):
"""退回仓库报表行模型。""" """退回仓库报表行模型。"""
@@ -205,12 +237,14 @@ class PalletTraceReportQueryModel(BaseModel):
model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True)
page_num: int = Field(default=1, gt=0, description='当前页码') 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='开始时间') 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='结束时间') 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='托盘编号') 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='事件类型') event_type: PalletEventType | None = Field(default=None, description='事件类型')
_validate_date_range = model_validator(mode='after')(_validate_report_query_date_range)
class PalletTraceReportModel(BaseModel): class PalletTraceReportModel(BaseModel):
"""托盘追溯报表行模型。""" """托盘追溯报表行模型。"""
@@ -8,7 +8,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from fastapi.routing import APIRoute from fastapi.routing import APIRoute
from pydantic import BaseModel from pydantic import BaseModel, ValidationError
from sqlalchemy import Select from sqlalchemy import Select
from sqlalchemy.dialects import mysql, postgresql from sqlalchemy.dialects import mysql, postgresql
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -44,6 +44,15 @@ ReportDao = getattr(report_dao_module, 'ReportDao', None)
ReportService = getattr(report_service_module, 'ReportService', None) ReportService = getattr(report_service_module, 'ReportService', None)
report_controller = getattr(report_controller_module, 'report_controller', 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: def make_user(user_id: int = 7) -> CurrentUserModel:
return CurrentUserModel( return CurrentUserModel(
@@ -255,6 +264,58 @@ class TestReportPublicContract(unittest.TestCase):
self.assertTrue(forbidden_calls.isdisjoint(called_attributes)) 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 尚未实现') @unittest.skipUnless(ReportDao is not None, 'ReportDao 尚未实现')
class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase): class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -417,6 +478,19 @@ class TestReportDaoQueries(unittest.IsolatedAsyncioTestCase):
sql, 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: async def test_consumption_query_uses_usage_filling_snapshots_all_filters_and_site_scope(self) -> None:
query = ConsumptionReportQueryModel( query = ConsumptionReportQueryModel(
customerId=3, customerId=3,
@@ -917,9 +991,14 @@ class TestReportControllerContract(unittest.TestCase):
} }
for path, parameters in expected_parameters.items(): for path, parameters in expected_parameters.items():
with self.subTest(path=path): 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.assertEqual(parameters, actual)
self.assertNotIn('mode', 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: def test_routes_have_exact_permissions_response_models_current_user_and_no_log(self) -> None:
expected = { expected = {