Files
ShiErFeng/shierfeng-fastapi-backend/module_business/dao/usage_fifo_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

199 lines
8.1 KiB
Python

"""按真实收货时间推荐可用物料,不推断历史到货时间。"""
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'],
),
)