from datetime import date, 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 def _normalize_fifo_reason(value: str | None) -> str | None: return (value.strip() or None) if isinstance(value, str) else value def _parse_page_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_page_query_date_range(value: BaseModel) -> BaseModel: begin_value = value.begin_time end_value = value.end_time begin_date = _parse_page_query_date(begin_value) if begin_value else None end_date = _parse_page_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 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='生产批次') production_date: date = Field(description='发货来源生产日期') expiry_date: date = Field(description='有效期至') package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', description='包装规格') initial_packages: Literal[100] = Field(description='初始包数(固定 100 包)') idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') @model_validator(mode='after') def validate_material_dates(self) -> 'FillingCreateModel': if self.expiry_date < self.production_date: raise ValueError('有效期不能早于生产日期') return self 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='二维码固定标识') tunnel_id: int = Field(gt=0, description='隧道ID') line_id: int | None = Field(default=None, 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='幂等键') fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因') _normalize_reason = field_validator('fifo_skip_reason', mode='before')(_normalize_fifo_reason) class TunnelEntryCreateModel(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='二维码固定标识') tunnel_id: int = Field(gt=0, description='隧道ID') idempotency_key: str = Field(min_length=1, max_length=64, pattern=r'\S', description='幂等键') fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因') _normalize_reason = field_validator('fifo_skip_reason', mode='before')(_normalize_fifo_reason) class TunnelEntrySuccessResponseModel(BaseModel): """托盘扫码入隧成功响应模型。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) pallet_id: int = Field(gt=0, description='托盘ID') tunnel_id: int = Field(gt=0, description='隧道ID') trace_no: str = Field(min_length=1, max_length=64, pattern=r'\S', description='物料溯源号') class UsageSuccessResponseModel(BaseModel): """客户现场使用成功响应模型。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) usage_id: int = Field(gt=0, description='使用记录ID') remaining_packages: int = Field(strict=True, ge=0, description='使用后剩余包数') flow_state: PalletFlowState = Field(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') pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)') flow_state: PalletFlowState | None = Field(default=None, description='托盘当前流转状态(只读展示)') 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 InboundListItemModel(InboundModel): """首次入库单列表行:在单据字段外附带只读的名称与托盘数。""" warehouse_name: str | None = Field(default=None, max_length=100, description='入库仓库名称') creator_name: str | None = Field(default=None, max_length=30, description='创建人昵称') confirmer_name: str | None = Field(default=None, max_length=30, description='确认人昵称') pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数') class InboundWarehouseCountModel(BaseModel): """按仓库统计的托盘数。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) warehouse_id: int = Field(gt=0, description='仓库ID') warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称') pallet_count: int = Field(strict=True, ge=0, description='托盘数') class InboundOverviewModel(BaseModel): """首次入库页面概况(只读)。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) draft_count: int = Field(strict=True, ge=0, description='待确认草稿单数') draft_pallet_count: int = Field(strict=True, ge=0, description='待确认草稿托盘数') oldest_draft_at: datetime | None = Field(default=None, description='最早草稿创建时间') month_start: date = Field(description='本月统计起始日期') month_confirmed_count: int = Field(strict=True, ge=0, description='本月已确认单数') month_confirmed_pallet_count: int = Field(strict=True, ge=0, description='本月已确认入库托盘数') unstocked_pallet_count: int = Field(strict=True, ge=0, description='待首次入库托盘数(资产正常且未入库)') empty_pallet_count: int = Field(strict=True, ge=0, description='授权仓库空托盘总数') empty_by_warehouse: list[InboundWarehouseCountModel] = 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='生产批次') production_date: date | None = Field(default=None, description='生产日期;历史未登记时为空') expiry_date: date | None = Field(default=None, description='有效期至;历史未登记时为空') package_spec: str = Field(min_length=1, max_length=64, pattern=r'\S', 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='幂等键') 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 FillingListItemModel(FillingModel): """托盘装填记录列表行:在记录字段外附带只读的托盘编号、仓库与操作人名称。""" pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号') warehouse_name: str | None = Field(default=None, max_length=100, description='仓库名称') operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称') class FillingBatchModel(BaseModel): """按菌种 + 生产批次聚合的装填汇总(只读)。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) 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, description='生产批次') production_date: date | None = Field(default=None, description='生产日期(批次内最早)') expiry_date: date | None = Field(default=None, description='有效期至(批次内最早)') package_spec: str | None = Field(default=None, max_length=64, description='最近一次装填的包装规格') pallet_count: int = Field(strict=True, ge=0, description='已装填托盘数') package_sum: int = Field(strict=True, ge=0, description='已装填包数') last_operated_at: datetime | None = Field(default=None, description='最近装填时间') last_operator_name: str | None = Field(default=None, max_length=30, description='最近装填操作人昵称') class FillingBatchStateCountModel(BaseModel): """批次托盘按当前去向统计。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) state: str = Field( min_length=1, max_length=32, description='托盘当前流转状态;装填周期已结束(物料用完)时为 CONSUMED', ) pallet_count: int = Field(strict=True, ge=0, description='托盘数') class FillingBatchRecordModel(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, description='装填单号') pallet_id: int = Field(gt=0, description='托盘ID') pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号') warehouse_name: str | None = Field(default=None, max_length=100, description='装填仓库名称') operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称') operated_at: datetime = Field(description='装填时间') class FillingBatchDetailModel(FillingBatchModel): """生产批次详情:批次汇总、托盘当前去向与最近装填记录。""" distribution: list[FillingBatchStateCountModel] = Field( default_factory=list, description='托盘当前去向,托盘数从多到少' ) recent: list[FillingBatchRecordModel] = Field(default_factory=list, description='最近 10 条装填记录') 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, strict=True, gt=0, description='确认时包数快照') remaining_packages: int | None = Field( default=None, strict=True, ge=0, description='当前库存剩余包数(只读,非确认快照)', ) pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)') 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='生产批次(只读展示)') 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='确认时间') receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | None = Field(default=None, description='收货核对状态') received_by: int | None = Field(default=None, gt=0, description='收货核对人') received_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 ShipmentStrainSummaryModel(BaseModel): """发货单内菌种构成。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) strain_name: str = Field(min_length=1, max_length=100, description='菌种名称或编码') pallet_count: int = Field(strict=True, gt=0, description='托盘数') class FillingOverviewModel(BaseModel): """工厂装填页面概况(只读)。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) today_pallet_count: int = Field(strict=True, ge=0, description='今日装填托盘数') today_package_sum: int = Field(strict=True, ge=0, description='今日装填包数') today_batch_count: int = Field(strict=True, ge=0, description='今日涉及的生产批次数') month_start: date = Field(description='本月统计起始日期') month_pallet_count: int = Field(strict=True, ge=0, description='本月装填托盘数') month_batch_count: int = Field(strict=True, ge=0, description='本月涉及的生产批次数') month_top_strains: list[ShipmentStrainSummaryModel] = Field( default_factory=list, description='本月装填最多的菌种(最多 3 个)', ) empty_pallet_count: int = Field(strict=True, ge=0, description='授权仓库可装填空托盘总数') empty_by_warehouse: list[InboundWarehouseCountModel] = Field( default_factory=list, description='授权仓库空托盘按仓库分布,托盘数从多到少', ) near_expiry_days: int = Field(strict=True, gt=0, description='临期判定天数') near_expiry_pallet_count: int = Field( strict=True, ge=0, description='有效期在临期天数内(含已过期)且仍在仓的有料托盘数' ) near_expiry_batch_count: int = Field(strict=True, ge=0, description='上述托盘涉及的生产批次数') nearest_expiry_batch_no: str | None = Field(default=None, max_length=64, description='最早到期的在仓批次') nearest_expiry_date: date | None = Field(default=None, description='最早到期的在仓批次有效期') class ShipmentListItemModel(ShipmentModel): """整托发货单列表行:在单据字段外附带只读的名称与汇总。""" source_warehouse_name: str | None = Field(default=None, max_length=100, description='来源仓库名称') target_site_name: str | None = Field(default=None, max_length=100, description='目标现场名称') customer_name: str | None = Field(default=None, max_length=100, description='客户名称') creator_name: str | None = Field(default=None, max_length=30, description='创建人昵称') pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数') package_qty: int | None = Field( default=None, strict=True, ge=0, description='包数:已确认为确认快照之和,草稿为当前可发余量之和', ) strains: list[ShipmentStrainSummaryModel] = 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') tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID') trace_no: str | None = Field(default=None, max_length=64, description='物料溯源号') batch_no_snapshot: str | None = Field(default=None, max_length=64, description='生产批次快照') line_id: int | None = Field(default=None, gt=0, description='产线ID(可选)') 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') occurred_at: datetime = Field(description='发生时间') fifo_skip_reason: str | None = Field(default=None, max_length=500, description='跳过先到托盘原因') class UsageListItemModel(UsageModel): """客户现场使用记录列表行:附带只读的托盘、现场、隧道、产线与操作人名称。""" pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号') site_name: str | None = Field(default=None, max_length=100, description='现场名称') tunnel_code: str | None = Field(default=None, max_length=64, description='隧道编号') tunnel_name: str | None = Field(default=None, max_length=100, description='隧道名称') line_name: str | None = Field(default=None, max_length=100, description='产线名称') operator_name: str | None = Field(default=None, max_length=30, description='操作人昵称') skipped_pallet_code: str | None = Field( default=None, max_length=64, 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='申请时活动装填记录快照') tunnel_id_snapshot: int | None = Field(default=None, gt=0, description='申请时隧道ID快照') trace_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时物料溯源号快照') batch_no_snapshot: str | None = Field(default=None, max_length=64, description='申请时生产批次快照') remaining_packages_snapshot: int = Field(strict=True, ge=0, description='申请时剩余包数快照') pallet_code: str | None = Field(default=None, max_length=64, description='托盘编号(只读展示)') tunnel_name: str | None = Field(default=None, max_length=100, 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 ReturnListItemModel(ReturnModel): """托盘退回单列表行:在单据字段外附带只读的名称与托盘汇总。""" source_site_name: str | None = Field(default=None, max_length=100, description='来源现场名称') customer_name: str | None = Field(default=None, max_length=100, description='客户名称') target_warehouse_name: str | None = Field(default=None, max_length=100, description='目标仓库名称') requester_name: str | None = Field(default=None, max_length=30, description='申请人昵称') confirmer_name: str | None = Field(default=None, max_length=30, description='确认人昵称') pallet_count: int = Field(default=0, strict=True, ge=0, description='托盘数') package_sum: int = Field(default=0, strict=True, ge=0, description='申请时剩余包数快照之和') filled_count: int = Field(default=0, strict=True, ge=0, description='申请时仍有余量的托盘数') class ReturnOverviewModel(BaseModel): """托盘退回页面概况(只读)。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) pending_count: int = Field(strict=True, ge=0, description='待到仓核对单数') pending_pallet_count: int = Field(strict=True, ge=0, description='待到仓核对托盘数(即退回中托盘)') pending_filled_count: int = Field(strict=True, ge=0, description='待核对托盘中仍有余量的托盘数') pending_package_sum: int = Field(strict=True, ge=0, description='待核对托盘剩余包数之和') oldest_pending_at: datetime | None = Field(default=None, description='最早一张待核对单的申请时间') month_start: date = Field(description='本月统计起始日期') month_confirmed_count: int = Field(strict=True, ge=0, description='本月已确认单数') month_confirmed_pallet_count: int = Field(strict=True, ge=0, description='本月已确认托盘数') month_filled_pallet_count: int = Field(strict=True, ge=0, description='本月确认后回到有料库存的托盘数') month_package_sum: int = Field(strict=True, ge=0, description='本月确认带回的剩余包数') month_avg_confirm_hours: float | None = Field(default=None, ge=0, 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, le=100, 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='结束时间') _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) 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, le=100, description='每页记录数') filling_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='装填单号') keyword: 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='结束时间') _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) class FillingBatchPageQueryModel(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, le=100, description='每页记录数') keyword: 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') 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='装填结束日期') _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) class FillingBatchDetailQueryModel(BaseModel): """生产批次详情查询模型。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) strain_id: int = Field(gt=0, description='菌种ID') batch_no: str = Field(min_length=1, max_length=64, 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, le=100, description='每页记录数') shipment_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='发货单号') keyword: 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='单据状态') receipt_state: Literal['PENDING', 'RECEIVED', 'LEGACY'] | 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='结束时间') _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) 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, le=100, description='每页记录数') usage_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='使用记录号') keyword: str | None = Field( default=None, min_length=1, max_length=64, pattern=r'\S', description='关键字:匹配使用记录号或托盘编号', ) fifo_skipped: bool | None = Field(default=None, description='为 true 时只看跳过先到托盘的记录') site_id: int | None = Field(default=None, gt=0, description='现场ID') tunnel_id: int | None = Field(default=None, gt=0, description='隧道ID') trace_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='物料溯源号') 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='结束时间') _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) 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, le=100, description='每页记录数') return_no: str | None = Field(default=None, min_length=1, max_length=64, pattern=r'\S', description='退回单号') keyword: 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='结束时间') _validate_date_range = model_validator(mode='after')(_validate_page_query_date_range) 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 ReturnSiteOptionModel(BusinessOptionModel): """退回来源现场选项,附带所属客户名称。""" customer_name: str | None = Field(default=None, max_length=100, description='客户名称') class UsageSiteOptionModel(BusinessOptionModel): """现场使用页的现场选项,附带客户名称与启用隧道、产线数。""" customer_name: str | None = Field(default=None, max_length=100, description='客户名称') tunnel_count: int = Field(default=0, strict=True, ge=0, description='启用隧道数') line_count: int = Field(default=0, strict=True, ge=0, description='启用产线数') class UsageBoardTunnelModel(BaseModel): """隧道看板中的一条隧道(不含内部溯源号)。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) tunnel_id: int = Field(gt=0, description='隧道ID') tunnel_code: str = Field(min_length=1, max_length=64, description='隧道编号') tunnel_name: str = Field(min_length=1, max_length=100, description='隧道名称') in_use: bool = Field(description='是否有正在使用的物料批') pallet_count: int = Field(strict=True, ge=0, description='在隧道中仍有余量的托盘数') remaining_packages: int = Field(strict=True, ge=0, description='在隧道中托盘的剩余包数') started_at: datetime | None = Field(default=None, description='本轮物料批开始时间') last_used_at: datetime | None = Field(default=None, description='最近一次登记使用时间') last_line_name: str | None = Field(default=None, max_length=100, description='最近一次使用的产线名称') class UsageLinePackagesModel(BaseModel): """按产线统计的用量。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) line_id: int | None = Field(default=None, gt=0, description='产线ID;未指定产线时为空') line_name: str | None = Field(default=None, max_length=100, description='产线名称') used_packages: int = Field(strict=True, ge=0, description='使用包数') class UsageSiteBoardModel(BaseModel): """客户现场看板:库存、今日用量、隧道与先到先用概况(只读)。""" model_config = ConfigDict(alias_generator=to_camel, from_attributes=True, populate_by_name=True) site_id: int = Field(gt=0, description='现场ID') site_code: str | None = Field(default=None, max_length=64, description='现场编码') site_name: str | None = Field(default=None, max_length=100, description='现场名称') customer_name: str | None = Field(default=None, max_length=100, description='客户名称') pallet_count: int = Field(strict=True, ge=0, description='现场未使用与使用中的托盘数') package_sum: int = Field(strict=True, ge=0, description='上述托盘的剩余包数') unused_pallet_count: int = Field(strict=True, ge=0, description='现场未使用托盘数') partial_pallet_count: int = Field(strict=True, ge=0, description='现场使用中托盘数') empty_pallet_count: int = Field(strict=True, ge=0, description='现场已用完、待退回的托盘数') today_usage_count: int = Field(strict=True, ge=0, description='今日登记次数') today_used_packages: int = Field(strict=True, ge=0, description='今日使用包数') today_by_line: list[UsageLinePackagesModel] = Field(default_factory=list, description='今日用量按产线,从多到少') tunnel_count: int = Field(strict=True, ge=0, description='启用隧道数') active_tunnel_count: int = Field(strict=True, ge=0, description='正在使用的隧道数') today_fifo_skip_count: int = Field(strict=True, ge=0, description='今日跳过先到托盘的使用次数') week_fifo_skip_count: int = Field(strict=True, ge=0, description='近 7 天跳过先到托盘的使用次数') tunnels: list[UsageBoardTunnelModel] = Field(default_factory=list, description='启用隧道,按编号排序') lines: list[BusinessOptionModel] = Field(default_factory=list, 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='托盘编号') qr_token: str | None = Field(default=None, min_length=1, max_length=128, 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') tunnel_id: int | None = Field(default=None, gt=0, description='当前隧道ID') trace_no: str | None = Field(default=None, max_length=64, description='当前物料溯源号') 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='生产批次') production_date: date | None = Field(default=None, description='生产日期') expiry_date: date | None = Field(default=None, description='有效期至') package_spec: str | None = Field(default=None, max_length=64, 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='当前接口允许的业务动作')