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>
This commit is contained in:
@@ -2,17 +2,76 @@
|
||||
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
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,
|
||||
@@ -23,27 +82,10 @@ class UsageFifoDao:
|
||||
for_update: bool = False,
|
||||
) -> tuple[dict[str, Any] | None, int]:
|
||||
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,
|
||||
filling.strain_id == strain_id,
|
||||
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))
|
||||
),
|
||||
]
|
||||
base = (
|
||||
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,
|
||||
@@ -51,22 +93,8 @@ class UsageFifoDao:
|
||||
inventory.remaining_packages,
|
||||
inventory.site_arrived_at,
|
||||
)
|
||||
.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),
|
||||
)
|
||||
.where(*conditions)
|
||||
)
|
||||
statement = (
|
||||
base.where(inventory.site_arrived_at.is_not(None))
|
||||
.order_by(
|
||||
inventory.site_arrived_at,
|
||||
inventory.pallet_id,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
).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,避免把正在操作的早到物料悄悄跳过。
|
||||
@@ -83,3 +111,88 @@ class UsageFifoDao:
|
||||
)
|
||||
)
|
||||
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'],
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user