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
@@ -0,0 +1,220 @@
from datetime import datetime
from typing import Any
from sqlalchemy import and_, false, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import DocumentStatus, PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizPallet, BizWarehouse
from module_business.entity.do.operation_do import BizInboundItem, BizInboundOrder, BizPalletInventory
class InboundSummaryDao:
"""首次入库的只读汇总:列表名称与托盘数、详情托盘编号与当前状态、页面概况。
列表与详情只汇总调用方已按数据范围取得的单据,所有关联表均受同一测试批次范围约束。
"""
@staticmethod
async def get_list_summaries(
db: AsyncSession,
inbound_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(inbound_ids))
if not ids:
return {}
creator = aliased(SysUser, name='creator')
confirmer = aliased(SysUser, name='confirmer')
name_statement = (
select(
BizInboundOrder.inbound_id,
BizWarehouse.warehouse_name,
creator.nick_name,
confirmer.nick_name,
)
.select_from(BizInboundOrder)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizInboundOrder.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.outerjoin(creator, and_(creator.user_name == BizInboundOrder.create_by, creator.del_flag == '0'))
.outerjoin(confirmer, and_(confirmer.user_id == BizInboundOrder.confirmed_by, confirmer.del_flag == '0'))
.where(
BizInboundOrder.inbound_id.in_(ids),
batch_scope_condition(BizInboundOrder.test_batch_id, scope),
)
)
count_statement = (
select(BizInboundItem.inbound_id, func.count(BizInboundItem.inbound_item_id))
.where(
BizInboundItem.inbound_id.in_(ids),
batch_scope_condition(BizInboundItem.test_batch_id, scope),
)
.group_by(BizInboundItem.inbound_id)
)
summaries: dict[int, dict[str, Any]] = {
inbound_id: {
'warehouse_name': None,
'creator_name': None,
'confirmer_name': None,
'pallet_count': 0,
}
for inbound_id in ids
}
for inbound_id, warehouse_name, creator_name, confirmer_name in (await db.execute(name_statement)).all():
summary = summaries.get(inbound_id)
if summary is None:
continue
summary.update(
warehouse_name=warehouse_name,
creator_name=creator_name,
confirmer_name=confirmer_name,
)
for inbound_id, pallet_count in (await db.execute(count_statement)).all():
summary = summaries.get(inbound_id)
if summary is not None:
summary['pallet_count'] = int(pallet_count or 0)
return summaries
@staticmethod
async def get_item_identities(
db: AsyncSession,
inbound_id: int,
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
"""为详情明细补充托盘编号与当前流转状态,只读展示,不参与任何写入。"""
statement = (
select(BizInboundItem.inbound_item_id, BizPallet.pallet_code, BizPalletInventory.flow_state)
.select_from(BizInboundItem)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizInboundItem.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizPalletInventory,
and_(
BizPalletInventory.pallet_id == BizInboundItem.pallet_id,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
),
)
.where(
BizInboundItem.inbound_id == inbound_id,
batch_scope_condition(BizInboundItem.test_batch_id, scope),
)
)
return {
inbound_item_id: {'pallet_code': pallet_code, 'flow_state': flow_state}
for inbound_item_id, pallet_code, flow_state in (await db.execute(statement)).all()
}
@staticmethod
async def get_overview(
db: AsyncSession,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
month_start: datetime,
) -> dict[str, Any]:
"""入库页概况。单据与空托盘按仓库数据范围统计;未入库托盘尚无仓库归属,按测试批次范围统计。"""
order_conditions = [batch_scope_condition(BizInboundOrder.test_batch_id, scope)]
inventory_conditions = [batch_scope_condition(BizPalletInventory.test_batch_id, scope)]
if warehouse_ids is not None:
scoped_ids = sorted(set(warehouse_ids))
order_conditions.append(BizInboundOrder.warehouse_id.in_(scoped_ids) if scoped_ids else false())
inventory_conditions.append(BizPalletInventory.warehouse_id.in_(scoped_ids) if scoped_ids else false())
item_join = and_(
BizInboundItem.inbound_id == BizInboundOrder.inbound_id,
batch_scope_condition(BizInboundItem.test_batch_id, scope),
)
draft_statement = (
select(
func.count(func.distinct(BizInboundOrder.inbound_id)),
func.count(BizInboundItem.inbound_item_id),
func.min(BizInboundOrder.create_time),
)
.select_from(BizInboundOrder)
.outerjoin(BizInboundItem, item_join)
.where(*order_conditions, BizInboundOrder.status == DocumentStatus.DRAFT.value)
)
month_statement = (
select(
func.count(func.distinct(BizInboundOrder.inbound_id)),
func.count(BizInboundItem.inbound_item_id),
)
.select_from(BizInboundOrder)
.outerjoin(BizInboundItem, item_join)
.where(
*order_conditions,
BizInboundOrder.status == DocumentStatus.CONFIRMED.value,
BizInboundOrder.confirmed_at >= month_start,
)
)
unstocked_statement = (
select(func.count(BizPalletInventory.pallet_id))
.select_from(BizPalletInventory)
.join(
BizPallet,
and_(
BizPallet.pallet_id == BizPalletInventory.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.where(
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
BizPalletInventory.flow_state == PalletFlowState.UNSTOCKED.value,
BizPallet.asset_status == '0',
)
)
empty_statement = (
select(
BizPalletInventory.warehouse_id,
BizWarehouse.warehouse_name,
func.count(BizPalletInventory.pallet_id),
)
.select_from(BizPalletInventory)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizPalletInventory.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.where(
*inventory_conditions,
BizPalletInventory.flow_state == PalletFlowState.EMPTY_IN_WAREHOUSE.value,
BizPalletInventory.warehouse_id.is_not(None),
)
.group_by(BizPalletInventory.warehouse_id, BizWarehouse.warehouse_name)
)
draft_count, draft_pallets, oldest_draft_at = (await db.execute(draft_statement)).one()
month_count, month_pallets = (await db.execute(month_statement)).one()
unstocked_count = (await db.execute(unstocked_statement)).scalar_one()
empty_rows = [
{'warehouse_id': warehouse_id, 'warehouse_name': warehouse_name, 'pallet_count': int(count or 0)}
for warehouse_id, warehouse_name, count in (await db.execute(empty_statement)).all()
]
empty_rows.sort(key=lambda row: (-row['pallet_count'], row['warehouse_id']))
return {
'draft_count': int(draft_count or 0),
'draft_pallet_count': int(draft_pallets or 0),
'oldest_draft_at': oldest_draft_at,
'month_start': month_start.date(),
'month_confirmed_count': int(month_count or 0),
'month_confirmed_pallet_count': int(month_pallets or 0),
'unstocked_pallet_count': int(unstocked_count or 0),
'empty_pallet_count': sum(row['pallet_count'] for row in empty_rows),
'empty_by_warehouse': empty_rows,
}