Files
ShiErFeng/shierfeng-fastapi-backend/module_business/dao/usage_summary_dao.py
T
yujiangandClaude Opus 5.5 3050ccdc4e 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>
2026-10-18 05:03:10 -07:00

359 lines
15 KiB
Python

from datetime import datetime
from typing import Any
from sqlalchemy import and_, case, false, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_admin.entity.do.user_do import SysUser
from module_business.domain.enums import PalletFlowState
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizProductionLine, BizTunnel
from module_business.entity.do.operation_do import BizPalletInventory, BizUsageRecord
_IN_USE_STATES = (PalletFlowState.AT_SITE_UNUSED.value, PalletFlowState.AT_SITE_PARTIAL.value)
class UsageSummaryDao:
"""客户现场使用的只读汇总:列表名称、现场选项与现场看板。
不返回隧道内部溯源号;所有关联表均受同一测试批次范围约束。
"""
@staticmethod
def _skipped_pallet_code(snapshot: Any) -> str | None:
# 跳过先到托盘时,写入路径保存了当时的先到托盘快照
if isinstance(snapshot, dict):
code = snapshot.get('palletCode')
return code if isinstance(code, str) and code.strip() else None
return None
@classmethod
async def get_list_summaries(
cls,
db: AsyncSession,
usage_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(usage_ids))
if not ids:
return {}
statement = (
select(
BizUsageRecord.usage_id,
BizPallet.pallet_code,
BizCustomerSite.site_name,
BizTunnel.tunnel_code,
BizTunnel.tunnel_name,
BizProductionLine.line_name,
SysUser.nick_name,
BizUsageRecord.fifo_recommendation,
)
.select_from(BizUsageRecord)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizUsageRecord.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizCustomerSite,
and_(
BizCustomerSite.site_id == BizUsageRecord.site_id,
batch_scope_condition(BizCustomerSite.test_batch_id, scope),
),
)
.outerjoin(
BizTunnel,
and_(
BizTunnel.tunnel_id == BizUsageRecord.tunnel_id,
batch_scope_condition(BizTunnel.test_batch_id, scope),
),
)
.outerjoin(
BizProductionLine,
and_(
BizProductionLine.line_id == BizUsageRecord.line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
),
)
.outerjoin(SysUser, and_(SysUser.user_id == BizUsageRecord.operator_id, SysUser.del_flag == '0'))
.where(
BizUsageRecord.usage_id.in_(ids),
batch_scope_condition(BizUsageRecord.test_batch_id, scope),
)
)
summaries: dict[int, dict[str, Any]] = {
usage_id: {
'pallet_code': None,
'site_name': None,
'tunnel_code': None,
'tunnel_name': None,
'line_name': None,
'operator_name': None,
'skipped_pallet_code': None,
}
for usage_id in ids
}
for usage_id, pallet_code, site_name, tunnel_code, tunnel_name, line_name, operator_name, snapshot in (
await db.execute(statement)
).all():
summary = summaries.get(usage_id)
if summary is None:
continue
summary.update(
pallet_code=pallet_code,
site_name=site_name,
tunnel_code=tunnel_code,
tunnel_name=tunnel_name,
line_name=line_name,
operator_name=operator_name,
skipped_pallet_code=cls._skipped_pallet_code(snapshot),
)
return summaries
@staticmethod
async def get_site_options(
db: AsyncSession,
site_ids: list[int] | None,
scope: TestBatchScope,
) -> list[dict[str, Any]]:
"""当前用户有权访问且已启用的现场,附带启用隧道与产线数。"""
conditions = [batch_scope_condition(BizCustomerSite.test_batch_id, scope), BizCustomerSite.status == '0']
if site_ids is not None:
scoped_ids = sorted(set(site_ids))
conditions.append(BizCustomerSite.site_id.in_(scoped_ids) if scoped_ids else false())
tunnel_counts = (
select(BizTunnel.site_id, func.count(BizTunnel.tunnel_id).label('tunnel_count'))
.where(BizTunnel.status == '0', batch_scope_condition(BizTunnel.test_batch_id, scope))
.group_by(BizTunnel.site_id)
.subquery()
)
line_counts = (
select(BizProductionLine.site_id, func.count(BizProductionLine.line_id).label('line_count'))
.where(BizProductionLine.status == '0', batch_scope_condition(BizProductionLine.test_batch_id, scope))
.group_by(BizProductionLine.site_id)
.subquery()
)
statement = (
select(
BizCustomerSite.site_id,
BizCustomerSite.site_code,
BizCustomerSite.site_name,
BizCustomer.customer_name,
tunnel_counts.c.tunnel_count,
line_counts.c.line_count,
)
.select_from(BizCustomerSite)
.outerjoin(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.outerjoin(tunnel_counts, tunnel_counts.c.site_id == BizCustomerSite.site_id)
.outerjoin(line_counts, line_counts.c.site_id == BizCustomerSite.site_id)
.where(*conditions)
.order_by(BizCustomerSite.site_id)
)
return [
{
'id': site_id,
'code': site_code,
'name': site_name,
'customer_name': customer_name,
'tunnel_count': int(tunnel_count or 0),
'line_count': int(line_count or 0),
}
for site_id, site_code, site_name, customer_name, tunnel_count, line_count in (
await db.execute(statement)
).all()
]
@staticmethod
async def get_site_board(
db: AsyncSession,
site: Any,
scope: TestBatchScope,
today_start: datetime,
week_start: datetime,
) -> dict[str, Any]:
"""调用方已校验现场权限与批次范围;这里只做只读统计。"""
site_id = site.site_id
customer_statement = select(BizCustomer.customer_name).where(
BizCustomer.customer_id == site.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
)
inventory_statement = (
select(
BizPalletInventory.flow_state,
func.count(BizPalletInventory.pallet_id),
func.sum(BizPalletInventory.remaining_packages),
)
.where(
BizPalletInventory.site_id == site_id,
BizPalletInventory.warehouse_id.is_(None),
BizPalletInventory.flow_state.in_(
[*_IN_USE_STATES, PalletFlowState.AT_SITE_EMPTY.value],
),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.flow_state)
)
usage_conditions = [
BizUsageRecord.site_id == site_id,
batch_scope_condition(BizUsageRecord.test_batch_id, scope),
]
today_statement = (
select(
BizUsageRecord.line_id,
BizProductionLine.line_name,
func.count(BizUsageRecord.usage_id),
func.sum(BizUsageRecord.used_packages),
)
.select_from(BizUsageRecord)
.outerjoin(
BizProductionLine,
and_(
BizProductionLine.line_id == BizUsageRecord.line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
),
)
.where(*usage_conditions, BizUsageRecord.occurred_at >= today_start)
.group_by(BizUsageRecord.line_id, BizProductionLine.line_name)
)
skip_statement = select(
func.sum(case((BizUsageRecord.occurred_at >= today_start, 1), else_=0)),
func.count(BizUsageRecord.usage_id),
).where(
*usage_conditions,
BizUsageRecord.fifo_skip_reason.is_not(None),
BizUsageRecord.occurred_at >= week_start,
)
tunnel_statement = (
select(
BizTunnel.tunnel_id,
BizTunnel.tunnel_code,
BizTunnel.tunnel_name,
BizTunnel.current_trace_no.is_not(None).label('in_use'),
BizTunnel.current_trace_started_at,
)
.where(
BizTunnel.site_id == site_id,
BizTunnel.status == '0',
batch_scope_condition(BizTunnel.test_batch_id, scope),
)
.order_by(BizTunnel.tunnel_code, BizTunnel.tunnel_id)
)
tunnel_load_statement = (
select(
BizPalletInventory.tunnel_id,
func.count(BizPalletInventory.pallet_id),
func.sum(BizPalletInventory.remaining_packages),
)
.where(
BizPalletInventory.site_id == site_id,
BizPalletInventory.tunnel_id.is_not(None),
BizPalletInventory.flow_state.in_(_IN_USE_STATES),
BizPalletInventory.remaining_packages > 0,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
.group_by(BizPalletInventory.tunnel_id)
)
latest_usage = (
select(BizUsageRecord.tunnel_id, func.max(BizUsageRecord.usage_id).label('usage_id'))
.where(*usage_conditions, BizUsageRecord.tunnel_id.is_not(None))
.group_by(BizUsageRecord.tunnel_id)
.subquery()
)
last_use_statement = (
select(BizUsageRecord.tunnel_id, BizUsageRecord.occurred_at, BizProductionLine.line_name)
.select_from(latest_usage)
.join(BizUsageRecord, BizUsageRecord.usage_id == latest_usage.c.usage_id)
.outerjoin(
BizProductionLine,
and_(
BizProductionLine.line_id == BizUsageRecord.line_id,
batch_scope_condition(BizProductionLine.test_batch_id, scope),
),
)
)
line_statement = (
select(BizProductionLine.line_id, BizProductionLine.line_code, BizProductionLine.line_name)
.where(
BizProductionLine.site_id == site_id,
BizProductionLine.status == '0',
batch_scope_condition(BizProductionLine.test_batch_id, scope),
)
.order_by(BizProductionLine.line_id)
)
customer_name = (await db.execute(customer_statement)).scalar_one_or_none()
stock = {
state: (int(count or 0), int(packages or 0))
for state, count, packages in (await db.execute(inventory_statement)).all()
}
today_rows = (await db.execute(today_statement)).all()
today_skips, week_skips = (await db.execute(skip_statement)).one()
tunnel_rows = (await db.execute(tunnel_statement)).all()
loads = {
tunnel_id: (int(count or 0), int(packages or 0))
for tunnel_id, count, packages in (await db.execute(tunnel_load_statement)).all()
}
last_uses = {
tunnel_id: (occurred_at, line_name)
for tunnel_id, occurred_at, line_name in (await db.execute(last_use_statement)).all()
}
lines = [
{'id': line_id, 'code': line_code, 'name': line_name}
for line_id, line_code, line_name in (await db.execute(line_statement)).all()
]
unused = stock.get(PalletFlowState.AT_SITE_UNUSED.value, (0, 0))
partial = stock.get(PalletFlowState.AT_SITE_PARTIAL.value, (0, 0))
by_line = sorted(
(
{'line_id': line_id, 'line_name': line_name, 'used_packages': int(packages or 0)}
for line_id, line_name, _, packages in today_rows
),
key=lambda row: (-row['used_packages'], row['line_id'] or 0),
)
tunnels = []
for tunnel_id, tunnel_code, tunnel_name, in_use, started_at in tunnel_rows:
pallet_count, remaining = loads.get(tunnel_id, (0, 0))
last_used_at, last_line_name = last_uses.get(tunnel_id, (None, None))
tunnels.append(
{
'tunnel_id': tunnel_id,
'tunnel_code': tunnel_code,
'tunnel_name': tunnel_name,
'in_use': bool(in_use),
'pallet_count': pallet_count,
'remaining_packages': remaining,
'started_at': started_at if in_use else None,
'last_used_at': last_used_at,
'last_line_name': last_line_name,
}
)
return {
'site_id': site_id,
'site_code': site.site_code,
'site_name': site.site_name,
'customer_name': customer_name,
'pallet_count': unused[0] + partial[0],
'package_sum': unused[1] + partial[1],
'unused_pallet_count': unused[0],
'partial_pallet_count': partial[0],
'empty_pallet_count': stock.get(PalletFlowState.AT_SITE_EMPTY.value, (0, 0))[0],
'today_usage_count': sum(int(row[2] or 0) for row in today_rows),
'today_used_packages': sum(row['used_packages'] for row in by_line),
'today_by_line': by_line,
'tunnel_count': len(tunnels),
'active_tunnel_count': sum(1 for tunnel in tunnels if tunnel['in_use']),
'today_fifo_skip_count': int(today_skips or 0),
'week_fifo_skip_count': int(week_skips or 0),
'tunnels': tunnels,
'lines': lines,
}