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,381 @@
from datetime import date, datetime
from typing import Any
from sqlalchemy import ColumnElement, and_, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_admin.entity.do.user_do import SysUser
from module_business.dao.operation_dao import OperationDao
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 BizPallet, BizWarehouse
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
from module_business.entity.vo.operation_vo import FillingBatchPageQueryModel
# 装填周期已结束(物料用完、托盘回到空托)的托盘在批次去向中归为一类
CONSUMED_STATE = 'CONSUMED'
RECENT_RECORD_LIMIT = 10
TOP_STRAIN_LIMIT = 3
class FillingSummaryDao:
"""工厂装填的只读汇总:列表名称、按生产批次聚合、批次去向与页面概况。
装填记录按仓库数据范围筛选;所有关联表均受同一测试批次范围约束。
"""
@staticmethod
def _batch_columns() -> list[Any]:
return [
BizFillingRecord.strain_id,
BizFillingRecord.batch_no,
func.max(BizFillingRecord.strain_code_snapshot).label('strain_code'),
func.max(BizFillingRecord.strain_name_snapshot).label('strain_name'),
func.min(BizFillingRecord.production_date).label('production_date'),
func.min(BizFillingRecord.expiry_date).label('expiry_date'),
func.count(BizFillingRecord.filling_id).label('pallet_count'),
func.sum(BizFillingRecord.initial_packages).label('package_sum'),
func.max(BizFillingRecord.operated_at).label('last_operated_at'),
func.max(BizFillingRecord.filling_id).label('last_filling_id'),
]
@staticmethod
def _batch_row(row: Any) -> dict[str, Any]:
return {
'strain_id': row.strain_id,
'strain_code': row.strain_code,
'strain_name': row.strain_name,
'batch_no': row.batch_no,
'production_date': row.production_date,
'expiry_date': row.expiry_date,
'package_spec': None,
'pallet_count': int(row.pallet_count or 0),
'package_sum': int(row.package_sum or 0),
'last_operated_at': row.last_operated_at,
'last_operator_name': None,
'last_filling_id': row.last_filling_id,
}
@staticmethod
async def _attach_latest_record(
db: AsyncSession,
batches: list[dict[str, Any]],
scope: TestBatchScope,
) -> None:
"""用批次内最新一条记录补充包装规格与操作人。"""
latest_ids = sorted({batch['last_filling_id'] for batch in batches if batch['last_filling_id']})
if not latest_ids:
return
statement = (
select(BizFillingRecord.filling_id, BizFillingRecord.package_spec, SysUser.nick_name)
.select_from(BizFillingRecord)
.outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0'))
.where(
BizFillingRecord.filling_id.in_(latest_ids),
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
)
)
latest = {filling_id: (spec, name) for filling_id, spec, name in (await db.execute(statement)).all()}
for batch in batches:
spec, name = latest.get(batch['last_filling_id'], (None, None))
batch['package_spec'] = spec
batch['last_operator_name'] = name
@staticmethod
async def get_list_summaries(
db: AsyncSession,
filling_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(filling_ids))
if not ids:
return {}
statement = (
select(
BizFillingRecord.filling_id,
BizPallet.pallet_code,
BizWarehouse.warehouse_name,
SysUser.nick_name,
)
.select_from(BizFillingRecord)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizFillingRecord.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizFillingRecord.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0'))
.where(
BizFillingRecord.filling_id.in_(ids),
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
)
)
summaries: dict[int, dict[str, Any]] = {
filling_id: {'pallet_code': None, 'warehouse_name': None, 'operator_name': None} for filling_id in ids
}
for filling_id, pallet_code, warehouse_name, operator_name in (await db.execute(statement)).all():
summary = summaries.get(filling_id)
if summary is not None:
summary.update(pallet_code=pallet_code, warehouse_name=warehouse_name, operator_name=operator_name)
return summaries
@classmethod
async def get_batch_page(
cls,
db: AsyncSession,
query_object: FillingBatchPageQueryModel,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
) -> tuple[list[dict[str, Any]], int]:
"""按菌种 + 生产批次聚合,最近装填的批次在前。"""
conditions: list[ColumnElement[bool]] = OperationDao._filling_scope_conditions(warehouse_ids, scope)
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
conditions.append(
or_(
BizFillingRecord.batch_no.like(keyword),
BizFillingRecord.strain_code_snapshot.like(keyword),
BizFillingRecord.strain_name_snapshot.like(keyword),
)
)
if query_object.strain_id:
conditions.append(BizFillingRecord.strain_id == query_object.strain_id)
conditions.extend(
OperationDao._time_conditions(
BizFillingRecord.operated_at,
query_object.begin_time,
query_object.end_time,
)
)
grouped = (
select(*cls._batch_columns())
.where(*conditions)
.group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no)
)
total = (await db.execute(select(func.count()).select_from(grouped.subquery()))).scalar_one()
page_statement = (
grouped.order_by(
func.max(BizFillingRecord.operated_at).desc(),
func.max(BizFillingRecord.filling_id).desc(),
)
.offset((query_object.page_num - 1) * query_object.page_size)
.limit(query_object.page_size)
)
batches = [cls._batch_row(row) for row in (await db.execute(page_statement)).all()]
await cls._attach_latest_record(db, batches, scope)
return batches, int(total or 0)
@classmethod
async def get_batch_detail(
cls,
db: AsyncSession,
strain_id: int,
batch_no: str,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
) -> dict[str, Any] | None:
conditions = [
*OperationDao._filling_scope_conditions(warehouse_ids, scope),
BizFillingRecord.strain_id == strain_id,
BizFillingRecord.batch_no == batch_no,
]
header_statement = (
select(*cls._batch_columns())
.where(*conditions)
.group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no)
)
header_row = (await db.execute(header_statement)).first()
if header_row is None:
return None
batch = cls._batch_row(header_row)
# 托盘仍在本次装填周期内时取其当前状态;周期已结束则物料已用完
distribution_statement = (
select(BizPalletInventory.flow_state, func.count(BizFillingRecord.filling_id))
.select_from(BizFillingRecord)
.outerjoin(
BizPalletInventory,
and_(
BizPalletInventory.pallet_id == BizFillingRecord.pallet_id,
BizPalletInventory.active_filling_id == BizFillingRecord.filling_id,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
),
)
.where(*conditions)
.group_by(BizPalletInventory.flow_state)
)
recent_statement = (
select(
BizFillingRecord.filling_id,
BizFillingRecord.filling_no,
BizFillingRecord.pallet_id,
BizPallet.pallet_code,
BizWarehouse.warehouse_name,
SysUser.nick_name,
BizFillingRecord.operated_at,
BizFillingRecord.package_spec,
)
.select_from(BizFillingRecord)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizFillingRecord.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizWarehouse,
and_(
BizWarehouse.warehouse_id == BizFillingRecord.warehouse_id,
batch_scope_condition(BizWarehouse.test_batch_id, scope),
),
)
.outerjoin(SysUser, and_(SysUser.user_id == BizFillingRecord.operator_id, SysUser.del_flag == '0'))
.where(*conditions)
.order_by(BizFillingRecord.operated_at.desc(), BizFillingRecord.filling_id.desc())
.limit(RECENT_RECORD_LIMIT)
)
counts: dict[str, int] = {}
for flow_state, count in (await db.execute(distribution_statement)).all():
state = flow_state or CONSUMED_STATE
counts[state] = counts.get(state, 0) + int(count or 0)
recent_rows = (await db.execute(recent_statement)).all()
batch['distribution'] = [
{'state': state, 'pallet_count': count}
for state, count in sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))
]
batch['recent'] = [
{
'filling_id': filling_id,
'filling_no': filling_no,
'pallet_id': pallet_id,
'pallet_code': pallet_code,
'warehouse_name': warehouse_name,
'operator_name': operator_name,
'operated_at': operated_at,
}
for filling_id, filling_no, pallet_id, pallet_code, warehouse_name, operator_name, operated_at, _ in recent_rows
]
if recent_rows:
batch['package_spec'] = recent_rows[0][7]
batch['last_operator_name'] = recent_rows[0][5]
return batch
@staticmethod
async def get_overview(
db: AsyncSession,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
today_start: datetime,
month_start: datetime,
near_expiry_until: date,
near_expiry_days: int,
) -> dict[str, Any]:
"""装填页概况:今日与本月装填、可装填空托盘、临期仍在仓。"""
filling_conditions = OperationDao._filling_scope_conditions(warehouse_ids, scope)
inventory_conditions = [batch_scope_condition(BizPalletInventory.test_batch_id, scope)]
if warehouse_ids is not None:
scoped_ids = sorted(set(warehouse_ids))
inventory_conditions.append(BizPalletInventory.warehouse_id.in_(scoped_ids) if scoped_ids else false())
def batches_since(since: datetime) -> Any:
return (
select(
BizFillingRecord.strain_id,
BizFillingRecord.batch_no,
func.max(BizFillingRecord.strain_name_snapshot),
func.max(BizFillingRecord.strain_code_snapshot),
func.count(BizFillingRecord.filling_id),
func.sum(BizFillingRecord.initial_packages),
)
.where(*filling_conditions, BizFillingRecord.operated_at >= since)
.group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no)
)
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)
)
near_expiry_statement = (
select(
BizFillingRecord.batch_no,
func.min(BizFillingRecord.expiry_date),
func.count(BizPalletInventory.pallet_id),
)
.select_from(BizPalletInventory)
.join(
BizFillingRecord,
and_(
BizFillingRecord.filling_id == BizPalletInventory.active_filling_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
*inventory_conditions,
BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value,
BizFillingRecord.expiry_date <= near_expiry_until,
)
.group_by(BizFillingRecord.strain_id, BizFillingRecord.batch_no)
)
today_rows = (await db.execute(batches_since(today_start))).all()
month_rows = (await db.execute(batches_since(month_start))).all()
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']))
near_rows = sorted(
(await db.execute(near_expiry_statement)).all(),
key=lambda row: (row[1] or date.max, row[0]),
)
strain_counts: dict[str, int] = {}
for _, _, strain_name, strain_code, count, _ in month_rows:
label = strain_name or strain_code
if label:
strain_counts[label] = strain_counts.get(label, 0) + int(count or 0)
top_strains = sorted(strain_counts.items(), key=lambda entry: (-entry[1], entry[0]))[:TOP_STRAIN_LIMIT]
nearest = near_rows[0] if near_rows else None
return {
'today_pallet_count': sum(int(row[4] or 0) for row in today_rows),
'today_package_sum': sum(int(row[5] or 0) for row in today_rows),
'today_batch_count': len(today_rows),
'month_start': month_start.date(),
'month_pallet_count': sum(int(row[4] or 0) for row in month_rows),
'month_batch_count': len(month_rows),
'month_top_strains': [{'strain_name': name, 'pallet_count': count} for name, count in top_strains],
'empty_pallet_count': sum(row['pallet_count'] for row in empty_rows),
'empty_by_warehouse': empty_rows,
'near_expiry_days': near_expiry_days,
'near_expiry_pallet_count': sum(int(row[2] or 0) for row in near_rows),
'near_expiry_batch_count': len(near_rows),
'nearest_expiry_batch_no': nearest[0] if nearest else None,
'nearest_expiry_date': nearest[1] if nearest else None,
}
@@ -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,
}
@@ -2,12 +2,13 @@ from datetime import datetime
from typing import Any
from pydantic import BaseModel
from sqlalchemy import ColumnElement, Select, func, select, update
from sqlalchemy import ColumnElement, Select, func, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel
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,
@@ -39,6 +40,18 @@ from module_business.entity.vo.master_vo import (
)
from utils.page_util import PageUtil
_PALLET_FLOW_GROUP_STATES = {
'unstocked': (PalletFlowState.UNSTOCKED.value,),
'warehouse': (PalletFlowState.EMPTY_IN_WAREHOUSE.value, PalletFlowState.FILLED_IN_WAREHOUSE.value),
'transit': (PalletFlowState.IN_TRANSIT.value,),
'site': (
PalletFlowState.AT_SITE_UNUSED.value,
PalletFlowState.AT_SITE_PARTIAL.value,
PalletFlowState.AT_SITE_EMPTY.value,
),
'returning': (PalletFlowState.RETURNING.value,),
}
class MasterDao:
"""菌种、客户、现场、产线、隧道和仓库主数据数据库操作层。"""
@@ -494,15 +507,44 @@ class MasterDao:
) -> PageModel | list[dict[str, Any]]:
conditions = [batch_scope_condition(BizPallet.test_batch_id, scope)]
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
# 扫码枪输入的是二维码固定标识,按编号模糊或标识精确匹配
conditions.append(
or_(
BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'),
BizPallet.qr_token == query_object.pallet_code,
)
)
if query_object.asset_status:
conditions.append(BizPallet.asset_status == query_object.asset_status)
if query_object.flow_group:
conditions.append(cls._pallet_flow_group_condition(query_object.flow_group, scope))
ids = cls._parse_id_list(getattr(query_object, 'ids', None))
if ids:
conditions.append(BizPallet.pallet_id.in_(ids))
statement = select(BizPallet).where(*conditions).order_by(BizPallet.pallet_id)
return await cls._paginate(db, statement, query_object, is_page)
@staticmethod
def _pallet_flow_group_condition(flow_group: str, scope: TestBatchScope) -> ColumnElement[bool]:
states = _PALLET_FLOW_GROUP_STATES[flow_group]
in_states = BizPallet.pallet_id.in_(
select(BizPalletInventory.pallet_id).where(
BizPalletInventory.flow_state.in_(states),
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
)
if flow_group != 'unstocked':
return in_states
# 历史托盘可能缺少库存行,同样视为未入库
return or_(
in_states,
BizPallet.pallet_id.not_in(
select(BizPalletInventory.pallet_id).where(
batch_scope_condition(BizPalletInventory.test_batch_id, scope)
)
),
)
@classmethod
async def get_pallet(
cls,
@@ -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]
@@ -3,12 +3,13 @@ from typing import Any
from sqlalchemy import ColumnElement, Select, and_, delete, false, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlalchemy.orm.attributes import InstrumentedAttribute
from common.vo import PageModel
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
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizWarehouse
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizInboundItem,
@@ -608,6 +609,15 @@ class OperationDao:
conditions.append(BizFillingRecord.batch_no.like(f'%{query_object.batch_no}%'))
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
conditions.append(
or_(
BizFillingRecord.filling_no.like(keyword),
BizPallet.pallet_code.like(keyword),
BizFillingRecord.batch_no.like(keyword),
)
)
conditions.extend(
cls._time_conditions(
BizFillingRecord.operated_at,
@@ -616,7 +626,7 @@ class OperationDao:
)
)
statement = select(BizFillingRecord)
if query_object.pallet_code:
if query_object.pallet_code or query_object.keyword:
statement = statement.join(
BizPallet,
BizPallet.pallet_id == BizFillingRecord.pallet_id,
@@ -680,6 +690,10 @@ class OperationDao:
conditions.append(BizShipmentOrder.target_site_id == query_object.target_site_id)
if query_object.status:
conditions.append(BizShipmentOrder.status == query_object.status.value)
if query_object.receipt_state:
# 收货状态只在确认后有意义,草稿的默认值不参与筛选
conditions.append(BizShipmentOrder.status == DocumentStatus.CONFIRMED.value)
conditions.append(BizShipmentOrder.receipt_state == query_object.receipt_state)
conditions.extend(
cls._time_conditions(
BizShipmentOrder.create_time,
@@ -687,7 +701,45 @@ class OperationDao:
query_object.end_time,
)
)
statement = select(BizShipmentOrder).where(*conditions).order_by(BizShipmentOrder.shipment_id)
statement = select(BizShipmentOrder)
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
source_warehouse = aliased(BizWarehouse, name='source_warehouse')
target_site = aliased(BizCustomerSite, name='target_site')
target_customer = aliased(BizCustomer, name='target_customer')
# 名称关联与单据处于同一测试批次范围,跨批次的主数据不会被匹配
statement = (
statement.outerjoin(
source_warehouse,
and_(
source_warehouse.warehouse_id == BizShipmentOrder.source_warehouse_id,
batch_scope_condition(source_warehouse.test_batch_id, scope),
),
)
.outerjoin(
target_site,
and_(
target_site.site_id == BizShipmentOrder.target_site_id,
batch_scope_condition(target_site.test_batch_id, scope),
),
)
.outerjoin(
target_customer,
and_(
target_customer.customer_id == target_site.customer_id,
batch_scope_condition(target_customer.test_batch_id, scope),
),
)
)
conditions.append(
or_(
BizShipmentOrder.shipment_no.like(keyword),
source_warehouse.warehouse_name.like(keyword),
target_site.site_name.like(keyword),
target_customer.customer_name.like(keyword),
)
)
statement = statement.where(*conditions).order_by(BizShipmentOrder.shipment_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
@@ -818,6 +870,11 @@ class OperationDao:
conditions.append(BizUsageRecord.line_id == query_object.line_id)
if query_object.pallet_code:
conditions.append(BizPallet.pallet_code.like(f'%{query_object.pallet_code}%'))
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
conditions.append(or_(BizUsageRecord.usage_no.like(keyword), BizPallet.pallet_code.like(keyword)))
if query_object.fifo_skipped:
conditions.append(BizUsageRecord.fifo_skip_reason.is_not(None))
conditions.extend(
cls._time_conditions(
BizUsageRecord.occurred_at,
@@ -826,7 +883,7 @@ class OperationDao:
)
)
statement = select(BizUsageRecord)
if query_object.pallet_code:
if query_object.pallet_code or query_object.keyword:
statement = statement.join(
BizPallet,
BizPallet.pallet_id == BizUsageRecord.pallet_id,
@@ -884,7 +941,63 @@ class OperationDao:
query_object.end_time,
)
)
statement = select(BizReturnOrder).where(*conditions).order_by(BizReturnOrder.return_id)
statement = select(BizReturnOrder)
if query_object.keyword:
keyword = f'%{query_object.keyword}%'
source_site = aliased(BizCustomerSite, name='source_site')
source_customer = aliased(BizCustomer, name='source_customer')
target_warehouse = aliased(BizWarehouse, name='target_warehouse')
# 名称关联与单据处于同一测试批次范围,跨批次的主数据不会被匹配
statement = (
statement.outerjoin(
source_site,
and_(
source_site.site_id == BizReturnOrder.source_site_id,
batch_scope_condition(source_site.test_batch_id, scope),
),
)
.outerjoin(
source_customer,
and_(
source_customer.customer_id == source_site.customer_id,
batch_scope_condition(source_customer.test_batch_id, scope),
),
)
.outerjoin(
target_warehouse,
and_(
target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id,
batch_scope_condition(target_warehouse.test_batch_id, scope),
),
)
)
# 托盘编号按明细存在性匹配,不改变每张单据只出现一行
pallet_match = (
select(BizReturnItem.return_item_id)
.join(
BizPallet,
and_(
BizPallet.pallet_id == BizReturnItem.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.where(
BizReturnItem.return_id == BizReturnOrder.return_id,
batch_scope_condition(BizReturnItem.test_batch_id, scope),
BizPallet.pallet_code.like(keyword),
)
.exists()
)
conditions.append(
or_(
BizReturnOrder.return_no.like(keyword),
source_site.site_name.like(keyword),
source_customer.customer_name.like(keyword),
target_warehouse.warehouse_name.like(keyword),
pallet_match,
)
)
statement = statement.where(*conditions).order_by(BizReturnOrder.return_id)
return await cls._paginate(db, statement, query_object, is_page)
@classmethod
@@ -0,0 +1,280 @@
from datetime import datetime
from typing import Any
from sqlalchemy import and_, case, false, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from module_admin.entity.do.user_do import SysUser
from module_business.dao.operation_dao import OperationDao
from module_business.domain.enums import ReturnStatus
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizCustomer, BizCustomerSite, BizPallet, BizTunnel, BizWarehouse
from module_business.entity.do.operation_do import BizReturnItem, BizReturnOrder
class ReturnSummaryDao:
"""托盘退回的只读汇总:列表名称与托盘汇总、详情托盘编号与隧道名称、来源现场选项、页面概况。
列表与详情只汇总调用方已按数据范围取得的单据;概况与选项沿用列表的「来源现场或目标仓库」可见范围。
所有关联表均受同一测试批次范围约束。
"""
@staticmethod
def _filled_flag() -> Any:
# 申请时仍有余量的托盘,确认后回到有料库存;其余成为空托盘
return case((BizReturnItem.remaining_packages_snapshot > 0, 1), else_=0)
@staticmethod
async def get_list_summaries(
db: AsyncSession,
return_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(return_ids))
if not ids:
return {}
source_site = aliased(BizCustomerSite, name='source_site')
source_customer = aliased(BizCustomer, name='source_customer')
target_warehouse = aliased(BizWarehouse, name='target_warehouse')
requester = aliased(SysUser, name='requester')
confirmer = aliased(SysUser, name='confirmer')
name_statement = (
select(
BizReturnOrder.return_id,
source_site.site_name,
source_customer.customer_name,
target_warehouse.warehouse_name,
requester.nick_name,
confirmer.nick_name,
)
.select_from(BizReturnOrder)
.outerjoin(
source_site,
and_(
source_site.site_id == BizReturnOrder.source_site_id,
batch_scope_condition(source_site.test_batch_id, scope),
),
)
.outerjoin(
source_customer,
and_(
source_customer.customer_id == source_site.customer_id,
batch_scope_condition(source_customer.test_batch_id, scope),
),
)
.outerjoin(
target_warehouse,
and_(
target_warehouse.warehouse_id == BizReturnOrder.target_warehouse_id,
batch_scope_condition(target_warehouse.test_batch_id, scope),
),
)
.outerjoin(requester, and_(requester.user_id == BizReturnOrder.requested_by, requester.del_flag == '0'))
.outerjoin(confirmer, and_(confirmer.user_id == BizReturnOrder.confirmed_by, confirmer.del_flag == '0'))
.where(
BizReturnOrder.return_id.in_(ids),
batch_scope_condition(BizReturnOrder.test_batch_id, scope),
)
)
item_statement = (
select(
BizReturnItem.return_id,
func.count(BizReturnItem.return_item_id),
func.sum(BizReturnItem.remaining_packages_snapshot),
func.sum(ReturnSummaryDao._filled_flag()),
)
.where(
BizReturnItem.return_id.in_(ids),
batch_scope_condition(BizReturnItem.test_batch_id, scope),
)
.group_by(BizReturnItem.return_id)
)
summaries: dict[int, dict[str, Any]] = {
return_id: {
'source_site_name': None,
'customer_name': None,
'target_warehouse_name': None,
'requester_name': None,
'confirmer_name': None,
'pallet_count': 0,
'package_sum': 0,
'filled_count': 0,
}
for return_id in ids
}
for return_id, site_name, customer_name, warehouse_name, requester_name, confirmer_name in (
await db.execute(name_statement)
).all():
summary = summaries.get(return_id)
if summary is None:
continue
summary.update(
source_site_name=site_name,
customer_name=customer_name,
target_warehouse_name=warehouse_name,
requester_name=requester_name,
confirmer_name=confirmer_name,
)
for return_id, pallet_count, package_sum, filled_count in (await db.execute(item_statement)).all():
summary = summaries.get(return_id)
if summary is not None:
summary.update(
pallet_count=int(pallet_count or 0),
package_sum=int(package_sum or 0),
filled_count=int(filled_count or 0),
)
return summaries
@staticmethod
async def get_item_identities(
db: AsyncSession,
return_id: int,
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
"""为详情明细补充托盘编号与申请时所在隧道名称,只读展示,不参与任何写入或核对。"""
statement = (
select(BizReturnItem.return_item_id, BizPallet.pallet_code, BizTunnel.tunnel_name)
.select_from(BizReturnItem)
.outerjoin(
BizPallet,
and_(
BizPallet.pallet_id == BizReturnItem.pallet_id,
batch_scope_condition(BizPallet.test_batch_id, scope),
),
)
.outerjoin(
BizTunnel,
and_(
BizTunnel.tunnel_id == BizReturnItem.tunnel_id_snapshot,
batch_scope_condition(BizTunnel.test_batch_id, scope),
),
)
.where(
BizReturnItem.return_id == return_id,
batch_scope_condition(BizReturnItem.test_batch_id, scope),
)
)
return {
return_item_id: {'pallet_code': pallet_code, 'tunnel_name': tunnel_name}
for return_item_id, pallet_code, tunnel_name in (await db.execute(statement)).all()
}
@staticmethod
async def get_site_options(
db: AsyncSession,
site_ids: list[int] | None,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
) -> list[dict[str, Any]]:
"""来源现场选项:用户授权的现场(可发起退回),加上其可见退回单涉及的来源现场(用于筛选)。
停用的现场仍保留,以便筛选历史单据。
"""
conditions = [batch_scope_condition(BizCustomerSite.test_batch_id, scope)]
if site_ids is not None or warehouse_ids is not None:
scoped_site_ids = sorted(set(site_ids or []))
visible_sources = select(BizReturnOrder.source_site_id).where(
# 与列表接口使用同一可见范围
*OperationDao._return_scope_conditions(site_ids, warehouse_ids, scope)
)
conditions.append(
or_(
BizCustomerSite.site_id.in_(scoped_site_ids) if scoped_site_ids else false(),
BizCustomerSite.site_id.in_(visible_sources),
)
)
statement = (
select(
BizCustomerSite.site_id,
BizCustomerSite.site_code,
BizCustomerSite.site_name,
BizCustomer.customer_name,
)
.select_from(BizCustomerSite)
.outerjoin(
BizCustomer,
and_(
BizCustomer.customer_id == BizCustomerSite.customer_id,
batch_scope_condition(BizCustomer.test_batch_id, scope),
),
)
.where(*conditions)
.order_by(BizCustomerSite.site_id)
)
return [
{'id': site_id, 'code': site_code, 'name': site_name, 'customer_name': customer_name}
for site_id, site_code, site_name, customer_name in (await db.execute(statement)).all()
]
@staticmethod
async def get_overview(
db: AsyncSession,
site_ids: list[int] | None,
warehouse_ids: list[int] | None,
scope: TestBatchScope,
month_start: datetime,
) -> dict[str, Any]:
"""退回页概况。待核对托盘即当前处于「退回中」的托盘,余量取申请时快照(确认前不会变化)。"""
visibility = OperationDao._return_scope_conditions(site_ids, warehouse_ids, scope)
item_join = and_(
BizReturnItem.return_id == BizReturnOrder.return_id,
batch_scope_condition(BizReturnItem.test_batch_id, scope),
)
filled = ReturnSummaryDao._filled_flag()
pending_statement = (
select(
func.count(func.distinct(BizReturnOrder.return_id)),
func.count(BizReturnItem.return_item_id),
func.sum(filled),
func.sum(BizReturnItem.remaining_packages_snapshot),
func.min(BizReturnOrder.requested_at),
)
.select_from(BizReturnOrder)
.outerjoin(BizReturnItem, item_join)
.where(*visibility, BizReturnOrder.status == ReturnStatus.REQUESTED.value)
)
# 按单据分组取回申请与确认时间,平均耗时在应用侧计算,避免依赖数据库的日期差函数
month_statement = (
select(
BizReturnOrder.return_id,
BizReturnOrder.requested_at,
BizReturnOrder.confirmed_at,
func.count(BizReturnItem.return_item_id),
func.sum(filled),
func.sum(BizReturnItem.remaining_packages_snapshot),
)
.select_from(BizReturnOrder)
.outerjoin(BizReturnItem, item_join)
.where(
*visibility,
BizReturnOrder.status == ReturnStatus.CONFIRMED.value,
BizReturnOrder.confirmed_at >= month_start,
)
.group_by(BizReturnOrder.return_id, BizReturnOrder.requested_at, BizReturnOrder.confirmed_at)
)
pending_count, pending_pallets, pending_filled, pending_packages, oldest_pending_at = (
await db.execute(pending_statement)
).one()
month_rows = (await db.execute(month_statement)).all()
durations = [
(confirmed_at - requested_at).total_seconds() / 3600
for _, requested_at, confirmed_at, *_ in month_rows
if requested_at is not None and confirmed_at is not None and confirmed_at >= requested_at
]
return {
'pending_count': int(pending_count or 0),
'pending_pallet_count': int(pending_pallets or 0),
'pending_filled_count': int(pending_filled or 0),
'pending_package_sum': int(pending_packages or 0),
'oldest_pending_at': oldest_pending_at,
'month_start': month_start.date(),
'month_confirmed_count': len(month_rows),
'month_confirmed_pallet_count': sum(int(row[3] or 0) for row in month_rows),
'month_filled_pallet_count': sum(int(row[4] or 0) for row in month_rows),
'month_package_sum': sum(int(row[5] or 0) for row in month_rows),
'month_avg_confirm_hours': round(sum(durations) / len(durations), 1) if durations else None,
}
@@ -0,0 +1,167 @@
from typing import Any
from sqlalchemy import and_, 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 BizCustomer, BizCustomerSite, BizWarehouse
from module_business.entity.do.operation_do import (
BizFillingRecord,
BizPalletInventory,
BizShipmentItem,
BizShipmentOrder,
)
class ShipmentSummaryDao:
"""发货单列表的只读汇总:名称、托盘数、包数与菌种构成。
只汇总调用方已按数据范围分页得到的单据 ID,所有关联表均受同一测试批次范围约束。
"""
@staticmethod
async def get_list_summaries(
db: AsyncSession,
shipment_ids: list[int],
scope: TestBatchScope,
) -> dict[int, dict[str, Any]]:
ids = sorted(set(shipment_ids))
if not ids:
return {}
source_warehouse = aliased(BizWarehouse, name='source_warehouse')
target_site = aliased(BizCustomerSite, name='target_site')
target_customer = aliased(BizCustomer, name='target_customer')
creator = aliased(SysUser, name='creator')
name_statement = (
select(
BizShipmentOrder.shipment_id,
source_warehouse.warehouse_name,
target_site.site_name,
target_customer.customer_name,
creator.nick_name,
)
.select_from(BizShipmentOrder)
.outerjoin(
source_warehouse,
and_(
source_warehouse.warehouse_id == BizShipmentOrder.source_warehouse_id,
batch_scope_condition(source_warehouse.test_batch_id, scope),
),
)
.outerjoin(
target_site,
and_(
target_site.site_id == BizShipmentOrder.target_site_id,
batch_scope_condition(target_site.test_batch_id, scope),
),
)
.outerjoin(
target_customer,
and_(
target_customer.customer_id == target_site.customer_id,
batch_scope_condition(target_customer.test_batch_id, scope),
),
)
.outerjoin(creator, and_(creator.user_name == BizShipmentOrder.create_by, creator.del_flag == '0'))
.where(
BizShipmentOrder.shipment_id.in_(ids),
batch_scope_condition(BizShipmentOrder.test_batch_id, scope),
)
)
# 已确认单使用确认快照;草稿与详情一致,只取来源仓库内仍可发货的当前余量
draft_inventory = and_(
BizShipmentOrder.status == DocumentStatus.DRAFT.value,
BizPalletInventory.pallet_id == BizShipmentItem.pallet_id,
BizPalletInventory.warehouse_id == BizShipmentOrder.source_warehouse_id,
BizPalletInventory.flow_state == PalletFlowState.FILLED_IN_WAREHOUSE.value,
BizPalletInventory.site_id.is_(None),
BizPalletInventory.current_line_id.is_(None),
BizPalletInventory.active_filling_id.is_not(None),
BizPalletInventory.remaining_packages > 0,
batch_scope_condition(BizPalletInventory.test_batch_id, scope),
)
item_statement = (
select(
BizShipmentItem.shipment_id,
BizShipmentItem.package_qty,
BizPalletInventory.remaining_packages,
BizFillingRecord.strain_code_snapshot,
BizFillingRecord.strain_name_snapshot,
)
.select_from(BizShipmentItem)
.join(
BizShipmentOrder,
and_(
BizShipmentOrder.shipment_id == BizShipmentItem.shipment_id,
batch_scope_condition(BizShipmentOrder.test_batch_id, scope),
),
)
.outerjoin(BizPalletInventory, draft_inventory)
.outerjoin(
BizFillingRecord,
and_(
BizFillingRecord.filling_id
== func.coalesce(BizShipmentItem.active_filling_id, BizPalletInventory.active_filling_id),
BizFillingRecord.pallet_id == BizShipmentItem.pallet_id,
batch_scope_condition(BizFillingRecord.test_batch_id, scope),
),
)
.where(
BizShipmentItem.shipment_id.in_(ids),
batch_scope_condition(BizShipmentItem.test_batch_id, scope),
)
.order_by(BizShipmentItem.shipment_id, BizShipmentItem.shipment_item_id)
)
summaries: dict[int, dict[str, Any]] = {
shipment_id: {
'source_warehouse_name': None,
'target_site_name': None,
'customer_name': None,
'creator_name': None,
'pallet_count': 0,
'package_qty': None,
'strains': [],
}
for shipment_id in ids
}
for shipment_id, warehouse_name, site_name, customer_name, creator_name in (
await db.execute(name_statement)
).all():
summary = summaries.get(shipment_id)
if summary is None:
continue
summary.update(
source_warehouse_name=warehouse_name,
target_site_name=site_name,
customer_name=customer_name,
creator_name=creator_name,
)
strain_counts: dict[int, dict[str, int]] = {}
for shipment_id, package_qty, remaining_packages, strain_code, strain_name in (
await db.execute(item_statement)
).all():
summary = summaries.get(shipment_id)
if summary is None:
continue
summary['pallet_count'] += 1
quantity = package_qty if package_qty is not None else remaining_packages
if quantity is not None:
summary['package_qty'] = (summary['package_qty'] or 0) + quantity
label = strain_name or strain_code
if label:
counts = strain_counts.setdefault(shipment_id, {})
counts[label] = counts.get(label, 0) + 1
for shipment_id, counts in strain_counts.items():
summaries[shipment_id]['strains'] = [
{'strain_name': name, 'pallet_count': count}
for name, count in sorted(counts.items(), key=lambda entry: (-entry[1], entry[0]))
]
return summaries
@@ -2,17 +2,76 @@
from typing import Any
from sqlalchemy import and_, func, or_, select
from sqlalchemy import ColumnElement, Select, and_, func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from module_business.domain.test_batch_scope import TestBatchScope, batch_scope_condition
from module_business.entity.do.master_do import BizPallet
from module_business.entity.do.operation_do import BizFillingRecord, BizPalletInventory
# 待使用队列每个菌种最多返回的托盘数;总数仍完整统计
QUEUE_LIMIT_PER_STRAIN = 20
class UsageFifoDao:
@staticmethod
def _candidate_conditions(
*,
site_id: int,
strain_id: int | None,
tunnel_id: int | None,
trace_no: str | None,
scope: TestBatchScope,
) -> list[ColumnElement[bool]]:
"""建议与待使用队列共用的候选条件;strain_id 为空时不按菌种限制(仅队列使用)。"""
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None))
conditions = [
inventory.site_id == site_id,
inventory.warehouse_id.is_(None),
inventory.flow_state.in_(['AT_SITE_UNUSED', 'AT_SITE_PARTIAL']),
inventory.remaining_packages > 0,
inventory.remaining_packages <= pallet.capacity_packages,
]
if strain_id is not None:
conditions.append(filling.strain_id == strain_id)
conditions.extend(
[
filling.active_slot == 'ACTIVE',
filling.closed_at.is_(None),
pallet.asset_status == '0',
batch_scope_condition(inventory.test_batch_id, scope),
batch_scope_condition(filling.test_batch_id, scope),
batch_scope_condition(pallet.test_batch_id, scope),
(
unbound
if tunnel_id is None
else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no))
),
]
)
return conditions
@staticmethod
def _join_candidates(statement: Select) -> Select:
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
return (
statement.select_from(inventory)
.join(pallet, pallet.pallet_id == inventory.pallet_id)
.join(
filling,
and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id),
)
)
@staticmethod
def _fifo_order() -> tuple[Any, ...]:
# 先到先用:按实际收货时间,同一时间按托盘ID稳定排序
return (BizPalletInventory.site_arrived_at, BizPalletInventory.pallet_id)
@classmethod
async def recommendation(
cls,
db: AsyncSession,
*,
site_id: int,
@@ -23,27 +82,10 @@ class UsageFifoDao:
for_update: bool = False,
) -> tuple[dict[str, Any] | None, int]:
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
unbound = and_(inventory.tunnel_id.is_(None), inventory.trace_no.is_(None))
conditions = [
inventory.site_id == site_id,
inventory.warehouse_id.is_(None),
inventory.flow_state.in_(['AT_SITE_UNUSED', 'AT_SITE_PARTIAL']),
inventory.remaining_packages > 0,
inventory.remaining_packages <= pallet.capacity_packages,
filling.strain_id == strain_id,
filling.active_slot == 'ACTIVE',
filling.closed_at.is_(None),
pallet.asset_status == '0',
batch_scope_condition(inventory.test_batch_id, scope),
batch_scope_condition(filling.test_batch_id, scope),
batch_scope_condition(pallet.test_batch_id, scope),
(
unbound
if tunnel_id is None
else or_(unbound, and_(inventory.tunnel_id == tunnel_id, inventory.trace_no == trace_no))
),
]
base = (
conditions = cls._candidate_conditions(
site_id=site_id, strain_id=strain_id, tunnel_id=tunnel_id, trace_no=trace_no, scope=scope
)
base = cls._join_candidates(
select(
pallet.pallet_id,
pallet.pallet_code,
@@ -51,22 +93,8 @@ class UsageFifoDao:
inventory.remaining_packages,
inventory.site_arrived_at,
)
.select_from(inventory)
.join(pallet, pallet.pallet_id == inventory.pallet_id)
.join(
filling,
and_(filling.filling_id == inventory.active_filling_id, filling.pallet_id == inventory.pallet_id),
)
.where(*conditions)
)
statement = (
base.where(inventory.site_arrived_at.is_not(None))
.order_by(
inventory.site_arrived_at,
inventory.pallet_id,
)
.limit(1)
)
).where(*conditions)
statement = base.where(inventory.site_arrived_at.is_not(None)).order_by(*cls._fifo_order()).limit(1)
if for_update:
# 写入决策必须用当前读,不能复用权限预检期间建立的 MVCC 旧快照。
# 不使用 skip_locked,避免把正在操作的早到物料悄悄跳过。
@@ -83,3 +111,88 @@ class UsageFifoDao:
)
)
return (dict(first) if first is not None else None, unknown.scalar_one())
@classmethod
async def queue(
cls,
db: AsyncSession,
*,
site_id: int,
scope: TestBatchScope,
limit_per_strain: int = QUEUE_LIMIT_PER_STRAIN,
) -> list[dict[str, Any]]:
"""现场尚未入隧的待使用托盘,按菌种分组;组内顺序与先到先用建议一致(只读)。"""
inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet
conditions = cls._candidate_conditions(
site_id=site_id, strain_id=None, tunnel_id=None, trace_no=None, scope=scope
)
known_statement = (
cls._join_candidates(
select(
filling.strain_id,
filling.strain_code_snapshot,
filling.strain_name_snapshot,
pallet.pallet_id,
pallet.pallet_code,
filling.batch_no,
filling.expiry_date,
inventory.remaining_packages,
inventory.site_arrived_at,
)
)
.where(*conditions, inventory.site_arrived_at.is_not(None))
.order_by(*cls._fifo_order())
)
unknown_statement = (
cls._join_candidates(
select(
filling.strain_id,
func.max(filling.strain_code_snapshot),
func.max(filling.strain_name_snapshot),
func.count(inventory.pallet_id),
)
)
.where(*conditions, inventory.site_arrived_at.is_(None))
.group_by(filling.strain_id)
)
groups: dict[int, dict[str, Any]] = {}
def group_for(strain_id: int, strain_code: str | None, strain_name: str | None) -> dict[str, Any]:
group = groups.get(strain_id)
if group is None:
group = {
'strain_id': strain_id,
'strain_code': strain_code,
'strain_name': strain_name,
'pallet_count': 0,
'unknown_arrival_count': 0,
'pallets': [],
}
groups[strain_id] = group
return group
for row in (await db.execute(known_statement)).mappings().all():
group = group_for(row['strain_id'], row['strain_code_snapshot'], row['strain_name_snapshot'])
group['pallet_count'] += 1
if len(group['pallets']) < limit_per_strain:
group['pallets'].append(
{
'pallet_id': row['pallet_id'],
'pallet_code': row['pallet_code'],
'batch_no': row['batch_no'],
'expiry_date': row['expiry_date'],
'remaining_packages': row['remaining_packages'],
'site_arrived_at': row['site_arrived_at'],
}
)
for strain_id, strain_code, strain_name, count in (await db.execute(unknown_statement)).all():
group_for(strain_id, strain_code, strain_name)['unknown_arrival_count'] = int(count or 0)
return sorted(
groups.values(),
key=lambda group: (
-(group['pallet_count'] + group['unknown_arrival_count']),
group['strain_name'] or '',
group['strain_id'],
),
)
@@ -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,
}