修复:完善报表查询校验
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
"""托盘追溯报表行模型。"""
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user