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:
@@ -0,0 +1,358 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user