为 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>
382 lines
16 KiB
Python
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,
|
|
}
|