"""按真实收货时间推荐可用物料,不推断历史到货时间。""" from typing import Any 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, strain_id: int, tunnel_id: int | None, trace_no: str | None, scope: TestBatchScope, for_update: bool = False, ) -> tuple[dict[str, Any] | None, int]: inventory, filling, pallet = BizPalletInventory, BizFillingRecord, BizPallet 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, filling.batch_no, inventory.remaining_packages, inventory.site_arrived_at, ) ).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,避免把正在操作的早到物料悄悄跳过。 # 此时已持有所选库存及现场/隧道锁,不能等待其他候选库存造成反向锁依赖。 # NOWAIT 不会跳过早到托盘:冲突令整个命令回滚,再按原幂等键有限重试。 statement = statement.with_for_update(nowait=True) known = await db.execute(statement) first = known.mappings().first() if for_update: return (dict(first) if first is not None else None, 0) unknown = await db.execute( select(func.count()).select_from( base.where(inventory.site_arrived_at.is_(None)).subquery(), ) ) 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'], ), )