diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py new file mode 100644 index 0000000..2a7df5e --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/entity/vo/master_vo.py @@ -0,0 +1,391 @@ +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator +from pydantic.alias_generators import to_camel + +PositiveId = Annotated[int, Field(gt=0)] + + +def _ensure_unique_ids(value: list[int]) -> list[int]: + if len(value) != len(set(value)): + raise ValueError('ID列表不能包含重复项') + return value + + +class StrainModel(BaseModel): + """菌种主数据写入及展示模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + strain_id: int | None = Field(default=None, gt=0, description='菌种ID') + strain_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='菌种编码') + strain_name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='菌种名称') + status: Literal['0', '1'] = Field(default='0', description='状态(0正常 1停用)') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + + +class StrainPageQueryModel(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='每页记录数') + strain_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='菌种编码') + strain_name: str | None = Field( + default=None, + min_length=1, + max_length=100, + pattern=r'\S', + description='菌种名称', + ) + status: Literal['0', '1'] | None = Field(default=None, description='状态') + + +class DeleteStrainModel(BaseModel): + """删除菌种模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + strain_ids: list[PositiveId] = Field(min_length=1, description='需要删除的菌种ID') + + _validate_unique_ids = field_validator('strain_ids')(_ensure_unique_ids) + + +class CustomerModel(BaseModel): + """客户主数据写入及展示模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + customer_id: int | None = Field(default=None, gt=0, description='客户ID') + customer_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='客户编码') + customer_name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='客户名称') + status: Literal['0', '1'] = Field(default='0', description='状态(0正常 1停用)') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + + +class CustomerPageQueryModel(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='每页记录数') + customer_code: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r'\S', + description='客户编码', + ) + customer_name: str | None = Field( + default=None, + min_length=1, + max_length=100, + pattern=r'\S', + description='客户名称', + ) + status: Literal['0', '1'] | None = Field(default=None, description='状态') + + +class DeleteCustomerModel(BaseModel): + """删除客户模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + customer_ids: list[PositiveId] = Field(min_length=1, description='需要删除的客户ID') + + _validate_unique_ids = field_validator('customer_ids')(_ensure_unique_ids) + + +class SiteModel(BaseModel): + """客户现场主数据写入及展示模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + site_id: int | None = Field(default=None, gt=0, description='现场ID') + customer_id: int = Field(gt=0, description='客户ID') + site_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='现场编码') + site_name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='现场名称') + address: str | None = Field(default=None, max_length=500, description='现场地址') + status: Literal['0', '1'] = Field(default='0', description='状态(0正常 1停用)') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + + +class SitePageQueryModel(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='每页记录数') + customer_id: int | None = Field(default=None, gt=0, description='客户ID') + site_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='现场编码') + site_name: str | None = Field( + default=None, + min_length=1, + max_length=100, + pattern=r'\S', + description='现场名称', + ) + status: Literal['0', '1'] | None = Field(default=None, description='状态') + + +class DeleteSiteModel(BaseModel): + """删除客户现场模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + site_ids: list[PositiveId] = Field(min_length=1, description='需要删除的现场ID') + + _validate_unique_ids = field_validator('site_ids')(_ensure_unique_ids) + + +class LineModel(BaseModel): + """生产线主数据写入及展示模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + line_id: int | None = Field(default=None, gt=0, description='产线ID') + site_id: int = Field(gt=0, description='现场ID') + line_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='产线编码') + line_name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='产线名称') + status: Literal['0', '1'] = Field(default='0', description='状态(0正常 1停用)') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + + +class LinePageQueryModel(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='每页记录数') + site_id: int | None = Field(default=None, gt=0, description='现场ID') + line_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='产线编码') + line_name: str | None = Field( + default=None, + min_length=1, + max_length=100, + pattern=r'\S', + description='产线名称', + ) + status: Literal['0', '1'] | None = Field(default=None, description='状态') + + +class DeleteLineModel(BaseModel): + """删除生产线模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + line_ids: list[PositiveId] = Field(min_length=1, description='需要删除的产线ID') + + _validate_unique_ids = field_validator('line_ids')(_ensure_unique_ids) + + +class WarehouseModel(BaseModel): + """仓库主数据写入及展示模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID') + warehouse_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='仓库编码') + warehouse_name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='仓库名称') + address: str | None = Field(default=None, max_length=500, description='仓库地址') + status: Literal['0', '1'] = Field(default='0', description='状态(0正常 1停用)') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + + +class WarehousePageQueryModel(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='每页记录数') + warehouse_code: str | None = Field( + default=None, + min_length=1, + max_length=64, + pattern=r'\S', + description='仓库编码', + ) + warehouse_name: str | None = Field( + default=None, + min_length=1, + max_length=100, + pattern=r'\S', + description='仓库名称', + ) + status: Literal['0', '1'] | None = Field(default=None, description='状态') + + +class DeleteWarehouseModel(BaseModel): + """删除仓库模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + warehouse_ids: list[PositiveId] = Field(min_length=1, description='需要删除的仓库ID') + + _validate_unique_ids = field_validator('warehouse_ids')(_ensure_unique_ids) + + +class PalletModel(BaseModel): + """托盘资产展示模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + 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='容量包数') + 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='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + can_remove: bool | None = Field(default=None, description='是否允许删除') + + +class PalletCreateModel(BaseModel): + """新增托盘模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + capacity_packages: int = Field(strict=True, gt=0, description='容量包数') + remark: str | None = Field(default=None, max_length=500, description='备注') + + +class PalletEditModel(BaseModel): + """编辑托盘可变属性模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + pallet_id: int = Field(gt=0, description='托盘ID') + 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='备注') + + +class PalletPageQueryModel(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='每页记录数') + pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + asset_status: Literal['0', '1'] | None = Field(default=None, description='资产状态') + + +class DeletePalletModel(BaseModel): + """删除托盘模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + pallet_ids: list[PositiveId] = Field(min_length=1, description='需要删除的托盘ID') + + _validate_unique_ids = field_validator('pallet_ids')(_ensure_unique_ids) + + +class UserAssignModel(BaseModel): + """用户数据范围分配模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + user_ids: list[PositiveId] = Field(min_length=1, description='用户ID列表') + + _validate_unique_ids = field_validator('user_ids')(_ensure_unique_ids) diff --git a/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py new file mode 100644 index 0000000..9dcd53f --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/entity/vo/operation_vo.py @@ -0,0 +1,463 @@ +from datetime import datetime +from typing import Annotated, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic.alias_generators import to_camel + +from module_business.domain.enums import DocumentStatus, PalletFlowState, ReturnStatus + +PositiveId = Annotated[int, Field(gt=0)] +NonBlankAction = Annotated[str, Field(min_length=1, max_length=64, pattern=r'\S')] + + +def _ensure_unique_ids(value: list[int]) -> list[int]: + if len(value) != len(set(value)): + raise ValueError('托盘ID列表不能包含重复项') + return value + + +class InboundCreateModel(BaseModel): + """首次入库草稿新增模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + warehouse_id: int = Field(gt=0, description='仓库ID') + pallet_ids: list[PositiveId] = Field(min_length=1, description='托盘ID列表') + + _validate_unique_ids = field_validator('pallet_ids')(_ensure_unique_ids) + + +class InboundUpdateModel(BaseModel): + """首次入库草稿更新模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + inbound_id: int = Field(gt=0, description='入库单ID') + warehouse_id: int = Field(gt=0, description='仓库ID') + pallet_ids: list[PositiveId] = Field(min_length=1, description='托盘ID列表') + + _validate_unique_ids = field_validator('pallet_ids')(_ensure_unique_ids) + + +class ConfirmCommandModel(BaseModel): + """单据确认命令模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='确认幂等键') + + +class FillingCreateModel(BaseModel): + """托盘装填命令模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识') + warehouse_id: int = Field(gt=0, description='仓库ID') + strain_id: int = Field(gt=0, description='菌种ID') + 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(strict=True, gt=0, description='初始包数') + idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') + + +class ShipmentCreateModel(BaseModel): + """整托发货草稿新增模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + source_warehouse_id: int = Field(gt=0, description='来源仓库ID') + target_site_id: int = Field(gt=0, description='目标现场ID') + pallet_ids: list[PositiveId] = Field(min_length=1, description='托盘ID列表') + + _validate_unique_ids = field_validator('pallet_ids')(_ensure_unique_ids) + + +class ShipmentUpdateModel(BaseModel): + """整托发货草稿更新模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + shipment_id: int = Field(gt=0, description='发货单ID') + source_warehouse_id: int = Field(gt=0, description='来源仓库ID') + target_site_id: int = Field(gt=0, description='目标现场ID') + pallet_ids: list[PositiveId] = Field(min_length=1, description='托盘ID列表') + + _validate_unique_ids = field_validator('pallet_ids')(_ensure_unique_ids) + + +class UsageCreateModel(BaseModel): + """客户现场使用命令模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + qr_token: str = Field(min_length=1, max_length=128, pattern=r'\S', description='二维码固定标识') + line_id: int = Field(gt=0, description='产线ID') + used_packages: int = Field(strict=True, gt=0, description='本次使用包数') + idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') + + +class ReturnRequestModel(BaseModel): + """托盘退回申请命令模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + source_site_id: int = Field(gt=0, description='来源现场ID') + target_warehouse_id: int = Field(gt=0, description='目标仓库ID') + pallet_ids: list[PositiveId] = Field(min_length=1, description='托盘ID列表') + idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='申请幂等键') + + _validate_unique_ids = field_validator('pallet_ids')(_ensure_unique_ids) + + +class ReturnConfirmModel(BaseModel): + """托盘退回确认命令模型。""" + + model_config = ConfigDict( + alias_generator=to_camel, + from_attributes=True, + populate_by_name=True, + extra='forbid', + ) + + scanned_pallet_ids: list[PositiveId] = Field(min_length=1, description='已扫描托盘ID列表') + idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='确认幂等键') + + _validate_unique_ids = field_validator('scanned_pallet_ids')(_ensure_unique_ids) + + +class InboundItemModel(BaseModel): + """首次入库单托盘明细模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + inbound_item_id: int = Field(gt=0, description='入库明细ID') + inbound_id: int = Field(gt=0, description='入库单ID') + pallet_id: int = Field(gt=0, description='托盘ID') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + + +class InboundModel(BaseModel): + """首次入库单展示模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + inbound_id: int = Field(gt=0, description='入库单ID') + inbound_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='入库单号') + warehouse_id: int = Field(gt=0, description='仓库ID') + status: DocumentStatus = Field(description='单据状态') + confirm_idempotency_key: str | None = Field(default=None, max_length=64, description='确认幂等键') + confirm_request_hash: str | None = Field(default=None, max_length=64, description='确认请求摘要') + confirmed_by: int | None = Field(default=None, gt=0, description='确认人ID') + confirmed_at: datetime | None = Field(default=None, description='确认时间') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + items: list[InboundItemModel] = Field(default_factory=list, description='入库托盘明细') + + +class FillingModel(BaseModel): + """托盘装填记录展示模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + filling_id: int = Field(gt=0, description='装填记录ID') + filling_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='装填单号') + pallet_id: int = Field(gt=0, description='托盘ID') + warehouse_id: int = Field(gt=0, description='仓库ID') + strain_id: int = Field(gt=0, description='菌种ID') + strain_code_snapshot: str = Field(min_length=1, max_length=64, pattern=r'\S', description='菌种编码快照') + 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='初始包数') + 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='幂等键') + request_hash: str = Field(min_length=1, max_length=64, pattern=r'\S', description='请求摘要') + operator_id: int = Field(gt=0, description='操作人ID') + operated_at: datetime = Field(description='操作时间') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + + +class ShipmentItemModel(BaseModel): + """整托发货明细模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + shipment_item_id: int = Field(gt=0, description='发货明细ID') + 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='确认时包数快照') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + + +class ShipmentModel(BaseModel): + """整托发货单展示模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + shipment_id: int = Field(gt=0, description='发货单ID') + shipment_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='发货单号') + source_warehouse_id: int = Field(gt=0, description='来源仓库ID') + target_site_id: int = Field(gt=0, description='目标现场ID') + status: DocumentStatus = Field(description='单据状态') + confirm_idempotency_key: str | None = Field(default=None, max_length=64, description='确认幂等键') + confirm_request_hash: str | None = Field(default=None, max_length=64, description='确认请求摘要') + confirmed_by: int | None = Field(default=None, gt=0, description='确认人ID') + confirmed_at: datetime | None = Field(default=None, description='确认时间') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + items: list[ShipmentItemModel] = Field(default_factory=list, description='发货托盘明细') + + +class UsageModel(BaseModel): + """客户现场使用记录展示模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + usage_id: int = Field(gt=0, description='使用记录ID') + usage_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='使用记录号') + pallet_id: int = Field(gt=0, description='托盘ID') + 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='使用后剩余包数') + 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') + occurred_at: datetime = Field(description='发生时间') + + +class ReturnItemModel(BaseModel): + """托盘退回明细模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + return_item_id: int = Field(gt=0, description='退回明细ID') + 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='申请时剩余包数快照') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + + +class ReturnModel(BaseModel): + """托盘退回单展示模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + return_id: int = Field(gt=0, description='退回单ID') + return_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='退回单号') + source_site_id: int = Field(gt=0, description='来源现场ID') + target_warehouse_id: int = Field(gt=0, description='目标仓库ID') + status: ReturnStatus = Field(description='退回状态') + request_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='申请请求摘要') + confirm_idempotency_key: str | None = Field(default=None, max_length=64, description='确认幂等键') + confirm_request_hash: str | None = Field(default=None, max_length=64, description='确认请求摘要') + requested_by: int = Field(gt=0, description='申请人ID') + requested_at: datetime = Field(description='申请时间') + confirmed_by: int | None = Field(default=None, gt=0, description='确认人ID') + confirmed_at: datetime | None = Field(default=None, description='确认时间') + create_by: str | None = Field(default=None, max_length=64, description='创建者') + create_time: datetime | None = Field(default=None, description='创建时间') + update_by: str | None = Field(default=None, max_length=64, description='更新者') + update_time: datetime | None = Field(default=None, description='更新时间') + remark: str | None = Field(default=None, max_length=500, description='备注') + items: list[ReturnItemModel] = Field(default_factory=list, description='退回托盘明细') + + +class InboundPageQueryModel(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='每页记录数') + inbound_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='入库单号') + warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID') + status: DocumentStatus | None = Field(default=None, 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='结束时间') + + +class FillingPageQueryModel(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='每页记录数') + filling_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='装填单号') + warehouse_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='生产批次') + pallet_code: str | None = Field(default=None, min_length=1, max_length=64, 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='结束时间') + + +class ShipmentPageQueryModel(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='每页记录数') + shipment_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='发货单号') + source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID') + target_site_id: int | None = Field(default=None, gt=0, description='目标现场ID') + status: DocumentStatus | None = Field(default=None, 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='结束时间') + + +class UsagePageQueryModel(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='每页记录数') + usage_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='使用记录号') + site_id: int | None = Field(default=None, gt=0, description='现场ID') + line_id: int | None = Field(default=None, gt=0, description='产线ID') + pallet_code: str | None = Field(default=None, min_length=1, max_length=64, 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='结束时间') + + +class ReturnPageQueryModel(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='每页记录数') + return_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回单号') + source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID') + target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID') + status: ReturnStatus | None = Field(default=None, 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='结束时间') + + +class InboundScanQueryModel(BaseModel): + """首次入库扫码查询模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + warehouse_id: int = Field(gt=0, description='仓库ID') + + +class ShipmentScanQueryModel(BaseModel): + """整托发货扫码查询模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + source_warehouse_id: int = Field(gt=0, description='来源仓库ID') + target_site_id: int = Field(gt=0, description='目标现场ID') + + +class ReturnScanQueryModel(BaseModel): + """托盘退回扫码查询模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + mode: Literal['request', 'confirm'] = Field(description='扫码模式') + return_id: int | None = Field(default=None, gt=0, description='退回单ID') + + @model_validator(mode='after') + def validate_return_id(self) -> 'ReturnScanQueryModel': + if self.mode == 'confirm' and self.return_id is None: + raise ValueError('确认退回扫码时必须提供退回单ID') + if self.mode == 'request' and self.return_id is not None: + raise ValueError('申请退回扫码时不能提供退回单ID') + return self + + +class BusinessOptionModel(BaseModel): + """业务下拉选项模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + id: int = Field(gt=0, description='业务主键') + code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='业务编码') + name: str = Field(min_length=1, max_length=100, pattern=r'\S', description='业务名称') + + +class PalletScanResponseModel(BaseModel): + """业务扫码托盘响应模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + pallet_id: int = Field(gt=0, description='托盘ID') + pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + flow_state: PalletFlowState = Field(description='托盘流转状态') + warehouse_id: int | None = Field(default=None, gt=0, description='当前仓库ID') + site_id: int | None = Field(default=None, gt=0, description='当前现场ID') + current_line_id: int | None = Field(default=None, gt=0, description='当前绑定产线ID') + active_filling_id: int | None = Field(default=None, gt=0, description='当前活动装填记录ID') + strain_id: int | None = Field(default=None, gt=0, description='菌种ID') + 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='生产批次') + 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='当前剩余包数') + 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 new file mode 100644 index 0000000..ae67f7b --- /dev/null +++ b/shierfeng-fastapi-backend/module_business/entity/vo/report_vo.py @@ -0,0 +1,245 @@ +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel + +from module_business.domain.enums import DocumentStatus, PalletEventType, PalletFlowState, ReturnStatus + + +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='每页记录数') + 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='托盘编号') + flow_state: PalletFlowState | None = Field(default=None, description='托盘流转状态') + warehouse_id: int | None = Field(default=None, gt=0, description='仓库ID') + site_id: int | None = Field(default=None, gt=0, description='现场ID') + line_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='生产批次') + + +class PalletInventoryReportModel(BaseModel): + """托盘库存报表行模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + pallet_id: int = Field(gt=0, description='托盘ID') + pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + flow_state: PalletFlowState = Field(description='托盘流转状态') + warehouse_id: int | None = Field(default=None, gt=0, description='当前仓库ID') + warehouse_name: str | None = Field(default=None, max_length=100, description='当前仓库名称') + site_id: int | None = Field(default=None, gt=0, description='当前现场ID') + site_name: str | None = Field(default=None, max_length=100, description='当前现场名称') + line_id: int | None = Field(default=None, gt=0, description='当前产线ID') + line_name: str | None = Field(default=None, max_length=100, description='当前产线名称') + strain_id: int | None = Field(default=None, gt=0, description='当前菌种ID') + 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='当前剩余包数') + updated_at: datetime = Field(description='库存更新时间') + + +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='每页记录数') + 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') + pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + 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='生产批次') + + +class FillingReportModel(BaseModel): + """装填记录报表行模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + filling_id: int = Field(gt=0, description='装填记录ID') + filling_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='装填单号') + operated_at: datetime = Field(description='装填时间') + warehouse_id: int = Field(gt=0, description='仓库ID') + warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称') + pallet_id: int = Field(gt=0, description='托盘ID') + pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + strain_id: int = Field(gt=0, description='菌种ID') + strain_code_snapshot: str = Field(min_length=1, max_length=64, pattern=r'\S', description='菌种编码快照') + 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='初始包数') + operator_id: int = Field(gt=0, description='操作人ID') + operator_name: str | None = Field(default=None, max_length=100, description='操作人名称') + + +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='每页记录数') + 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='发货单号') + source_warehouse_id: int | None = Field(default=None, gt=0, description='来源仓库ID') + target_site_id: int | None = Field(default=None, gt=0, description='目标现场ID') + pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + status: DocumentStatus | None = Field(default=None, description='单据状态') + + +class ShipmentReportModel(BaseModel): + """发货出库报表行模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + shipment_id: int = Field(gt=0, description='发货单ID') + shipment_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='发货单号') + confirmed_at: datetime = Field(description='确认发货时间') + source_warehouse_id: int = Field(gt=0, description='来源仓库ID') + source_warehouse_name: str | None = Field(default=None, max_length=100, description='来源仓库名称') + target_site_id: int = Field(gt=0, description='目标现场ID') + target_site_name: str | None = Field(default=None, max_length=100, description='目标现场名称') + customer_id: int = Field(gt=0, description='客户ID') + customer_name: str | None = Field(default=None, max_length=100, description='客户名称') + 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='发货时包数快照') + + +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='每页记录数') + 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') + site_id: int | None = Field(default=None, gt=0, description='现场ID') + line_id: int | None = Field(default=None, gt=0, description='产线ID') + pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + 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='生产批次') + + +class ConsumptionReportModel(BaseModel): + """产线消耗报表行模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + usage_id: int = Field(gt=0, description='使用记录ID') + usage_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='使用记录号') + occurred_at: datetime = Field(description='使用发生时间') + customer_id: int = Field(gt=0, description='客户ID') + customer_name: str | None = Field(default=None, max_length=100, description='客户名称') + site_id: int = Field(gt=0, description='现场ID') + site_name: str | None = Field(default=None, max_length=100, description='现场名称') + line_id: int = Field(gt=0, description='产线ID') + line_name: str | None = Field(default=None, max_length=100, description='产线名称') + pallet_id: int = Field(gt=0, description='托盘ID') + pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + strain_id: int = Field(gt=0, description='菌种ID') + 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='使用后剩余包数') + operator_id: int = Field(gt=0, description='操作人ID') + operator_name: str | None = Field(default=None, max_length=100, description='操作人名称') + + +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='每页记录数') + 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='退回单号') + source_site_id: int | None = Field(default=None, gt=0, description='来源现场ID') + target_warehouse_id: int | None = Field(default=None, gt=0, description='目标仓库ID') + pallet_code: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + status: ReturnStatus | None = Field(default=None, description='退回状态') + + +class ReturnReportModel(BaseModel): + """退回仓库报表行模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + return_id: int = Field(gt=0, description='退回单ID') + return_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='退回单号') + status: ReturnStatus = Field(description='退回状态') + requested_at: datetime = Field(description='申请时间') + confirmed_at: datetime | None = Field(default=None, description='确认时间') + source_site_id: int = Field(gt=0, description='来源现场ID') + source_site_name: str | None = Field(default=None, max_length=100, description='来源现场名称') + target_warehouse_id: int = Field(gt=0, description='目标仓库ID') + target_warehouse_name: str | None = Field(default=None, max_length=100, description='目标仓库名称') + 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='剩余包数快照') + + +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='每页记录数') + 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='事件类型') + + +class PalletTraceReportModel(BaseModel): + """托盘追溯报表行模型。""" + + model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) + + event_id: int = Field(gt=0, description='事件ID') + event_seq: int = Field(gt=0, description='托盘内事件序号') + event_type: PalletEventType = Field(description='事件类型') + source_type: str = Field(min_length=1, max_length=32, pattern=r'\S', description='来源类型') + source_id: int = Field(gt=0, description='来源记录ID') + pallet_id: int = Field(gt=0, description='托盘ID') + pallet_code: str = Field(min_length=1, max_length=64, pattern=r'\S', description='托盘编号') + before_state: PalletFlowState = Field(description='变更前状态') + after_state: PalletFlowState = Field(description='变更后状态') + before_warehouse_id: int | None = Field(default=None, gt=0, description='变更前仓库ID') + before_warehouse_name: str | None = Field(default=None, max_length=100, description='变更前仓库名称') + before_site_id: int | None = Field(default=None, gt=0, description='变更前现场ID') + before_site_name: str | None = Field(default=None, max_length=100, description='变更前现场名称') + before_line_id: int | None = Field(default=None, gt=0, description='变更前产线ID') + before_line_name: str | None = Field(default=None, max_length=100, description='变更前产线名称') + after_warehouse_id: int | None = Field(default=None, gt=0, description='变更后仓库ID') + after_warehouse_name: str | None = Field(default=None, max_length=100, description='变更后仓库名称') + after_site_id: int | None = Field(default=None, gt=0, description='变更后现场ID') + 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='变更后剩余包数') + 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 new file mode 100644 index 0000000..0ad0673 --- /dev/null +++ b/shierfeng-fastapi-backend/tests/module_business/test_vo_contract.py @@ -0,0 +1,785 @@ +import unittest + +from pydantic import ValidationError +from pydantic.alias_generators import to_camel + +from module_business.entity.vo.master_vo import ( + CustomerModel, + CustomerPageQueryModel, + DeleteCustomerModel, + DeleteLineModel, + DeletePalletModel, + DeleteSiteModel, + DeleteStrainModel, + DeleteWarehouseModel, + LineModel, + LinePageQueryModel, + PalletCreateModel, + PalletEditModel, + PalletModel, + PalletPageQueryModel, + SiteModel, + SitePageQueryModel, + StrainModel, + StrainPageQueryModel, + UserAssignModel, + WarehouseModel, + WarehousePageQueryModel, +) +from module_business.entity.vo.operation_vo import ( + BusinessOptionModel, + ConfirmCommandModel, + FillingCreateModel, + FillingModel, + FillingPageQueryModel, + InboundCreateModel, + InboundItemModel, + InboundModel, + InboundPageQueryModel, + InboundScanQueryModel, + InboundUpdateModel, + PalletScanResponseModel, + ReturnConfirmModel, + ReturnItemModel, + ReturnModel, + ReturnPageQueryModel, + ReturnRequestModel, + ReturnScanQueryModel, + ShipmentCreateModel, + ShipmentItemModel, + ShipmentModel, + ShipmentPageQueryModel, + ShipmentScanQueryModel, + ShipmentUpdateModel, + UsageCreateModel, + UsageModel, + UsagePageQueryModel, +) +from module_business.entity.vo.report_vo import ( + ConsumptionReportModel, + ConsumptionReportQueryModel, + FillingReportModel, + FillingReportQueryModel, + PalletInventoryReportModel, + PalletInventoryReportQueryModel, + PalletTraceReportModel, + PalletTraceReportQueryModel, + ReturnReportModel, + ReturnReportQueryModel, + ShipmentReportModel, + ShipmentReportQueryModel, +) + +MASTER_MODEL_FIELDS = { + StrainModel: { + 'strain_id', + 'strain_code', + 'strain_name', + 'status', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + }, + StrainPageQueryModel: {'page_num', 'page_size', 'strain_code', 'strain_name', 'status'}, + DeleteStrainModel: {'strain_ids'}, + CustomerModel: { + 'customer_id', + 'customer_code', + 'customer_name', + 'status', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + }, + CustomerPageQueryModel: {'page_num', 'page_size', 'customer_code', 'customer_name', 'status'}, + DeleteCustomerModel: {'customer_ids'}, + SiteModel: { + 'site_id', + 'customer_id', + 'site_code', + 'site_name', + 'address', + 'status', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + }, + SitePageQueryModel: {'page_num', 'page_size', 'customer_id', 'site_code', 'site_name', 'status'}, + DeleteSiteModel: {'site_ids'}, + LineModel: { + 'line_id', + 'site_id', + 'line_code', + 'line_name', + 'status', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + }, + LinePageQueryModel: {'page_num', 'page_size', 'site_id', 'line_code', 'line_name', 'status'}, + DeleteLineModel: {'line_ids'}, + WarehouseModel: { + 'warehouse_id', + 'warehouse_code', + 'warehouse_name', + 'address', + 'status', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + }, + WarehousePageQueryModel: {'page_num', 'page_size', 'warehouse_code', 'warehouse_name', 'status'}, + DeleteWarehouseModel: {'warehouse_ids'}, + PalletModel: { + 'pallet_id', + 'pallet_code', + 'qr_token', + 'capacity_packages', + 'asset_status', + 'remark', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'can_remove', + }, + PalletCreateModel: {'pallet_code', 'capacity_packages', 'remark'}, + PalletEditModel: {'pallet_id', 'capacity_packages', 'asset_status', 'remark'}, + PalletPageQueryModel: {'page_num', 'page_size', 'pallet_code', 'asset_status'}, + DeletePalletModel: {'pallet_ids'}, + UserAssignModel: {'user_ids'}, +} + +OPERATION_MODEL_FIELDS = { + InboundCreateModel: {'warehouse_id', 'pallet_ids'}, + InboundUpdateModel: {'inbound_id', 'warehouse_id', 'pallet_ids'}, + ConfirmCommandModel: {'idempotency_key'}, + FillingCreateModel: { + 'qr_token', + 'warehouse_id', + 'strain_id', + 'batch_no', + 'package_spec', + 'initial_packages', + 'idempotency_key', + }, + ShipmentCreateModel: {'source_warehouse_id', 'target_site_id', 'pallet_ids'}, + ShipmentUpdateModel: {'shipment_id', 'source_warehouse_id', 'target_site_id', 'pallet_ids'}, + UsageCreateModel: {'qr_token', 'line_id', 'used_packages', 'idempotency_key'}, + ReturnRequestModel: {'source_site_id', 'target_warehouse_id', 'pallet_ids', 'idempotency_key'}, + ReturnConfirmModel: {'scanned_pallet_ids', 'idempotency_key'}, + InboundItemModel: {'inbound_item_id', 'inbound_id', 'pallet_id', 'create_by', 'create_time'}, + InboundModel: { + 'inbound_id', + 'inbound_no', + 'warehouse_id', + 'status', + 'confirm_idempotency_key', + 'confirm_request_hash', + 'confirmed_by', + 'confirmed_at', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + 'items', + }, + FillingModel: { + 'filling_id', + 'filling_no', + 'pallet_id', + 'warehouse_id', + 'strain_id', + 'strain_code_snapshot', + 'strain_name_snapshot', + 'batch_no', + 'package_spec', + 'initial_packages', + 'active_slot', + 'closed_at', + 'idempotency_key', + 'request_hash', + 'operator_id', + 'operated_at', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + }, + ShipmentItemModel: { + 'shipment_item_id', + 'shipment_id', + 'pallet_id', + 'active_filling_id', + 'package_qty', + 'create_by', + 'create_time', + }, + ShipmentModel: { + 'shipment_id', + 'shipment_no', + 'source_warehouse_id', + 'target_site_id', + 'status', + 'confirm_idempotency_key', + 'confirm_request_hash', + 'confirmed_by', + 'confirmed_at', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + 'items', + }, + UsageModel: { + 'usage_id', + 'usage_no', + 'pallet_id', + 'active_filling_id', + 'site_id', + 'line_id', + 'used_packages', + 'remaining_packages_after', + 'idempotency_key', + 'request_hash', + 'operator_id', + 'occurred_at', + }, + ReturnItemModel: { + 'return_item_id', + 'return_id', + 'pallet_id', + 'active_filling_id_snapshot', + 'remaining_packages_snapshot', + 'create_by', + 'create_time', + }, + ReturnModel: { + 'return_id', + 'return_no', + 'source_site_id', + 'target_warehouse_id', + 'status', + 'request_idempotency_key', + 'request_hash', + 'confirm_idempotency_key', + 'confirm_request_hash', + 'requested_by', + 'requested_at', + 'confirmed_by', + 'confirmed_at', + 'create_by', + 'create_time', + 'update_by', + 'update_time', + 'remark', + 'items', + }, + InboundPageQueryModel: { + 'page_num', + 'page_size', + 'inbound_no', + 'warehouse_id', + 'status', + 'begin_time', + 'end_time', + }, + FillingPageQueryModel: { + 'page_num', + 'page_size', + 'filling_no', + 'warehouse_id', + 'strain_id', + 'batch_no', + 'pallet_code', + 'begin_time', + 'end_time', + }, + ShipmentPageQueryModel: { + 'page_num', + 'page_size', + 'shipment_no', + 'source_warehouse_id', + 'target_site_id', + 'status', + 'begin_time', + 'end_time', + }, + UsagePageQueryModel: { + 'page_num', + 'page_size', + 'usage_no', + 'site_id', + 'line_id', + 'pallet_code', + 'begin_time', + 'end_time', + }, + ReturnPageQueryModel: { + 'page_num', + 'page_size', + 'return_no', + 'source_site_id', + 'target_warehouse_id', + 'status', + 'begin_time', + 'end_time', + }, + InboundScanQueryModel: {'warehouse_id'}, + ShipmentScanQueryModel: {'source_warehouse_id', 'target_site_id'}, + ReturnScanQueryModel: {'mode', 'return_id'}, + BusinessOptionModel: {'id', 'code', 'name'}, + PalletScanResponseModel: { + 'pallet_id', + 'pallet_code', + 'flow_state', + 'warehouse_id', + 'site_id', + 'current_line_id', + 'active_filling_id', + 'strain_id', + 'strain_code', + 'strain_name', + 'batch_no', + 'package_spec', + 'capacity_packages', + 'remaining_packages', + 'allowed_actions', + }, +} + +REPORT_MODEL_FIELDS = { + PalletInventoryReportQueryModel: { + 'page_num', + 'page_size', + 'begin_time', + 'end_time', + 'pallet_code', + 'flow_state', + 'warehouse_id', + 'site_id', + 'line_id', + 'strain_id', + 'batch_no', + }, + PalletInventoryReportModel: { + 'pallet_id', + 'pallet_code', + 'flow_state', + 'warehouse_id', + 'warehouse_name', + 'site_id', + 'site_name', + 'line_id', + 'line_name', + 'strain_id', + 'strain_code', + 'strain_name', + 'batch_no', + 'initial_packages', + 'remaining_packages', + 'updated_at', + }, + FillingReportQueryModel: { + 'page_num', + 'page_size', + 'begin_time', + 'end_time', + 'warehouse_id', + 'pallet_code', + 'strain_id', + 'batch_no', + }, + FillingReportModel: { + 'filling_id', + 'filling_no', + 'operated_at', + 'warehouse_id', + 'warehouse_name', + 'pallet_id', + 'pallet_code', + 'strain_id', + 'strain_code_snapshot', + 'strain_name_snapshot', + 'batch_no', + 'package_spec', + 'initial_packages', + 'operator_id', + 'operator_name', + }, + ShipmentReportQueryModel: { + 'page_num', + 'page_size', + 'begin_time', + 'end_time', + 'shipment_no', + 'source_warehouse_id', + 'target_site_id', + 'pallet_code', + 'status', + }, + ShipmentReportModel: { + 'shipment_id', + 'shipment_no', + 'confirmed_at', + 'source_warehouse_id', + 'source_warehouse_name', + 'target_site_id', + 'target_site_name', + 'customer_id', + 'customer_name', + 'pallet_id', + 'pallet_code', + 'active_filling_id', + 'package_qty', + }, + ConsumptionReportQueryModel: { + 'page_num', + 'page_size', + 'begin_time', + 'end_time', + 'customer_id', + 'site_id', + 'line_id', + 'pallet_code', + 'strain_id', + 'batch_no', + }, + ConsumptionReportModel: { + 'usage_id', + 'usage_no', + 'occurred_at', + 'customer_id', + 'customer_name', + 'site_id', + 'site_name', + 'line_id', + 'line_name', + 'pallet_id', + 'pallet_code', + 'strain_id', + 'strain_code', + 'strain_name', + 'batch_no', + 'used_packages', + 'remaining_packages_after', + 'operator_id', + 'operator_name', + }, + ReturnReportQueryModel: { + 'page_num', + 'page_size', + 'begin_time', + 'end_time', + 'return_no', + 'source_site_id', + 'target_warehouse_id', + 'pallet_code', + 'status', + }, + ReturnReportModel: { + 'return_id', + 'return_no', + 'status', + 'requested_at', + 'confirmed_at', + 'source_site_id', + 'source_site_name', + 'target_warehouse_id', + 'target_warehouse_name', + 'pallet_id', + 'pallet_code', + 'active_filling_id_snapshot', + 'remaining_packages_snapshot', + }, + PalletTraceReportQueryModel: { + 'page_num', + 'page_size', + 'begin_time', + 'end_time', + 'pallet_code', + 'event_type', + }, + PalletTraceReportModel: { + 'event_id', + 'event_seq', + 'event_type', + 'source_type', + 'source_id', + 'pallet_id', + 'pallet_code', + 'before_state', + 'after_state', + 'before_warehouse_id', + 'before_warehouse_name', + 'before_site_id', + 'before_site_name', + 'before_line_id', + 'before_line_name', + 'after_warehouse_id', + 'after_warehouse_name', + 'after_site_id', + 'after_site_name', + 'after_line_id', + 'after_line_name', + 'before_remaining_packages', + 'after_remaining_packages', + 'operator_id', + 'operator_name', + 'occurred_at', + }, +} + +WRITE_MODELS = ( + StrainModel, + DeleteStrainModel, + CustomerModel, + DeleteCustomerModel, + SiteModel, + DeleteSiteModel, + LineModel, + DeleteLineModel, + WarehouseModel, + DeleteWarehouseModel, + PalletCreateModel, + PalletEditModel, + DeletePalletModel, + UserAssignModel, + InboundCreateModel, + InboundUpdateModel, + ConfirmCommandModel, + FillingCreateModel, + ShipmentCreateModel, + ShipmentUpdateModel, + UsageCreateModel, + ReturnRequestModel, + ReturnConfirmModel, +) + +PAGE_QUERY_MODELS = ( + StrainPageQueryModel, + CustomerPageQueryModel, + SitePageQueryModel, + LinePageQueryModel, + WarehousePageQueryModel, + PalletPageQueryModel, + InboundPageQueryModel, + FillingPageQueryModel, + ShipmentPageQueryModel, + UsagePageQueryModel, + ReturnPageQueryModel, + PalletInventoryReportQueryModel, + FillingReportQueryModel, + ShipmentReportQueryModel, + ConsumptionReportQueryModel, + ReturnReportQueryModel, + PalletTraceReportQueryModel, +) + + +class BusinessVoContractTest(unittest.TestCase): + def test_all_models_have_exact_fields_and_camel_case_aliases(self) -> None: + expected_models = MASTER_MODEL_FIELDS | OPERATION_MODEL_FIELDS | REPORT_MODEL_FIELDS + + for model_class, expected_fields in expected_models.items(): + with self.subTest(model=model_class.__name__): + self.assertEqual(expected_fields, set(model_class.model_fields)) + self.assertTrue(model_class.model_config.get('from_attributes')) + self.assertTrue(model_class.model_config.get('populate_by_name')) + self.assertIs(to_camel, model_class.model_config.get('alias_generator')) + aliases = {name: field.alias for name, field in model_class.model_fields.items()} + self.assertEqual({name: to_camel(name) for name in expected_fields}, aliases) + + def test_write_models_forbid_extra_fields(self) -> None: + for model_class in WRITE_MODELS: + with self.subTest(model=model_class.__name__): + self.assertEqual('forbid', model_class.model_config.get('extra')) + + def test_page_queries_have_positive_defaults(self) -> None: + for model_class in PAGE_QUERY_MODELS: + with self.subTest(model=model_class.__name__): + model = model_class.model_validate({}) + self.assertEqual((1, 10), (model.page_num, model.page_size)) + with self.assertRaises(ValidationError): + model_class.model_validate({'pageNum': 0}) + with self.assertRaises(ValidationError): + model_class.model_validate({'pageSize': 0}) + + def test_filling_accepts_camel_case_and_positive_packages(self) -> None: + model = FillingCreateModel.model_validate( + { + 'qrToken': 'qr-12345678', + 'warehouseId': 1, + 'strainId': 2, + 'batchNo': 'B20260724', + 'packageSpec': '20kg/包', + 'initialPackages': 10, + 'idempotencyKey': 'fill-key-1', + } + ) + + self.assertEqual(10, model.initial_packages) + self.assertEqual(10, model.model_dump(by_alias=True)['initialPackages']) + + def test_usage_rejects_zero_packages(self) -> None: + with self.assertRaises(ValidationError): + UsageCreateModel.model_validate( + { + 'qrToken': 'qr-12345678', + 'lineId': 3, + 'usedPackages': 0, + 'idempotencyKey': 'usage-key-1', + } + ) + + def test_usage_rejects_non_integer_packages(self) -> None: + for used_packages in (1.5, '1', True): + with self.subTest(used_packages=used_packages), self.assertRaises(ValidationError): + UsageCreateModel.model_validate( + { + 'qrToken': 'qr-12345678', + 'lineId': 3, + 'usedPackages': used_packages, + 'idempotencyKey': 'usage-key-1', + } + ) + + def test_other_package_inputs_are_strict_positive_integers(self) -> None: + cases = ( + ( + PalletCreateModel, + {'palletCode': 'PLT-001', 'capacityPackages': 10, 'remark': None}, + 'capacityPackages', + ), + ( + FillingCreateModel, + { + 'qrToken': 'qr-12345678', + 'warehouseId': 1, + 'strainId': 2, + 'batchNo': 'B20260724', + 'packageSpec': '20kg/包', + 'initialPackages': 10, + 'idempotencyKey': 'fill-key-1', + }, + 'initialPackages', + ), + ) + + for model_class, payload, field_name in cases: + for invalid_value in (0, 1.5, '1', True): + with self.subTest(model=model_class.__name__, value=invalid_value), self.assertRaises(ValidationError): + model_class.model_validate(payload | {field_name: invalid_value}) + + def test_id_lists_reject_empty_non_positive_and_duplicate_values(self) -> None: + cases = ( + (DeleteStrainModel, {}, 'strainIds'), + (DeleteCustomerModel, {}, 'customerIds'), + (DeleteSiteModel, {}, 'siteIds'), + (DeleteLineModel, {}, 'lineIds'), + (DeleteWarehouseModel, {}, 'warehouseIds'), + (DeletePalletModel, {}, 'palletIds'), + (UserAssignModel, {}, 'userIds'), + (InboundCreateModel, {'warehouseId': 1}, 'palletIds'), + (InboundUpdateModel, {'inboundId': 1, 'warehouseId': 1}, 'palletIds'), + (ShipmentCreateModel, {'sourceWarehouseId': 1, 'targetSiteId': 2}, 'palletIds'), + ( + ShipmentUpdateModel, + {'shipmentId': 1, 'sourceWarehouseId': 1, 'targetSiteId': 2}, + 'palletIds', + ), + ( + ReturnRequestModel, + {'sourceSiteId': 1, 'targetWarehouseId': 2, 'idempotencyKey': 'return-key-1'}, + 'palletIds', + ), + (ReturnConfirmModel, {'idempotencyKey': 'return-confirm-key-1'}, 'scannedPalletIds'), + ) + + for model_class, payload, field_name in cases: + for invalid_value in ([], [0], [1, 1]): + with self.subTest(model=model_class.__name__, value=invalid_value), self.assertRaises(ValidationError): + model_class.model_validate(payload | {field_name: invalid_value}) + + def test_pallet_edit_forbids_identity_fields(self) -> None: + valid_payload = { + 'palletId': 1, + 'capacityPackages': 20, + 'assetStatus': '0', + 'remark': None, + } + + for field_name in ('palletCode', 'qrToken'): + with self.subTest(field=field_name), self.assertRaises(ValidationError): + PalletEditModel.model_validate(valid_payload | {field_name: 'immutable'}) + + def test_return_scan_mode_controls_return_id(self) -> None: + request_model = ReturnScanQueryModel.model_validate({'mode': 'request'}) + confirm_model = ReturnScanQueryModel.model_validate({'mode': 'confirm', 'returnId': 9}) + + self.assertIsNone(request_model.return_id) + self.assertEqual(9, confirm_model.return_id) + for payload in ( + {'mode': 'confirm'}, + {'mode': 'request', 'returnId': 9}, + {'mode': 'other'}, + ): + with self.subTest(payload=payload), self.assertRaises(ValidationError): + ReturnScanQueryModel.model_validate(payload) + + def test_required_business_strings_reject_whitespace(self) -> None: + with self.assertRaises(ValidationError): + ConfirmCommandModel.model_validate({'idempotencyKey': ' '}) + with self.assertRaises(ValidationError): + StrainModel.model_validate({'strainCode': '\t', 'strainName': '菌种 A', 'status': '0'}) + with self.assertRaises(ValidationError): + FillingCreateModel.model_validate( + { + 'qrToken': 'qr-12345678', + 'warehouseId': 1, + 'strainId': 2, + 'batchNo': ' ', + 'packageSpec': '20kg/包', + 'initialPackages': 10, + 'idempotencyKey': 'fill-key-1', + } + ) + + def test_business_ids_must_be_positive(self) -> None: + for model_class, payload in ( + (InboundScanQueryModel, {'warehouseId': 0}), + (ShipmentScanQueryModel, {'sourceWarehouseId': 1, 'targetSiteId': -1}), + (BusinessOptionModel, {'id': 0, 'code': 'WH-01', 'name': '一号仓'}), + ): + with self.subTest(model=model_class.__name__), self.assertRaises(ValidationError): + model_class.model_validate(payload) + + def test_non_negative_output_quantities_are_validated(self) -> None: + with self.assertRaises(ValidationError): + PalletScanResponseModel.model_validate( + { + 'palletId': 1, + 'palletCode': 'PLT-001', + 'flowState': 'AT_SITE_PARTIAL', + 'capacityPackages': 20, + 'remainingPackages': -1, + 'allowedActions': [], + } + ) + + +if __name__ == '__main__': + unittest.main()