为 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>
199 lines
8.1 KiB
Python
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'],
|
|
),
|
|
)
|