Files
ShiErFeng/shierfeng-fastapi-backend/module_business/dao/filling_summary_dao.py
T
yujiangandClaude Opus 5.5 3050ccdc4e Feat: 后端新增基础资料与业务管理只读汇总接口
为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情
返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。

- 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转,
  托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级
  资料选项接口;Excel 导入支持 preview 只校验不写入
- 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和
  菌种构成;详情明细补充托盘号、菌种、批次
- 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary
- 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道;
  新增 /summary、/site-options
- 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、
  /batches、/batches/detail
- 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号;
  新增 /site-options、/site-board 以及 usage-fifo 的 /queue
- 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致
- 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 05:03:10 -07:00

382 lines
16 KiB
Python

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,
}