From 36e60af46be12adb402afbac5067149fc5fec9af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=84=A6=E9=BE=99=E8=A8=80?= Date: Fri, 24 Jul 2026 21:29:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E6=94=B6=E7=B4=A7?= =?UTF-8?q?=E6=89=98=E7=9B=98=E4=B8=9A=E5=8A=A1=E5=8C=85=E6=95=B0=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../module_business/entity/vo/master_vo.py | 2 +- .../module_business/entity/vo/operation_vo.py | 14 +- .../module_business/entity/vo/report_vo.py | 18 +- .../tests/module_business/test_vo_contract.py | 200 ++++++++++++++++++ 4 files changed, 217 insertions(+), 17 deletions(-) diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py index 2a7df5e..4960b4e 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py @@ -309,7 +309,7 @@ class PalletModel(BaseModel): pallet_id: int = Field(gt=0, description='托盘ID') pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识') - capacity_packages: int = Field(gt=0, description='容量包数') + capacity_packages: int = Field(strict=True, gt=0, description='容量包数') asset_status: Literal['0', '1'] = Field(description='资产状态(0正常 1停用)') remark: str | None = Field(default=None, max_length=500, description='备注') create_by: str | None = Field(default=None, max_length=64, description='创建者') diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py index 9dcd53f..2e8533e 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py @@ -213,7 +213,7 @@ class FillingModel(BaseModel): strain_name_snapshot: str = Field(min_length=1, max_length=100, pattern=r'\S', description='菌种名称快照') batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次') package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格') - initial_packages: int = Field(ge=0, description='初始包数') + initial_packages: int = Field(strict=True, gt=0, description='初始包数') active_slot: Literal['ACTIVE'] | None = Field(default=None, description='活动装填槽') closed_at: datetime | None = Field(default=None, description='装填周期关闭时间') idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') @@ -236,7 +236,7 @@ class ShipmentItemModel(BaseModel): shipment_id: int = Field(gt=0, description='发货单ID') pallet_id: int = Field(gt=0, description='托盘ID') active_filling_id: int | None = Field(default=None, gt=0, description='确认时活动装填记录ID') - package_qty: int | None = Field(default=None, ge=0, description='确认时包数快照') + package_qty: int | None = Field(default=None, strict=True, gt=0, description='确认时包数快照') create_by: str | None = Field(default=None, max_length=64, description='创建者') create_time: datetime | None = Field(default=None, description='创建时间') @@ -274,8 +274,8 @@ class UsageModel(BaseModel): active_filling_id: int = Field(gt=0, description='活动装填记录ID') site_id: int = Field(gt=0, description='现场ID') line_id: int = Field(gt=0, description='产线ID') - used_packages: int = Field(ge=0, description='本次使用包数') - remaining_packages_after: int = Field(ge=0, description='使用后剩余包数') + used_packages: int = Field(strict=True, gt=0, description='本次使用包数') + remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数') idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') request_hash: str = Field(min_length=1, max_length=64, pattern=r'\S', description='请求摘要') operator_id: int = Field(gt=0, description='操作人ID') @@ -291,7 +291,7 @@ class ReturnItemModel(BaseModel): return_id: int = Field(gt=0, description='退回单ID') pallet_id: int = Field(gt=0, description='托盘ID') active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='申请时活动装填记录快照') - remaining_packages_snapshot: int = Field(ge=0, description='申请时剩余包数快照') + remaining_packages_snapshot: int = Field(strict=True, ge=0, description='申请时剩余包数快照') create_by: str | None = Field(default=None, max_length=64, description='创建者') create_time: datetime | None = Field(default=None, description='创建时间') @@ -458,6 +458,6 @@ class PalletScanResponseModel(BaseModel): strain_name: str | None = Field(default=None, max_length=100, description='菌种名称') batch_no: str | None = Field(default=None, max_length=64, description='生产批次') package_spec: str | None = Field(default=None, max_length=64, description='包装规格') - capacity_packages: int = Field(gt=0, description='容量包数') - remaining_packages: int = Field(ge=0, description='当前剩余包数') + capacity_packages: int = Field(strict=True, gt=0, description='容量包数') + remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数') allowed_actions: list[NonBlankAction] = Field(description='当前接口允许的业务动作') 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 ae67f7b..848a8f3 100644 --- a/shierfeng-fastapi-backend/module_business/entity/vo/report_vo.py +++ b/shierfeng-fastapi-backend/module_business/entity/vo/report_vo.py @@ -42,8 +42,8 @@ class PalletInventoryReportModel(BaseModel): strain_code: str | None = Field(default=None, max_length=64, description='当前菌种编码快照') strain_name: str | None = Field(default=None, max_length=100, description='当前菌种名称快照') batch_no: str | None = Field(default=None, max_length=64, description='当前生产批次') - initial_packages: int | None = Field(default=None, ge=0, description='初始包数') - remaining_packages: int = Field(ge=0, description='当前剩余包数') + initial_packages: int | None = Field(default=None, strict=True, gt=0, description='初始包数') + remaining_packages: int = Field(strict=True, ge=0, description='当前剩余包数') updated_at: datetime = Field(description='库存更新时间') @@ -79,7 +79,7 @@ class FillingReportModel(BaseModel): strain_name_snapshot: str = Field(min_length=1, max_length=100, pattern=r'\S', description='菌种名称快照') batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次') package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格') - initial_packages: int = Field(ge=0, description='初始包数') + initial_packages: int = Field(strict=True, gt=0, description='初始包数') operator_id: int = Field(gt=0, description='操作人ID') operator_name: str | None = Field(default=None, max_length=100, description='操作人名称') @@ -117,7 +117,7 @@ class ShipmentReportModel(BaseModel): pallet_id: int = Field(gt=0, description='托盘ID') pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') active_filling_id: int | None = Field(default=None, gt=0, description='发货时活动装填记录ID') - package_qty: int = Field(ge=0, description='发货时包数快照') + package_qty: int = Field(strict=True, gt=0, description='发货时包数快照') class ConsumptionReportQueryModel(BaseModel): @@ -157,8 +157,8 @@ class ConsumptionReportModel(BaseModel): strain_code: str | None = Field(default=None, max_length=64, description='菌种编码快照') strain_name: str | None = Field(default=None, max_length=100, description='菌种名称快照') batch_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='生产批次') - used_packages: int = Field(ge=0, description='本次使用包数') - remaining_packages_after: int = Field(ge=0, description='使用后剩余包数') + used_packages: int = Field(strict=True, gt=0, description='本次使用包数') + remaining_packages_after: int = Field(strict=True, ge=0, description='使用后剩余包数') operator_id: int = Field(gt=0, description='操作人ID') operator_name: str | None = Field(default=None, max_length=100, description='操作人名称') @@ -196,7 +196,7 @@ class ReturnReportModel(BaseModel): pallet_id: int = Field(gt=0, description='托盘ID') pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') active_filling_id_snapshot: int | None = Field(default=None, gt=0, description='活动装填记录快照') - remaining_packages_snapshot: int = Field(ge=0, description='剩余包数快照') + remaining_packages_snapshot: int = Field(strict=True, ge=0, description='剩余包数快照') class PalletTraceReportQueryModel(BaseModel): @@ -238,8 +238,8 @@ class PalletTraceReportModel(BaseModel): after_site_name: str | None = Field(default=None, max_length=100, description='变更后现场名称') after_line_id: int | None = Field(default=None, gt=0, description='变更后产线ID') after_line_name: str | None = Field(default=None, max_length=100, description='变更后产线名称') - before_remaining_packages: int = Field(ge=0, description='变更前剩余包数') - after_remaining_packages: int = Field(ge=0, description='变更后剩余包数') + before_remaining_packages: int = Field(strict=True, ge=0, description='变更前剩余包数') + after_remaining_packages: int = Field(strict=True, ge=0, description='变更后剩余包数') operator_id: int = Field(gt=0, description='操作人ID') operator_name: str | None = Field(default=None, max_length=100, description='操作人名称') occurred_at: datetime = Field(description='发生时间') diff --git a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py index 0ad0673..bca14b0 100644 --- a/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py +++ b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py @@ -589,6 +589,163 @@ PAGE_QUERY_MODELS = ( PalletTraceReportQueryModel, ) +STRICT_POSITIVE_PACKAGE_FIELDS = { + PalletModel: {'capacity_packages'}, + FillingModel: {'initial_packages'}, + ShipmentItemModel: {'package_qty'}, + UsageModel: {'used_packages'}, + PalletScanResponseModel: {'capacity_packages'}, + PalletInventoryReportModel: {'initial_packages'}, + FillingReportModel: {'initial_packages'}, + ShipmentReportModel: {'package_qty'}, + ConsumptionReportModel: {'used_packages'}, +} + +STRICT_NON_NEGATIVE_PACKAGE_FIELDS = { + UsageModel: {'remaining_packages_after'}, + ReturnItemModel: {'remaining_packages_snapshot'}, + PalletScanResponseModel: {'remaining_packages'}, + PalletInventoryReportModel: {'remaining_packages'}, + ConsumptionReportModel: {'remaining_packages_after'}, + ReturnReportModel: {'remaining_packages_snapshot'}, + PalletTraceReportModel: {'before_remaining_packages', 'after_remaining_packages'}, +} + +PACKAGE_MODEL_PAYLOADS = { + PalletModel: { + 'pallet_id': 1, + 'pallet_code': 'PLT-001', + 'qr_token': 'qr-12345678', + 'capacity_packages': 20, + 'asset_status': '0', + }, + FillingModel: { + 'filling_id': 1, + 'filling_no': 'F202607240001', + 'pallet_id': 1, + 'warehouse_id': 1, + 'strain_id': 1, + 'strain_code_snapshot': 'STRAIN-01', + 'strain_name_snapshot': '菌种一号', + 'batch_no': 'B20260724', + 'package_spec': '20kg/包', + 'initial_packages': 20, + 'idempotency_key': 'fill-key-1', + 'request_hash': 'fill-hash-1', + 'operator_id': 1, + 'operated_at': '2026-07-24T10:00:00', + }, + ShipmentItemModel: { + 'shipment_item_id': 1, + 'shipment_id': 1, + 'pallet_id': 1, + 'package_qty': 20, + }, + UsageModel: { + 'usage_id': 1, + 'usage_no': 'U202607240001', + 'pallet_id': 1, + 'active_filling_id': 1, + 'site_id': 1, + 'line_id': 1, + 'used_packages': 1, + 'remaining_packages_after': 19, + 'idempotency_key': 'usage-key-1', + 'request_hash': 'usage-hash-1', + 'operator_id': 1, + 'occurred_at': '2026-07-24T11:00:00', + }, + ReturnItemModel: { + 'return_item_id': 1, + 'return_id': 1, + 'pallet_id': 1, + 'remaining_packages_snapshot': 0, + }, + PalletScanResponseModel: { + 'pallet_id': 1, + 'pallet_code': 'PLT-001', + 'flow_state': 'AT_SITE_PARTIAL', + 'capacity_packages': 20, + 'remaining_packages': 10, + 'allowed_actions': [], + }, + PalletInventoryReportModel: { + 'pallet_id': 1, + 'pallet_code': 'PLT-001', + 'flow_state': 'FILLED_IN_WAREHOUSE', + 'initial_packages': 20, + 'remaining_packages': 20, + 'updated_at': '2026-07-24T10:00:00', + }, + FillingReportModel: { + 'filling_id': 1, + 'filling_no': 'F202607240001', + 'operated_at': '2026-07-24T10:00:00', + 'warehouse_id': 1, + 'pallet_id': 1, + 'pallet_code': 'PLT-001', + 'strain_id': 1, + 'strain_code_snapshot': 'STRAIN-01', + 'strain_name_snapshot': '菌种一号', + 'batch_no': 'B20260724', + 'package_spec': '20kg/包', + 'initial_packages': 20, + 'operator_id': 1, + }, + ShipmentReportModel: { + 'shipment_id': 1, + 'shipment_no': 'S202607240001', + 'confirmed_at': '2026-07-24T12:00:00', + 'source_warehouse_id': 1, + 'target_site_id': 1, + 'customer_id': 1, + 'pallet_id': 1, + 'pallet_code': 'PLT-001', + 'package_qty': 20, + }, + ConsumptionReportModel: { + 'usage_id': 1, + 'usage_no': 'U202607240001', + 'occurred_at': '2026-07-24T13:00:00', + 'customer_id': 1, + 'site_id': 1, + 'line_id': 1, + 'pallet_id': 1, + 'pallet_code': 'PLT-001', + 'strain_id': 1, + 'batch_no': 'B20260724', + 'used_packages': 1, + 'remaining_packages_after': 19, + 'operator_id': 1, + }, + ReturnReportModel: { + 'return_id': 1, + 'return_no': 'R202607240001', + 'status': 'REQUESTED', + 'requested_at': '2026-07-24T14:00:00', + 'source_site_id': 1, + 'target_warehouse_id': 1, + 'pallet_id': 1, + 'pallet_code': 'PLT-001', + 'remaining_packages_snapshot': 0, + }, + PalletTraceReportModel: { + 'event_id': 1, + 'event_seq': 1, + 'event_type': 'INBOUND_CONFIRMED', + 'source_type': 'INBOUND', + 'source_id': 1, + 'pallet_id': 1, + 'pallet_code': 'PLT-001', + 'before_state': 'UNSTOCKED', + 'after_state': 'EMPTY_IN_WAREHOUSE', + 'before_remaining_packages': 0, + 'after_remaining_packages': 0, + 'operator_id': 1, + 'occurred_at': '2026-07-24T15:00:00', + }, +} + class BusinessVoContractTest(unittest.TestCase): def test_all_models_have_exact_fields_and_camel_case_aliases(self) -> None: @@ -684,6 +841,49 @@ class BusinessVoContractTest(unittest.TestCase): with self.subTest(model=model_class.__name__, value=invalid_value), self.assertRaises(ValidationError): model_class.model_validate(payload | {field_name: invalid_value}) + def test_output_occurrence_and_capacity_fields_are_strict_positive_integers(self) -> None: + accepted_invalid_values = [] + + for model_class, field_names in STRICT_POSITIVE_PACKAGE_FIELDS.items(): + payload = PACKAGE_MODEL_PAYLOADS[model_class] + model_class.model_validate(payload) + for field_name in field_names: + for invalid_value in (0, '1', 1.5, True): + try: + model_class.model_validate(payload | {field_name: invalid_value}) + except ValidationError: + continue + accepted_invalid_values.append((model_class.__name__, field_name, repr(invalid_value))) + + self.assertEqual([], accepted_invalid_values) + self.assertIsNone( + ShipmentItemModel.model_validate( + PACKAGE_MODEL_PAYLOADS[ShipmentItemModel] | {'package_qty': None} + ).package_qty + ) + self.assertIsNone( + PalletInventoryReportModel.model_validate( + PACKAGE_MODEL_PAYLOADS[PalletInventoryReportModel] | {'initial_packages': None} + ).initial_packages + ) + + def test_output_snapshot_and_remaining_fields_are_strict_non_negative_integers(self) -> None: + accepted_invalid_values = [] + + for model_class, field_names in STRICT_NON_NEGATIVE_PACKAGE_FIELDS.items(): + payload = PACKAGE_MODEL_PAYLOADS[model_class] + for field_name in field_names: + zero_model = model_class.model_validate(payload | {field_name: 0}) + self.assertEqual(0, getattr(zero_model, field_name)) + for invalid_value in (-1, '1', 1.5, True): + try: + model_class.model_validate(payload | {field_name: invalid_value}) + except ValidationError: + continue + accepted_invalid_values.append((model_class.__name__, field_name, repr(invalid_value))) + + self.assertEqual([], accepted_invalid_values) + def test_id_lists_reject_empty_non_positive_and_duplicate_values(self) -> None: cases = ( (DeleteStrainModel, {}, 'strainIds'),