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