Feat: 后端新增基础资料与业务管理只读汇总接口
为 Web 端改版提供名称、统计和看板数据。只新增只读查询、扩展列表与详情 返回字段;写接口、托盘状态机和幂等键规则均未改动,未新增权限标识。 - 基础资料:列表补充名称与汇总,客户详情返回现场树,托盘详情返回最近流转, 托盘列表支持按流转阶段筛选和二维码精确匹配;新增现场、产线、隧道的上级 资料选项接口;Excel 导入支持 preview 只校验不写入 - 发货出库:列表支持 keyword、receiptState 筛选并补充名称、托盘数、包数和 菌种构成;详情明细补充托盘号、菌种、批次 - 首次入库:列表与详情补充名称、托盘数、托盘当前状态;新增 /summary - 托盘退回:列表支持 keyword 并补充名称与托盘汇总,详情补充托盘号和隧道; 新增 /summary、/site-options - 工厂装填:列表支持 keyword 并补充托盘号、仓库、操作人;新增 /summary、 /batches、/batches/detail - 客户现场使用:列表支持 keyword、fifoSkipped 并补充名称和被跳过的先到托盘号; 新增 /site-options、/site-board 以及 usage-fifo 的 /queue - 先到先用 DAO 抽出公共候选条件与排序,建议查询生成的 SQL 与改动前一致 - 新增各汇总 DAO 的单元测试,同步更新路由权限与模型字段契约测试 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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,
|
||||
}
|
||||
Reference in New Issue
Block a user