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,997 @@
from datetime import date, datetime, timedelta
from typing import Any
from sqlalchemy import and_, case, 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, PalletEventType, PalletFlowState, ReturnStatus
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import (
BizCustomer,
BizCustomerSite,
BizProductionLine,
BizTunnel,
BizUserSite,
BizUserWarehouse,
BizWarehouse,
)
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizInboundOrder,
BizPalletEvent,
BizPalletInventory,
BizReturnItem,
BizReturnOrder,
BizShipmentItem,
BizShipmentOrder,
BizUsageRecord,
)
SITE_STATES = (
PalletFlowState.AT_SITE_UNUSED.value,
PalletFlowState.AT_SITE_PARTIAL.value,
PalletFlowState.AT_SITE_EMPTY.value,
)
FIELD_STOCK_STATES = (
PalletFlowState.IN_TRANSIT.value,
PalletFlowState.AT_SITE_UNUSED.value,
PalletFlowState.AT_SITE_PARTIAL.value,
)
EXPIRY_WARNING_DAYS = 7
RECENT_DAYS = 30
USER_PREVIEW_LIMIT = 5
RECENT_EVENT_LIMIT = 6
PARENT_OPTION_LIMIT = 1000
def _count_when(condition: Any) -> Any:
return func.coalesce(func.sum(case((condition, 1), else_=0)), 0)
def _sum_when(condition: Any, column: Any) -> Any:
return func.coalesce(func.sum(case((condition, column), else_=0)), 0)
def _active_user() -> Any:
return and_(SysUser.status == '0', SysUser.del_flag == '0')
class MasterSummaryDao:
"""基础资料列表与详情的只读汇总。
只汇总调用方已分页得到的主数据 ID;每张关联表都受同一测试批次范围约束,
结果只用于展示,不参与任何写入校验。
"""
@staticmethod
def _ids(resource_ids: list[int]) -> list[int]:
return sorted(set(resource_ids))
@classmethod
async def get_strain_summaries(
cls,
db: AsyncSession,
strain_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(strain_ids)
if not ids:
return {}
in_warehouse = BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value
in_field = BizPalletInventory.flow_state.in_(FIELD_STOCK_STATES)
stock_statement = (
select(
BizFillingRecord.strain_id,
_count_when(in_warehouse),
_sum_when(in_warehouse, BizPalletInventory.remaining_packages),
_count_when(in_field),
_sum_when(in_field, BizPalletInventory.remaining_packages),
func.min(case((in_warehouse, BizFillingRecord.expiry_date), else_=None)),
)
.select_from(BizPalletInventory)
.join(
BizFillingRecord,
and_(
BizFillingRecord.filling_id == BizPalletInventory.active_filling_id,
BizFillingRecord.pallet_id == BizPalletInventory.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
BizFillingRecord.strain_id.in_(ids),
BizPalletInventory.remaining_packages > 0,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizFillingRecord.strain_id)
)
recent_from = datetime.now() - timedelta(days=RECENT_DAYS)
filling_statement = (
select(
BizFillingRecord.strain_id,
func.max(BizFillingRecord.operated_at),
_count_when(BizFillingRecord.operated_at >= recent_from),
)
.where(
BizFillingRecord.strain_id.in_(ids),
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
)
.group_by(BizFillingRecord.strain_id)
)
summaries: dict[int, dict[str, Any]] = {strain_id: {} for strain_id in ids}
for strain_id, wh_pallets, wh_packages, field_pallets, field_packages, nearest in (
await db.execute(stock_statement)
).all():
summaries[strain_id].update(
warehouse_pallet_count=int(wh_pallets),
warehouse_package_qty=int(wh_packages),
field_pallet_count=int(field_pallets),
field_package_qty=int(field_packages),
nearest_expiry_date=nearest,
)
for strain_id, last_filled_at, recent_count in (await db.execute(filling_statement)).all():
summaries[strain_id].update(last_filled_at=last_filled_at, filling_count_30d=int(recent_count))
return summaries
@classmethod
async def _site_pallet_counts(
cls,
db: AsyncSession,
group_column: Any,
condition: Any,
scope: TestBatchScope,
) -> dict[int, dict[str, int]]:
state = BizPalletInventory.flow_state
statement = (
select(
group_column,
_count_when(state == PalletFlowState.AT_SITE_UNUSED.value),
_count_when(state == PalletFlowState.AT_SITE_PARTIAL.value),
_count_when(state == PalletFlowState.AT_SITE_EMPTY.value),
_count_when(state == PalletFlowState.IN_TRANSIT.value),
_sum_when(state.in_(SITE_STATES), BizPalletInventory.remaining_packages),
)
.select_from(BizPalletInventory)
.join(
BizCustomerSite,
and_(
BizCustomerSite.site_id == BizPalletInventory.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.where(
condition,
state.in_((*SITE_STATES, PalletFlowState.IN_TRANSIT.value)),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(group_column)
)
return {
key: {
'unused_pallet_count': int(unused),
'partial_pallet_count': int(partial),
'empty_pallet_count': int(empty),
'transit_pallet_count': int(transit),
'remaining_package_qty': int(remaining),
}
for key, unused, partial, empty, transit, remaining in (await db.execute(statement)).all()
}
@classmethod
async def _site_users(
cls,
db: AsyncSession,
site_ids: list[int],
scope: TestBatchScope,
) -> dict[int, list[dict[str, Any]]]:
if not site_ids:
return {}
statement = (
select(BizUserSite.site_id, SysUser.user_id, SysUser.user_name, SysUser.nick_name)
.select_from(BizUserSite)
.join(
BizCustomerSite,
and_(
BizCustomerSite.site_id == BizUserSite.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.join(SysUser, and_(SysUser.user_id == BizUserSite.user_id, _active_user()))
.where(BizUserSite.site_id.in_(site_ids))
.order_by(BizUserSite.site_id, SysUser.user_id)
)
users: dict[int, list[dict[str, Any]]] = {}
for site_id, user_id, user_name, nick_name in (await db.execute(statement)).all():
users.setdefault(site_id, []).append({'user_id': user_id, 'user_name': user_name, 'nick_name': nick_name})
return users
@classmethod
async def _count_by(
cls,
db: AsyncSession,
group_column: Any,
count_column: Any,
*conditions: Any,
join: tuple[Any, Any] | None = None,
) -> dict[int, int]:
statement = select(group_column, func.count(count_column))
if join is not None:
statement = statement.select_from(count_column.class_).join(*join)
statement = statement.where(*conditions).group_by(group_column)
return {key: int(count) for key, count in (await db.execute(statement)).all()}
@classmethod
async def get_customer_summaries(
cls,
db: AsyncSession,
customer_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(customer_ids)
if not ids:
return {}
site_scope = batch_scope_condition(BizCustomerSite.test_batch_id, scope)
site_join = (
BizCustomerSite,
and_(BizCustomerSite.site_id == BizProductionLine.site_id, site_scope),
)
site_counts = await cls._count_by(
db,
BizCustomerSite.customer_id,
BizCustomerSite.site_id,
BizCustomerSite.customer_id.in_(ids),
site_scope,
)
line_counts = await cls._count_by(
db,
BizCustomerSite.customer_id,
BizProductionLine.line_id,
BizCustomerSite.customer_id.in_(ids),
batch_scope_condition(BizProductionLine.test_batch_id, scope),
join=site_join,
)
tunnel_counts = await cls._count_by(
db,
BizCustomerSite.customer_id,
BizTunnel.tunnel_id,
BizCustomerSite.customer_id.in_(ids),
batch_scope_condition(BizTunnel.test_batch_id, scope),
join=(BizCustomerSite, and_(BizCustomerSite.site_id == BizTunnel.site_id, site_scope)),
)
pallet_counts = await cls._site_pallet_counts(
db, BizCustomerSite.customer_id, BizCustomerSite.customer_id.in_(ids), scope
)
user_statement = (
select(BizCustomerSite.customer_id, func.count(func.distinct(BizUserSite.user_id)))
.select_from(BizUserSite)
.join(BizCustomerSite, and_(BizCustomerSite.site_id == BizUserSite.site_id, site_scope))
.join(SysUser, and_(SysUser.user_id == BizUserSite.user_id, _active_user()))
.where(BizCustomerSite.customer_id.in_(ids))
.group_by(BizCustomerSite.customer_id)
)
user_counts = {key: int(count) for key, count in (await db.execute(user_statement)).all()}
summaries: dict[int, dict[str, Any]] = {}
for customer_id in ids:
pallets = pallet_counts.get(customer_id, {})
summaries[customer_id] = {
'site_count': site_counts.get(customer_id, 0),
'line_count': line_counts.get(customer_id, 0),
'tunnel_count': tunnel_counts.get(customer_id, 0),
'site_pallet_count': pallets.get('unused_pallet_count', 0)
+ pallets.get('partial_pallet_count', 0)
+ pallets.get('empty_pallet_count', 0),
'transit_pallet_count': pallets.get('transit_pallet_count', 0),
'user_count': user_counts.get(customer_id, 0),
}
return summaries
@classmethod
async def get_customer_site_tree(
cls,
db: AsyncSession,
customer_id: int,
scope: TestBatchScope,
) -> list[dict[str, Any]]:
site_rows = (
await db.execute(
select(
BizCustomerSite.site_id,
BizCustomerSite.site_code,
BizCustomerSite.site_name,
BizCustomerSite.address,
BizCustomerSite.status,
)
.where(
BizCustomerSite.customer_id == customer_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
)
.order_by(BizCustomerSite.site_code, BizCustomerSite.site_id)
)
).all()
sites = {
row.site_id: {
'site_id': row.site_id,
'site_code': row.site_code,
'site_name': row.site_name,
'address': row.address,
'status': row.status,
'lines': [],
'tunnels': [],
'users': [],
}
for row in site_rows
}
if not sites:
return []
site_ids = list(sites)
line_pallets = (
select(
BizPalletInventory.current_line_id,
func.count(BizPalletInventory.pallet_id).label('pallet_count'),
)
.where(
BizPalletInventory.site_id.in_(site_ids),
BizPalletInventory.current_line_id.is_not(None),
BizPalletInventory.flow_state.in_(SITE_STATES),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.current_line_id)
.subquery()
)
line_rows = (
await db.execute(
select(
BizProductionLine.site_id,
BizProductionLine.line_id,
BizProductionLine.line_code,
BizProductionLine.line_name,
BizProductionLine.status,
func.coalesce(line_pallets.c.pallet_count, 0),
)
.outerjoin(line_pallets, line_pallets.c.current_line_id == BizProductionLine.line_id)
.where(
BizProductionLine.site_id.in_(site_ids),
batch_scope_condition(BizProductionLine.test_batch_id, scope),
)
.order_by(BizProductionLine.line_code, BizProductionLine.line_id)
)
).all()
for site_id, line_id, line_code, line_name, status, pallet_count in line_rows:
sites[site_id]['lines'].append(
{
'line_id': line_id,
'line_code': line_code,
'line_name': line_name,
'status': status,
'active_pallet_count': int(pallet_count),
}
)
tunnel_pallets = (
select(
BizPalletInventory.tunnel_id,
func.count(BizPalletInventory.pallet_id).label('pallet_count'),
)
.where(
BizPalletInventory.site_id.in_(site_ids),
BizPalletInventory.tunnel_id.is_not(None),
BizPalletInventory.flow_state.in_(SITE_STATES),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.tunnel_id)
.subquery()
)
tunnel_rows = (
await db.execute(
select(
BizTunnel.site_id,
BizTunnel.tunnel_id,
BizTunnel.tunnel_code,
BizTunnel.tunnel_name,
BizTunnel.status,
BizTunnel.current_trace_no,
BizTunnel.current_trace_started_at,
func.coalesce(tunnel_pallets.c.pallet_count, 0),
)
.outerjoin(tunnel_pallets, tunnel_pallets.c.tunnel_id == BizTunnel.tunnel_id)
.where(
BizTunnel.site_id.in_(site_ids),
batch_scope_condition(BizTunnel.test_batch_id, scope),
)
.order_by(BizTunnel.tunnel_code, BizTunnel.tunnel_id)
)
).all()
for site_id, tunnel_id, tunnel_code, tunnel_name, status, trace_no, trace_started_at, pallet_count in (
tunnel_rows
):
sites[site_id]['tunnels'].append(
{
'tunnel_id': tunnel_id,
'tunnel_code': tunnel_code,
'tunnel_name': tunnel_name,
'status': status,
'current_trace_no': trace_no,
'current_trace_started_at': trace_started_at,
'active_pallet_count': int(pallet_count),
}
)
pallet_counts = await cls._site_pallet_counts(
db, BizPalletInventory.site_id, BizPalletInventory.site_id.in_(site_ids), scope
)
for site_id, counts in pallet_counts.items():
sites[site_id].update(counts)
for site_id, users in (await cls._site_users(db, site_ids, scope)).items():
sites[site_id]['users'] = users
return list(sites.values())
@classmethod
async def get_site_summaries(
cls,
db: AsyncSession,
site_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(site_ids)
if not ids:
return {}
name_rows = (
await db.execute(
select(BizCustomerSite.site_id, BizCustomer.customer_code, BizCustomer.customer_name)
.select_from(BizCustomerSite)
.join(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.where(BizCustomerSite.site_id.in_(ids), batch_scope_condition(BizCustomerSite.test_batch_id, scope))
)
).all()
line_counts = await cls._count_by(
db,
BizProductionLine.site_id,
BizProductionLine.line_id,
BizProductionLine.site_id.in_(ids),
batch_scope_condition(BizProductionLine.test_batch_id, scope),
)
tunnel_counts = await cls._count_by(
db,
BizTunnel.site_id,
BizTunnel.tunnel_id,
BizTunnel.site_id.in_(ids),
batch_scope_condition(BizTunnel.test_batch_id, scope),
)
pallet_counts = await cls._site_pallet_counts(
db, BizPalletInventory.site_id, BizPalletInventory.site_id.in_(ids), scope
)
users = await cls._site_users(db, ids, scope)
summaries: dict[int, dict[str, Any]] = {
site_id: {
'line_count': line_counts.get(site_id, 0),
'tunnel_count': tunnel_counts.get(site_id, 0),
**pallet_counts.get(site_id, {}),
'user_count': len(users.get(site_id, [])),
'users': users.get(site_id, [])[:USER_PREVIEW_LIMIT],
}
for site_id in ids
}
for site_id, customer_code, customer_name in name_rows:
summaries[site_id].update(customer_code=customer_code, customer_name=customer_name)
return summaries
@classmethod
async def _child_site_names(
cls,
db: AsyncSession,
model: Any,
id_column: Any,
ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
statement = (
select(id_column, BizCustomerSite.site_code, BizCustomerSite.site_name, BizCustomer.customer_name)
.select_from(model)
.join(
BizCustomerSite,
and_(
BizCustomerSite.site_id == model.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.outerjoin(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.where(id_column.in_(ids), batch_scope_condition(model.test_batch_id, scope))
)
return {
resource_id: {'site_code': site_code, 'site_name': site_name, 'customer_name': customer_name}
for resource_id, site_code, site_name, customer_name in (await db.execute(statement)).all()
}
@classmethod
async def get_line_summaries(
cls,
db: AsyncSession,
line_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(line_ids)
if not ids:
return {}
names = await cls._child_site_names(db, BizProductionLine, BizProductionLine.line_id, ids, scope)
pallet_counts = await cls._count_by(
db,
BizPalletInventory.current_line_id,
BizPalletInventory.pallet_id,
BizPalletInventory.current_line_id.in_(ids),
BizPalletInventory.flow_state.in_(SITE_STATES),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
recent_from = datetime.now() - timedelta(days=RECENT_DAYS)
recent = BizUsageRecord.occurred_at >= recent_from
usage_rows = (
await db.execute(
select(
BizUsageRecord.line_id,
_count_when(recent),
_sum_when(recent, BizUsageRecord.used_packages),
func.max(BizUsageRecord.occurred_at),
)
.where(BizUsageRecord.line_id.in_(ids), batch_scope_condition(BizUsageRecord.test_batch_id, scope))
.group_by(BizUsageRecord.line_id)
)
).all()
summaries = {
line_id: {**names.get(line_id, {}), 'active_pallet_count': pallet_counts.get(line_id, 0)} for line_id in ids
}
for line_id, usage_count, used_packages, last_used_at in usage_rows:
summaries[line_id].update(
usage_count_30d=int(usage_count),
used_package_qty_30d=int(used_packages),
last_used_at=last_used_at,
)
return summaries
@classmethod
async def get_tunnel_summaries(
cls,
db: AsyncSession,
tunnel_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(tunnel_ids)
if not ids:
return {}
names = await cls._child_site_names(db, BizTunnel, BizTunnel.tunnel_id, ids, scope)
pallet_rows = (
await db.execute(
select(
BizPalletInventory.tunnel_id,
func.count(BizPalletInventory.pallet_id),
func.coalesce(func.sum(BizPalletInventory.remaining_packages), 0),
)
.where(
BizPalletInventory.tunnel_id.in_(ids),
BizPalletInventory.flow_state.in_(SITE_STATES),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.tunnel_id)
)
).all()
recent_from = datetime.now() - timedelta(days=RECENT_DAYS)
usage_rows = (
await db.execute(
select(
BizUsageRecord.tunnel_id,
_count_when(BizUsageRecord.occurred_at >= recent_from),
func.max(BizUsageRecord.occurred_at),
)
.where(
BizUsageRecord.tunnel_id.in_(ids),
batch_scope_condition(BizUsageRecord.test_batch_id, scope),
)
.group_by(BizUsageRecord.tunnel_id)
)
).all()
summaries: dict[int, dict[str, Any]] = {tunnel_id: dict(names.get(tunnel_id, {})) for tunnel_id in ids}
for tunnel_id, pallet_count, package_qty in pallet_rows:
summaries[tunnel_id].update(active_pallet_count=int(pallet_count), active_package_qty=int(package_qty))
for tunnel_id, usage_count, last_used_at in usage_rows:
summaries[tunnel_id].update(usage_count_30d=int(usage_count), last_used_at=last_used_at)
return summaries
@classmethod
async def get_warehouse_summaries(
cls,
db: AsyncSession,
warehouse_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(warehouse_ids)
if not ids:
return {}
today_start = datetime.combine(date.today(), datetime.min.time())
expiry_limit = date.today() + timedelta(days=EXPIRY_WARNING_DAYS)
filled = BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value
stock_rows = (
await db.execute(
select(
BizPalletInventory.warehouse_id,
_count_when(filled),
_sum_when(filled, BizPalletInventory.remaining_packages),
_count_when(BizPalletInventory.flow_state == PalletFlowState.EMPTY_IN_WAREHOUSE.value),
_count_when(and_(filled, BizFillingRecord.expiry_date <= expiry_limit)),
)
.select_from(BizPalletInventory)
.outerjoin(
BizFillingRecord,
and_(
BizFillingRecord.filling_id == BizPalletInventory.active_filling_id,
BizFillingRecord.pallet_id == BizPalletInventory.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
BizPalletInventory.warehouse_id.in_(ids),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.warehouse_id)
)
).all()
filling_counts = await cls._count_by(
db,
BizFillingRecord.warehouse_id,
BizFillingRecord.filling_id,
BizFillingRecord.warehouse_id.in_(ids),
BizFillingRecord.operated_at >= today_start,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
)
shipment_counts = await cls._count_by(
db,
BizShipmentOrder.source_warehouse_id,
BizShipmentItem.shipment_item_id,
BizShipmentOrder.source_warehouse_id.in_(ids),
BizShipmentOrder.status == DocumentStatus.CONFIRMED.value,
BizShipmentOrder.confirmed_at >= today_start,
batch_scope_condition(BizShipmentItem.test_batch_id, scope),
join=(
BizShipmentOrder,
and_(
BizShipmentOrder.shipment_id == BizShipmentItem.shipment_id,
batch_scope_condition(BizShipmentOrder.test_batch_id, scope),
),
),
)
inbound_drafts = await cls._count_by(
db,
BizInboundOrder.warehouse_id,
BizInboundOrder.inbound_id,
BizInboundOrder.warehouse_id.in_(ids),
BizInboundOrder.status == DocumentStatus.DRAFT.value,
batch_scope_condition(BizInboundOrder.test_batch_id, scope),
)
shipment_drafts = await cls._count_by(
db,
BizShipmentOrder.source_warehouse_id,
BizShipmentOrder.shipment_id,
BizShipmentOrder.source_warehouse_id.in_(ids),
BizShipmentOrder.status == DocumentStatus.DRAFT.value,
batch_scope_condition(BizShipmentOrder.test_batch_id, scope),
)
incoming_returns = await cls._count_by(
db,
BizReturnOrder.target_warehouse_id,
BizReturnOrder.return_id,
BizReturnOrder.target_warehouse_id.in_(ids),
BizReturnOrder.status == ReturnStatus.REQUESTED.value,
batch_scope_condition(BizReturnOrder.test_batch_id, scope),
)
user_rows = (
await db.execute(
select(BizUserWarehouse.warehouse_id, SysUser.user_id, SysUser.user_name, SysUser.nick_name)
.select_from(BizUserWarehouse)
.join(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizUserWarehouse.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.join(SysUser, and_(SysUser.user_id == BizUserWarehouse.user_id, _active_user()))
.where(BizUserWarehouse.warehouse_id.in_(ids))
.order_by(BizUserWarehouse.warehouse_id, SysUser.user_id)
)
).all()
users: dict[int, list[dict[str, Any]]] = {}
for warehouse_id, user_id, user_name, nick_name in user_rows:
users.setdefault(warehouse_id, []).append(
{'user_id': user_id, 'user_name': user_name, 'nick_name': nick_name}
)
summaries: dict[int, dict[str, Any]] = {
warehouse_id: {
'today_filling_count': filling_counts.get(warehouse_id, 0),
'today_shipment_pallet_count': shipment_counts.get(warehouse_id, 0),
'draft_count': inbound_drafts.get(warehouse_id, 0) + shipment_drafts.get(warehouse_id, 0),
'incoming_return_count': incoming_returns.get(warehouse_id, 0),
'user_count': len(users.get(warehouse_id, [])),
'users': users.get(warehouse_id, [])[:USER_PREVIEW_LIMIT],
}
for warehouse_id in ids
}
for warehouse_id, filled_count, package_qty, empty_count, expiring_count in stock_rows:
summaries[warehouse_id].update(
filled_pallet_count=int(filled_count),
filled_package_qty=int(package_qty),
empty_pallet_count=int(empty_count),
expiring_pallet_count=int(expiring_count),
)
return summaries
@classmethod
async def get_pallet_summaries(
cls,
db: AsyncSession,
pallet_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = cls._ids(pallet_ids)
if not ids:
return {}
state_rows = (
await db.execute(
select(
BizPalletInventory.pallet_id,
BizPalletInventory.flow_state,
BizPalletInventory.remaining_packages,
BizWarehouse.warehouse_name,
BizCustomer.customer_name,
BizCustomerSite.site_name,
BizProductionLine.line_name,
BizTunnel.tunnel_name,
BizFillingRecord.strain_name_snapshot,
BizFillingRecord.batch_no,
BizFillingRecord.expiry_date,
)
.select_from(BizPalletInventory)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizPalletInventory.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.outerjoin(
BizCustomerSite,
and_(
BizCustomerSite.site_id == BizPalletInventory.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.outerjoin(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.outerjoin(
BizProductionLine,
and_(
BizProductionLine.line_id == BizPalletInventory.current_line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
),
)
.outerjoin(
BizTunnel,
and_(
BizTunnel.tunnel_id == BizPalletInventory.tunnel_id,
batch_scope_condition(BizTunnel.test_batch_id, scope),
),
)
.outerjoin(
BizFillingRecord,
and_(
BizFillingRecord.filling_id == BizPalletInventory.active_filling_id,
BizFillingRecord.pallet_id == BizPalletInventory.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
BizPalletInventory.pallet_id.in_(ids),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
)
).all()
shipped = BizPalletEvent.event_type == PalletEventType.SHIPMENT_CONFIRMED.value
event_rows = (
await db.execute(
select(
BizPalletEvent.pallet_id,
_count_when(shipped),
func.max(case((shipped, BizPalletEvent.occurred_at), else_=None)),
func.max(BizPalletEvent.occurred_at),
)
.where(BizPalletEvent.pallet_id.in_(ids), batch_scope_condition(BizPalletEvent.test_batch_id, scope))
.group_by(BizPalletEvent.pallet_id)
)
).all()
return_rows = (
await db.execute(
select(BizReturnItem.pallet_id, BizWarehouse.warehouse_name)
.select_from(BizReturnItem)
.join(
BizReturnOrder,
and_(
BizReturnOrder.return_id == BizReturnItem.return_id,
BizReturnOrder.status == ReturnStatus.REQUESTED.value,
batch_scope_condition(BizReturnOrder.test_batch_id, scope),
),
)
.join(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizReturnOrder.target_warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.where(BizReturnItem.pallet_id.in_(ids), batch_scope_condition(BizReturnItem.test_batch_id, scope))
)
).all()
summaries: dict[int, dict[str, Any]] = {pallet_id: {} for pallet_id in ids}
for row in state_rows:
summaries[row[0]].update(
flow_state=row[1],
remaining_packages=row[2] or 0,
warehouse_name=row[3],
customer_name=row[4],
site_name=row[5],
line_name=row[6],
tunnel_name=row[7],
strain_name=row[8],
batch_no=row[9],
expiry_date=row[10],
)
for pallet_id, cycle_count, last_shipped_at, last_event_at in event_rows:
summaries[pallet_id].update(
cycle_count=int(cycle_count),
last_shipped_at=last_shipped_at,
last_event_at=last_event_at,
)
for pallet_id, warehouse_name in return_rows:
summaries[pallet_id]['return_warehouse_name'] = warehouse_name
return summaries
@classmethod
async def get_pallet_recent_events(
cls,
db: AsyncSession,
pallet_id: int,
scope: TestBatchScope,
) -> list[dict[str, Any]]:
warehouse = aliased(BizWarehouse, name='event_warehouse')
site = aliased(BizCustomerSite, name='event_site')
customer = aliased(BizCustomer, name='event_customer')
line = aliased(BizProductionLine, name='event_line')
tunnel = aliased(BizTunnel, name='event_tunnel')
operator = aliased(SysUser, name='event_operator')
statement = (
select(
BizPalletEvent.event_id,
BizPalletEvent.event_type,
BizPalletEvent.occurred_at,
BizPalletEvent.after_state,
BizPalletEvent.before_remaining_packages,
BizPalletEvent.after_remaining_packages,
BizPalletEvent.trace_no,
warehouse.warehouse_name,
customer.customer_name,
site.site_name,
line.line_name,
tunnel.tunnel_name,
operator.nick_name,
)
.select_from(BizPalletEvent)
.outerjoin(
warehouse,
and_(
warehouse.warehouse_id
== func.coalesce(BizPalletEvent.after_warehouse_id, BizPalletEvent.before_warehouse_id),
batch_scope_condition(warehouse.test_batch_id, scope),
),
)
.outerjoin(
site,
and_(
site.site_id == func.coalesce(BizPalletEvent.after_site_id, BizPalletEvent.before_site_id),
batch_scope_condition(site.test_batch_id, scope),
),
)
.outerjoin(
customer,
and_(customer.customer_id == site.customer_id, batch_scope_condition(customer.test_batch_id, scope)),
)
.outerjoin(
line,
and_(line.line_id == BizPalletEvent.after_line_id, batch_scope_condition(line.test_batch_id, scope)),
)
.outerjoin(
tunnel,
and_(
tunnel.tunnel_id == BizPalletEvent.after_tunnel_id,
batch_scope_condition(tunnel.test_batch_id, scope),
),
)
.outerjoin(operator, operator.user_id == BizPalletEvent.operator_id)
.where(BizPalletEvent.pallet_id == pallet_id, batch_scope_condition(BizPalletEvent.test_batch_id, scope))
.order_by(BizPalletEvent.event_seq.desc())
.limit(RECENT_EVENT_LIMIT)
)
keys = (
'event_id',
'event_type',
'occurred_at',
'after_state',
'before_remaining_packages',
'after_remaining_packages',
'trace_no',
'warehouse_name',
'customer_name',
'site_name',
'line_name',
'tunnel_name',
'operator_name',
)
return [dict(zip(keys, row, strict=True)) for row in (await db.execute(statement)).all()]
@classmethod
async def list_customer_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[dict[str, Any]]:
"""新增或修改现场时可选的客户:当前数据范围内状态正常的客户。"""
rows = (
await db.execute(
select(BizCustomer.customer_id, BizCustomer.customer_code, BizCustomer.customer_name)
.where(BizCustomer.status == '0', batch_scope_condition(BizCustomer.test_batch_id, scope))
.order_by(BizCustomer.customer_code, BizCustomer.customer_id)
.limit(PARENT_OPTION_LIMIT)
)
).all()
return [{'id': row[0], 'code': row[1], 'name': row[2]} for row in rows]
@classmethod
async def list_site_options(cls, db: AsyncSession, scope: TestBatchScope) -> list[dict[str, Any]]:
"""新增或修改产线、隧道时可选的现场:状态正常且所属客户也正常的现场。"""
rows = (
await db.execute(
select(
BizCustomerSite.site_id,
BizCustomerSite.site_code,
BizCustomerSite.site_name,
BizCustomer.customer_name,
)
.select_from(BizCustomerSite)
.join(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
BizCustomer.status == '0',
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.where(BizCustomerSite.status == '0', batch_scope_condition(BizCustomerSite.test_batch_id, scope))
.order_by(BizCustomer.customer_code, BizCustomerSite.site_code, BizCustomerSite.site_id)
.limit(PARENT_OPTION_LIMIT)
)
).all()
return [{'id': row[0], 'code': row[1], 'name': row[2], 'parent_name': row[3]} for row in rows]