Feat: 后端新增基础资料与业务管理只读汇总接口

为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情
返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。

- 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转,
  托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级
  资料选项接口;Excel 导入支持 preview 只校验不写入
- 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和
  菌种构成;详情明细补充托盘号、菌种、批次
- 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary
- 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道;
  新增 /summary、/site-options
- 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、
  /batches、/batches/detail
- 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号;
  新增 /site-options、/site-board 以及 usage-fifo 的 /queue
- 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致
- 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 05:03:10 -07:00
co-authored by Claude Opus 5.5
parent 80a71b48ce
commit 3050ccdc4e
51 changed files with 6545 additions and 103 deletions
@@ -250,6 +250,8 @@ class InboundItemModel(BaseModel):
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='创建时间')
@@ -275,6 +277,44 @@ class InboundModel(BaseModel):
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):
"""托盘装填记录展示模型。"""
@@ -305,6 +345,68 @@ class FillingModel(BaseModel):
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):
"""整托发货明细模型。"""
@@ -321,6 +423,10 @@ class ShipmentItemModel(BaseModel):
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='创建时间')
@@ -350,6 +456,61 @@ class ShipmentModel(BaseModel):
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):
"""客户现场使用记录展示模型。"""
@@ -373,6 +534,20 @@ class UsageModel(BaseModel):
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):
"""托盘退回明细模型。"""
@@ -386,6 +561,8 @@ class ReturnItemModel(BaseModel):
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='创建时间')
@@ -416,6 +593,37 @@ class ReturnModel(BaseModel):
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):
"""首次入库分页查询模型。"""
@@ -440,6 +648,13 @@ class FillingPageQueryModel(BaseModel):
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='生产批次')
@@ -450,6 +665,36 @@ class FillingPageQueryModel(BaseModel):
_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):
"""整托发货分页查询模型。"""
@@ -458,9 +703,17 @@ class ShipmentPageQueryModel(BaseModel):
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='结束时间')
@@ -475,6 +728,14 @@ class UsagePageQueryModel(BaseModel):
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='物料溯源号')
@@ -494,6 +755,13 @@ class ReturnPageQueryModel(BaseModel):
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='退回状态')
@@ -547,6 +815,71 @@ class BusinessOptionModel(BaseModel):
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):
"""业务扫码托盘响应模型。"""