Files
zkxw/backend/app/crud/storage_facility.py
T
zlwuandCursor 5dad1bb0e3 fix: 协议子资源单事务保存,并补齐样本编码/OA/质检等近期能力
锁定缴费校验失败时整单回滚,避免样本半成功落库;前端禁止删除锁定缴费样本,改码走更正编码。同步落地样本更正、采集微信通知、OA提及未读、细胞产品套餐与相关迁移文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-26 11:02:25 +08:00

1280 lines
40 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""存储设施(树结构)+ 小格 CRUD(biz_storage_facility / biz_storage_facility_slot)"""
from __future__ import annotations
from datetime import datetime
import re
from typing import Any, Dict, List, Optional
from sqlalchemy import and_, case, false, func, or_
from sqlalchemy.orm import Session
from app.core.utils import generate_uuid
from app.models.models import BizStorageFacility, BizStorageFacilitySlot
from app.services.storage_facility_code import (
assert_sibling_node_code_unique,
build_full_slot_code,
build_node_code_path,
cascade_refresh_slot_name_snapshots_for_nodes,
cascade_refresh_slot_name_snapshots_for_slots,
decode_slot_layout_order,
encode_slot_layout_order,
load_facility_ancestor_map,
load_facility_map,
validate_node_code,
)
# ---------------------------------------------------------------------------
# Facility node helpers
# ---------------------------------------------------------------------------
def _row_to_dict(f: BizStorageFacility) -> Dict[str, Any]:
return {
"id": f.id,
"parentId": f.parent_id,
"nodeName": f.node_name,
"nodeCode": f.node_code or "",
"nodeType": f.node_type or "",
"isLeaf": 1 if f.is_leaf else 0,
"tenantNo": f.tenant_no,
"orderNum": f.order_num if f.order_num is not None else 0,
"remark": f.remark or "",
"createTime": f.create_time.strftime("%Y-%m-%d %H:%M:%S") if f.create_time else "",
"updateTime": f.update_time.strftime("%Y-%m-%d %H:%M:%S") if f.update_time else "",
}
def list_all_facilities(
db: Session,
*,
tenant_no: str,
keyword: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Return FLAT list of all non-deleted facility nodes (no pagination, no tree building)."""
q = db.query(BizStorageFacility).filter(BizStorageFacility.del_flag == 0)
if tenant_no:
q = q.filter(BizStorageFacility.tenant_no == tenant_no)
kw = (keyword or "").strip()
if kw:
like = f"%{kw}%"
q = q.filter(
or_(
BizStorageFacility.node_name.like(like),
BizStorageFacility.node_code.like(like),
)
)
rows = q.order_by(BizStorageFacility.order_num.asc(), BizStorageFacility.create_time.asc()).all()
return [_row_to_dict(x) for x in rows]
def get_facility(db: Session, node_id: str, tenant_no: Optional[str] = None) -> Optional[BizStorageFacility]:
q = db.query(BizStorageFacility).filter(
and_(BizStorageFacility.id == node_id, BizStorageFacility.del_flag == 0)
)
if tenant_no:
q = q.filter(BizStorageFacility.tenant_no == tenant_no)
return q.first()
def get_children(db: Session, parent_id: str, tenant_no: str) -> List[BizStorageFacility]:
"""Direct children of a node."""
return (
db.query(BizStorageFacility)
.filter(
and_(
BizStorageFacility.parent_id == parent_id,
BizStorageFacility.del_flag == 0,
BizStorageFacility.tenant_no == tenant_no,
)
)
.order_by(BizStorageFacility.order_num.asc(), BizStorageFacility.create_time.asc())
.all()
)
def collect_descendant_ids(db: Session, node_id: str, tenant_no: str) -> List[str]:
"""Iteratively collect ALL descendant node IDs (stack-based, no recursion)."""
ids: List[str] = []
stack: List[str] = [node_id]
while stack:
current = stack.pop()
children = get_children(db, current, tenant_no)
for child in children:
ids.append(child.id)
stack.append(child.id)
return ids
def create_facility(
db: Session,
*,
parent_id: str = "0",
node_name: str,
node_code: str,
node_type: Optional[str] = None,
is_leaf: int = 0,
tenant_no: str = "zkxw",
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
) -> BizStorageFacility:
name = (node_name or "").strip()
if not name:
raise ValueError("节点名称不能为空")
code = validate_node_code(node_code)
pid = parent_id or "0"
assert_sibling_node_code_unique(
db, parent_id=pid, node_code=code, tenant_no=tenant_no
)
obj = BizStorageFacility(
id=generate_uuid(),
parent_id=pid,
node_name=name,
node_code=code,
node_type=(node_type or "").strip() or None,
is_leaf=is_leaf,
tenant_no=tenant_no,
order_num=order_num if order_num is not None else 0,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def _format_seq(num: int, pad_width: int) -> str:
s = str(num)
width = int(pad_width or 0)
if width <= 0:
return s
return s.zfill(width)
def batch_create_facilities(
db: Session,
*,
parent_id: str = "0",
name_prefix: str = "",
name_suffix: str = "",
start_num: int,
end_num: int,
pad_width: int = 0,
code_prefix: str = "",
code_suffix: str = "",
node_type: Optional[str] = None,
is_leaf: int = 0,
tenant_no: str = "zkxw",
remark: Optional[str] = None,
user_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""按「前缀 + 序号 + 后缀」批量创建同级子节点。"""
if start_num < 0 or end_num < 0:
raise ValueError("序号不能为负数")
if end_num < start_num:
raise ValueError("结束序号不能小于起始序号")
count = end_num - start_num + 1
if count > 200:
raise ValueError("单次最多创建 200 个子节点")
if pad_width < 0 or pad_width > 8:
raise ValueError("位数补零须在 0~8 之间")
pid = (parent_id or "0").strip() or "0"
if pid != "0":
parent = get_facility(db, pid, tenant_no=tenant_no)
if not parent:
raise ValueError("上级节点不存在")
name_prefix = name_prefix or ""
name_suffix = name_suffix or ""
code_prefix = code_prefix or ""
code_suffix = code_suffix or ""
node_type_val = (node_type or "").strip() or None
remark_val = (remark or "").strip() or None
leaf_val = 1 if int(is_leaf or 0) == 1 else 0
now = datetime.now()
planned: List[tuple[str, str, int]] = []
seen_codes: set[str] = set()
for num in range(start_num, end_num + 1):
seq = _format_seq(num, pad_width)
name = f"{name_prefix}{seq}{name_suffix}".strip()
if not name:
raise ValueError("节点名称不能为空,请填写前缀或后缀")
# 前缀、后缀都空时,节点编号 = 序号(含补零)
code = validate_node_code(f"{code_prefix}{seq}{code_suffix}")
if not code:
raise ValueError("节点编号不能为空")
if code in seen_codes:
raise ValueError(f"批次内节点编号重复:{code}")
seen_codes.add(code)
assert_sibling_node_code_unique(
db, parent_id=pid, node_code=code, tenant_no=tenant_no
)
planned.append((name, code, num))
objs: List[BizStorageFacility] = []
for name, code, num in planned:
obj = BizStorageFacility(
id=generate_uuid(),
parent_id=pid,
node_name=name,
node_code=code,
node_type=node_type_val,
is_leaf=leaf_val,
tenant_no=tenant_no,
order_num=num,
remark=remark_val,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
objs.append(obj)
db.commit()
for obj in objs:
db.refresh(obj)
return [_row_to_dict(obj) for obj in objs]
def update_facility(
db: Session,
node_id: str,
*,
node_name: Optional[str] = None,
node_code: Optional[str] = None,
node_type: Optional[str] = None,
is_leaf: Optional[int] = None,
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
) -> BizStorageFacility:
obj = get_facility(db, node_id, tenant_no=tenant_no)
if not obj:
raise ValueError("存储设施节点不存在")
old_node_code = (obj.node_code or "").strip()
if node_name is not None:
name = (node_name or "").strip()
if not name:
raise ValueError("节点名称不能为空")
obj.node_name = name
if node_code is not None:
code = validate_node_code(node_code)
assert_sibling_node_code_unique(
db,
parent_id=obj.parent_id or "0",
node_code=code,
tenant_no=obj.tenant_no,
exclude_id=obj.id,
)
obj.node_code = code
if node_type is not None:
obj.node_type = (node_type or "").strip() or None
if is_leaf is not None:
obj.is_leaf = is_leaf
if order_num is not None:
obj.order_num = order_num
if remark is not None:
obj.remark = (remark or "").strip() or None
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
node_code_changed = node_code is not None and (obj.node_code or "").strip() != old_node_code
if node_code_changed:
cascade_refresh_slot_name_snapshots_for_nodes(
db,
[node_id],
obj.tenant_no,
collect_descendant_ids_fn=collect_descendant_ids,
)
db.commit()
db.refresh(obj)
return obj
def soft_delete_facility(db: Session, node_id: str, tenant_no: Optional[str] = None) -> None:
"""Cascade soft-delete: node + all descendants + their slots."""
node = get_facility(db, node_id, tenant_no=tenant_no)
if not node:
raise ValueError("存储设施节点不存在")
# Collect all descendant IDs
all_ids = [node_id] + collect_descendant_ids(db, node_id, node.tenant_no)
now = datetime.now()
# Soft-delete all slots belonging to these nodes
db.query(BizStorageFacilitySlot).filter(
BizStorageFacilitySlot.facility_node_id.in_(all_ids),
BizStorageFacilitySlot.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
# Soft-delete all facility nodes
db.query(BizStorageFacility).filter(
BizStorageFacility.id.in_(all_ids),
BizStorageFacility.del_flag == 0,
).update({"del_flag": 1, "update_time": now}, synchronize_session=False)
db.commit()
# ---------------------------------------------------------------------------
# Slot helpers
# ---------------------------------------------------------------------------
def _slot_to_dict(
s: BizStorageFacilitySlot,
facility_by_id: Optional[Dict[str, BizStorageFacility]] = None,
) -> Dict[str, Any]:
occupied_count = s.occupied_count if s.occupied_count is not None else 0
full_slot_code = ""
if facility_by_id is not None:
full_slot_code = build_full_slot_code(s.facility_node_id, s.slot_code, facility_by_id)
return {
"id": s.id,
"facilityNodeId": s.facility_node_id,
"slotCode": s.slot_code or "",
"fullSlotCode": full_slot_code,
"slotStatus": s.slot_status or "",
"occupiedCount": int(occupied_count),
"tenantNo": s.tenant_no,
"orderNum": s.order_num if s.order_num is not None else 0,
"remark": s.remark or "",
"createTime": s.create_time.strftime("%Y-%m-%d %H:%M:%S") if s.create_time else "",
"updateTime": s.update_time.strftime("%Y-%m-%d %H:%M:%S") if s.update_time else "",
}
def aggregate_slot_stats_by_facility(*, query) -> List[Dict[str, Any]]:
"""按设施叶子节点聚合格子总数与占用数(单次 GROUP BY,供树节点徽章展示)。"""
rows = (
query.with_entities(
BizStorageFacilitySlot.facility_node_id,
func.count(BizStorageFacilitySlot.id).label("total"),
func.sum(
case((BizStorageFacilitySlot.slot_status == "占用", 1), else_=0)
).label("occupied"),
)
.group_by(BizStorageFacilitySlot.facility_node_id)
.all()
)
result: List[Dict[str, Any]] = []
for facility_node_id, total, occupied in rows:
total_int = int(total or 0)
occupied_int = int(occupied or 0)
result.append(
{
"facilityNodeId": facility_node_id,
"total": total_int,
"occupied": occupied_int,
"empty": total_int - occupied_int,
}
)
return result
def list_slots(
db: Session,
*,
facility_node_id: str,
tenant_no: Optional[str] = None,
) -> List[Dict[str, Any]]:
q = db.query(BizStorageFacilitySlot).filter(
and_(
BizStorageFacilitySlot.facility_node_id == facility_node_id,
BizStorageFacilitySlot.del_flag == 0,
)
)
if tenant_no:
q = q.filter(BizStorageFacilitySlot.tenant_no == tenant_no)
rows = q.order_by(BizStorageFacilitySlot.order_num.asc(), BizStorageFacilitySlot.create_time.asc()).all()
facility_by_id = load_facility_map(db, tenant_no or "") if tenant_no else {}
items = [_slot_to_dict(x, facility_by_id) for x in rows]
if tenant_no:
from app.crud import storage_occupancy as occupancy_svc
items = occupancy_svc.enrich_slots_with_stored_cell_products(db, tenant_no, items)
return items
def _is_slot_available_row(row: Dict[str, Any]) -> bool:
status = row.get("slotStatus") or ""
occupied = int(row.get("occupiedCount") or 0)
return status == "空" or occupied == 0
def _slot_select_display_name(row: Dict[str, Any]) -> str:
full = (row.get("fullSlotCode") or "").strip()
code = (row.get("slotCode") or "").strip()
return full or code or str(row.get("id") or "")
def _slot_available_filter():
"""空格子:状态为「空」或 occupied_count 为 0 / NULL。"""
return or_(
BizStorageFacilitySlot.slot_status == "空",
BizStorageFacilitySlot.occupied_count == 0,
BizStorageFacilitySlot.occupied_count.is_(None),
)
def _facility_node_ids_matching_keyword(
db: Session,
tenant_no: str,
keyword: str,
) -> set[str]:
like = f"%{keyword.strip()}%"
return {
row[0]
for row in db.query(BizStorageFacility.id)
.filter(
BizStorageFacility.tenant_no == tenant_no,
BizStorageFacility.del_flag == 0,
or_(
BizStorageFacility.node_name.like(like),
BizStorageFacility.node_code.like(like),
),
)
.all()
}
def _slot_matches_select_keyword(
slot: BizStorageFacilitySlot,
keyword: str,
facility_by_id: Dict[str, BizStorageFacility],
matching_node_ids: set[str],
) -> bool:
"""下拉关键词:完整 fullSlotCode 子串 + 备注 + 设施节点名/编号。"""
kw = keyword.strip().lower()
if not kw:
return True
row = _slot_to_dict(slot, facility_by_id)
display = _slot_select_display_name(row).lower()
if kw in display:
return True
remark = (row.get("remark") or "").lower()
if kw in remark:
return True
if slot.facility_node_id in matching_node_ids:
return True
return False
def _leaf_node_ids_for_path_prefix(
facility_by_id: Dict[str, BizStorageFacility],
path_prefix: str,
) -> set[str]:
"""按设施 node_code 路径前缀/子串匹配叶子节点(储物格挂在节点上)。"""
prefix = (path_prefix or "").strip().lower()
if not prefix:
return set()
result: set[str] = set()
for node_id in facility_by_id:
path = build_node_code_path(node_id, facility_by_id).lower()
if not path:
continue
if (
prefix in path
or path in prefix
or path.startswith(f"{prefix}-")
or prefix.startswith(f"{path}-")
):
result.add(node_id)
return result
def _build_node_path_cache(
facility_by_id: Dict[str, BizStorageFacility],
) -> Dict[str, str]:
return {
node_id: build_node_code_path(node_id, facility_by_id)
for node_id in facility_by_id
}
def _exact_node_ids_for_path(
path_cache: Dict[str, str],
path: str,
) -> List[str]:
target = (path or "").strip().lower()
if not target:
return []
return [
node_id
for node_id, node_path in path_cache.items()
if (node_path or "").strip().lower() == target
]
def _collect_slot_host_node_ids(
path_cache: Dict[str, str],
facility_by_id: Dict[str, BizStorageFacility],
path: str,
) -> List[str]:
"""储物格只挂在叶子节点;路径命中非叶子(如 1-1 架子)时展开其下全部叶子层。"""
target = (path or "").strip().lower()
if not target:
return []
anchor_ids = _exact_node_ids_for_path(path_cache, path)
if not anchor_ids:
return []
host_ids: List[str] = []
seen: set[str] = set()
child_prefix = f"{target}-"
for anchor_id in anchor_ids:
anchor = facility_by_id.get(anchor_id)
if anchor and int(getattr(anchor, "is_leaf", 0) or 0) == 1:
if anchor_id not in seen:
seen.add(anchor_id)
host_ids.append(anchor_id)
continue
for node_id, node_path in path_cache.items():
if node_id in seen:
continue
node = facility_by_id.get(node_id)
if not node or int(getattr(node, "is_leaf", 0) or 0) != 1:
continue
np = (node_path or "").strip().lower()
if np.startswith(child_prefix):
seen.add(node_id)
host_ids.append(node_id)
return host_ids
def _resolve_path_keyword(
path_cache: Dict[str, str],
keyword: str,
) -> tuple[Optional[str], Optional[str]]:
"""
解析路径型储物格编号关键词。
- '1-1-2-' -> ('1-1-2', None) 整盒
- '1-1-2-1' -> ('1-1-2', '1') 精确格位
- '1-1-2' -> ('1-1-2', None) 若存在该节点路径
"""
kw = (keyword or "").strip()
if not kw or "-" not in kw:
return None, None
if kw.endswith("-"):
path = kw.rstrip("-").strip()
if path and _exact_node_ids_for_path(path_cache, path):
return path, None
return None, None
known_paths = {
(path or "").strip().lower()
for path in path_cache.values()
if (path or "").strip()
}
parts = kw.split("-")
for i in range(len(parts), 0, -1):
candidate = "-".join(parts[:i]).strip()
if not candidate:
continue
if candidate.lower() not in known_paths:
continue
slot_part = "-".join(parts[i:]).strip() if i < len(parts) else ""
return candidate, slot_part or None
return None, None
def _build_slot_keyword_sql_filters(
db: Session,
tenant_no: str,
keyword: str,
facility_by_id: Dict[str, BizStorageFacility],
matching_node_ids: set[str],
):
"""SQL 预筛:slot_code / remark / 设施节点 + 路径型关键词分段,缩小 Python 校验范围。"""
kw = keyword.strip()
like = f"%{kw}%"
filters = [
BizStorageFacilitySlot.slot_code.like(like),
BizStorageFacilitySlot.remark.like(like),
]
if matching_node_ids:
filters.append(BizStorageFacilitySlot.facility_node_id.in_(list(matching_node_ids)))
path_cache = _build_node_path_cache(facility_by_id)
path_prefix, slot_part = _resolve_path_keyword(path_cache, kw)
if path_prefix:
host_nodes = _collect_slot_host_node_ids(path_cache, facility_by_id, path_prefix)
if host_nodes:
filters.append(BizStorageFacilitySlot.facility_node_id.in_(host_nodes))
if slot_part:
filters.append(BizStorageFacilitySlot.slot_code.like(f"{slot_part}%"))
elif "-" in kw:
prefix, loose_slot_part = kw.rsplit("-", 1)
prefix = prefix.strip()
loose_slot_part = loose_slot_part.strip()
if loose_slot_part:
filters.append(BizStorageFacilitySlot.slot_code.like(f"%{loose_slot_part}%"))
if prefix:
path_node_ids = _leaf_node_ids_for_path_prefix(facility_by_id, prefix)
if path_node_ids:
filters.append(BizStorageFacilitySlot.facility_node_id.in_(list(path_node_ids)))
return filters
# ---------------------------------------------------------------------------
# Slot select options (remote dropdown)
# ---------------------------------------------------------------------------
SLOT_SELECT_DEFAULT_PAGE_SIZE = 20
SLOT_SELECT_MAX_PAGE_SIZE = 50
SLOT_SELECT_KEYWORD_SCAN_LIMIT = 120
def _apply_slot_select_base_filters(
query,
*,
available_only: bool = False,
include_slot_ids: Optional[List[str]] = None,
):
include_set = {
str(x).strip()
for x in (include_slot_ids or [])
if str(x).strip()
}
filtered = query
if available_only:
availability = _slot_available_filter()
if include_set:
filtered = filtered.filter(
or_(BizStorageFacilitySlot.id.in_(list(include_set)), availability)
)
else:
filtered = filtered.filter(availability)
return filtered
def _search_slot_select_matched_ids(
db: Session,
filtered,
tenant_no: str,
keyword: str,
*,
max_results: int,
) -> List[str]:
"""关键词匹配储物格 ID(限量扫描,路径型编码优先精确匹配)。"""
kw = (keyword or "").strip()
if not kw or max_results <= 0:
return []
facility_by_id = load_facility_map(db, tenant_no)
path_cache = _build_node_path_cache(facility_by_id)
matching_node_ids = _facility_node_ids_matching_keyword(db, tenant_no, kw)
matched_ids: List[str] = []
seen: set[str] = set()
def collect(rows: List[BizStorageFacilitySlot]) -> bool:
for slot in rows:
sid = slot.id
if sid in seen:
continue
if _slot_matches_select_keyword(slot, kw, facility_by_id, matching_node_ids):
seen.add(sid)
matched_ids.append(sid)
if len(matched_ids) >= max_results:
return True
return False
order_clause = (
BizStorageFacilitySlot.order_num.asc(),
BizStorageFacilitySlot.create_time.asc(),
)
# 快路径:路径型编码 1-1-2- / 1-1-2 / 1-1-2-1 → 设施路径 + 可选格位前缀(非叶子展开子层)
path_prefix, slot_part = _resolve_path_keyword(path_cache, kw)
if path_prefix:
host_nodes = _collect_slot_host_node_ids(path_cache, facility_by_id, path_prefix)
if host_nodes:
exact_q = filtered.filter(
BizStorageFacilitySlot.facility_node_id.in_(host_nodes)
)
if slot_part:
exact_q = exact_q.filter(
BizStorageFacilitySlot.slot_code.like(f"{slot_part}%")
)
exact_rows = (
exact_q.order_by(*order_clause)
.limit(max(max_results * 2, 10))
.all()
)
if collect(exact_rows):
return matched_ids
if matched_ids:
return matched_ids
keyword_filters = _build_slot_keyword_sql_filters(
db, tenant_no, kw, facility_by_id, matching_node_ids
)
sql_q = filtered.filter(or_(*keyword_filters)).order_by(*order_clause)
offset = 0
batch_size = 40
while len(matched_ids) < max_results and offset < SLOT_SELECT_KEYWORD_SCAN_LIMIT:
rows = sql_q.offset(offset).limit(batch_size).all()
if not rows:
break
if collect(rows):
return matched_ids
offset += batch_size
return matched_ids
def search_slot_ids_by_keyword(
db: Session,
tenant_no: str,
keyword: str,
*,
max_results: int = SLOT_SELECT_KEYWORD_SCAN_LIMIT,
) -> List[str]:
"""按路径/格位编码关键词解析储物格 ID(供细胞产品列表等跨实体检索复用)。"""
kw = (keyword or "").strip()
if not kw or max_results <= 0:
return []
base = db.query(BizStorageFacilitySlot).filter(
BizStorageFacilitySlot.del_flag == 0,
BizStorageFacilitySlot.tenant_no == tenant_no,
)
return _search_slot_select_matched_ids(
db,
base,
tenant_no,
kw,
max_results=max_results,
)
def _apply_slot_select_query_filters(
db: Session,
query,
*,
tenant_no: str,
keyword: Optional[str] = None,
available_only: bool = False,
include_slot_ids: Optional[List[str]] = None,
):
"""兼容旧调用:无关键词时等同 base;有关键词时返回 ID 过滤后的 query。"""
filtered = _apply_slot_select_base_filters(
query,
available_only=available_only,
include_slot_ids=include_slot_ids,
)
kw = (keyword or "").strip()
if not kw:
return filtered
matched_ids = _search_slot_select_matched_ids(
db,
filtered,
tenant_no,
kw,
max_results=SLOT_SELECT_KEYWORD_SCAN_LIMIT,
)
if not matched_ids:
return filtered.filter(false())
return filtered.filter(BizStorageFacilitySlot.id.in_(matched_ids))
def _load_slot_select_include_rows(query, include_slot_ids: Optional[List[str]]):
"""按传入顺序加载需强制保留的储物格(编辑态当前选中格)。"""
include_ids = [
str(x).strip()
for x in (include_slot_ids or [])
if str(x).strip()
]
if not include_ids:
return []
rows_by_id = {
row.id: row
for row in query.filter(BizStorageFacilitySlot.id.in_(include_ids)).all()
}
return [rows_by_id[sid] for sid in include_ids if sid in rows_by_id]
def _merge_slot_select_rows_with_includes(
page_rows: List[BizStorageFacilitySlot],
include_rows: List[BizStorageFacilitySlot],
) -> List[BizStorageFacilitySlot]:
"""将 include 格置顶合并进当前页,避免分页把当前选中格挤出导致下拉显示 id。"""
merged: List[BizStorageFacilitySlot] = []
seen: set[str] = set()
for row in list(include_rows or []) + list(page_rows or []):
sid = row.id
if not sid or sid in seen:
continue
seen.add(sid)
merged.append(row)
return merged
def list_slot_select_options(
db: Session,
*,
query,
tenant_no: str,
keyword: Optional[str] = None,
available_only: bool = False,
include_slot_ids: Optional[List[str]] = None,
page: int = 1,
page_size: int = SLOT_SELECT_DEFAULT_PAGE_SIZE,
) -> tuple[List[Dict[str, Any]], int]:
"""储物格下拉远程选项:默认 20 条;关键词检索限量扫描 + 路径快路径。"""
page = max(int(page or 1), 1)
page_size = max(
min(int(page_size or SLOT_SELECT_DEFAULT_PAGE_SIZE), SLOT_SELECT_MAX_PAGE_SIZE),
1,
)
need_count = page * page_size
include_rows = _load_slot_select_include_rows(query, include_slot_ids)
filtered = _apply_slot_select_base_filters(
query,
available_only=available_only,
include_slot_ids=include_slot_ids,
)
kw = (keyword or "").strip()
order_clause = (
BizStorageFacilitySlot.order_num.asc(),
BizStorageFacilitySlot.create_time.asc(),
)
if kw:
matched_ids = _search_slot_select_matched_ids(
db,
filtered,
tenant_no,
kw,
max_results=need_count,
)
total = len(matched_ids)
page_ids = matched_ids[(page - 1) * page_size : page * page_size]
rows_by_id = {
row.id: row
for row in (
filtered.filter(BizStorageFacilitySlot.id.in_(page_ids)).all()
if page_ids
else []
)
}
rows = [rows_by_id[sid] for sid in page_ids if sid in rows_by_id]
else:
total = (
filtered.order_by(None)
.with_entities(func.count(BizStorageFacilitySlot.id))
.scalar()
or 0
)
rows = (
filtered.order_by(*order_clause)
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
rows = _merge_slot_select_rows_with_includes(rows, include_rows)
if not rows:
return [], int(total)
facility_by_id = load_facility_map(db, tenant_no)
result_list = []
for slot_row in rows:
row = _slot_to_dict(slot_row, facility_by_id)
result_list.append(
{
"id": row.get("id"),
"name": _slot_select_display_name(row),
"fullSlotCode": row.get("fullSlotCode") or "",
"slotCode": row.get("slotCode") or "",
"facilityNodeId": row.get("facilityNodeId") or "",
"slotStatus": row.get("slotStatus") or "",
"occupiedCount": int(row.get("occupiedCount") or 0),
}
)
return result_list, int(total)
def get_slot(db: Session, slot_id: str, tenant_no: Optional[str] = None) -> Optional[BizStorageFacilitySlot]:
q = db.query(BizStorageFacilitySlot).filter(
and_(BizStorageFacilitySlot.id == slot_id, BizStorageFacilitySlot.del_flag == 0)
)
if tenant_no:
q = q.filter(BizStorageFacilitySlot.tenant_no == tenant_no)
return q.first()
def _slot_to_dict_for_row(db: Session, s: BizStorageFacilitySlot) -> Dict[str, Any]:
facility_by_id = load_facility_map(db, s.tenant_no)
return _slot_to_dict(s, facility_by_id)
def create_slot(
db: Session,
*,
facility_node_id: str,
slot_code: Optional[str] = None,
slot_status: Optional[str] = None,
tenant_no: str = "zkxw",
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
at_row_num: Optional[int] = None,
) -> BizStorageFacilitySlot:
fid = (facility_node_id or "").strip()
if not fid:
raise ValueError("所属设施节点不能为空")
if at_row_num is not None:
return insert_slot_at_row_end(
db,
facility_node_id=fid,
row_num=at_row_num,
slot_status=slot_status,
tenant_no=tenant_no,
remark=remark,
user_id=user_id,
)
obj = BizStorageFacilitySlot(
id=generate_uuid(),
facility_node_id=fid,
slot_code=(slot_code or "").strip() or None,
slot_status=(slot_status or "").strip() or None,
tenant_no=tenant_no,
order_num=order_num if order_num is not None else 0,
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=datetime.now(),
update_by=user_id,
update_time=datetime.now(),
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def insert_slot_at_row_end(
db: Session,
*,
facility_node_id: str,
row_num: int,
slot_status: Optional[str] = None,
tenant_no: str = "zkxw",
remark: Optional[str] = None,
user_id: Optional[str] = None,
) -> BizStorageFacilitySlot:
fid = (facility_node_id or "").strip()
row = int(row_num)
if row < 1:
raise ValueError("行号参数不合法")
node = db.query(BizStorageFacility).filter(
and_(BizStorageFacility.id == fid, BizStorageFacility.del_flag == 0)
).first()
if not node:
raise ValueError("所属设施节点不存在")
if not node.is_leaf:
raise ValueError("只能为叶子节点创建格子")
max_col = 0
for slot in _list_facility_slots(db, fid, tenant_no):
slot_row, slot_col = _resolve_slot_layout_position(slot)
if slot_row == row:
max_col = max(max_col, slot_col)
_ensure_slot_layout_order(slot, user_id=user_id)
now = datetime.now()
obj = BizStorageFacilitySlot(
id=generate_uuid(),
facility_node_id=fid,
slot_code=None,
slot_status=(slot_status or "空").strip() or "空",
tenant_no=tenant_no,
order_num=encode_slot_layout_order(row, max_col + 1),
remark=(remark or "").strip() or None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_slot(
db: Session,
slot_id: str,
*,
slot_code: Optional[str] = None,
slot_status: Optional[str] = None,
order_num: Optional[int] = None,
remark: Optional[str] = None,
user_id: Optional[str] = None,
tenant_no: Optional[str] = None,
) -> BizStorageFacilitySlot:
obj = get_slot(db, slot_id, tenant_no=tenant_no)
if not obj:
raise ValueError("存储设施小格不存在")
old_slot_code = (obj.slot_code or "").strip()
if slot_code is not None:
obj.slot_code = (slot_code or "").strip() or None
if slot_status is not None:
obj.slot_status = (slot_status or "").strip() or None
if order_num is not None:
obj.order_num = order_num
if remark is not None:
obj.remark = (remark or "").strip() or None
obj.update_by = user_id
obj.update_time = datetime.now()
db.add(obj)
slot_code_changed = slot_code is not None and (obj.slot_code or "").strip() != old_slot_code
if slot_code_changed:
cascade_refresh_slot_name_snapshots_for_slots(db, [slot_id], obj.tenant_no)
db.commit()
db.refresh(obj)
return obj
def soft_delete_slot(db: Session, slot_id: str, tenant_no: Optional[str] = None, user_id: Optional[str] = None) -> None:
obj = get_slot(db, slot_id, tenant_no=tenant_no)
if not obj:
raise ValueError("存储设施小格不存在")
obj.del_flag = 1
obj.update_time = datetime.now()
obj.update_by = user_id
db.add(obj)
db.commit()
def clear_slots_by_facility_node(
db: Session,
*,
facility_node_id: str,
tenant_no: str,
user_id: Optional[str] = None,
) -> int:
fid = (facility_node_id or "").strip()
if not fid:
raise ValueError("所属设施节点不能为空")
node = db.query(BizStorageFacility).filter(
and_(
BizStorageFacility.id == fid,
BizStorageFacility.del_flag == 0,
BizStorageFacility.tenant_no == tenant_no,
)
).first()
if not node:
raise ValueError("所属设施节点不存在")
now = datetime.now()
updated = db.query(BizStorageFacilitySlot).filter(
BizStorageFacilitySlot.facility_node_id == fid,
BizStorageFacilitySlot.del_flag == 0,
BizStorageFacilitySlot.tenant_no == tenant_no,
).update(
{"del_flag": 1, "update_time": now, "update_by": user_id},
synchronize_session=False
)
db.commit()
return int(updated or 0)
# ---------------------------------------------------------------------------
# Slot layout & sequential code helpers
# ---------------------------------------------------------------------------
def _resolve_slot_layout_position(slot: BizStorageFacilitySlot) -> tuple[int, int]:
order = int(slot.order_num or 0)
row, col = decode_slot_layout_order(order)
if row > 0:
return row, col
match = re.match(r"^(.*?)(\d+)-(\d+)$", (slot.slot_code or "").strip())
if match:
return int(match.group(2)), int(match.group(3))
return 0, 0
def _ensure_slot_layout_order(
slot: BizStorageFacilitySlot,
*,
user_id: Optional[str] = None,
) -> tuple[int, int]:
row, col = _resolve_slot_layout_position(slot)
if row > 0:
expected = encode_slot_layout_order(row, col)
if int(slot.order_num or 0) != expected:
slot.order_num = expected
if user_id:
slot.update_by = user_id
slot.update_time = datetime.now()
return row, col
return 0, 0
def _list_facility_slots(
db: Session,
facility_node_id: str,
tenant_no: str,
) -> List[BizStorageFacilitySlot]:
return (
db.query(BizStorageFacilitySlot)
.filter(
BizStorageFacilitySlot.facility_node_id == facility_node_id,
BizStorageFacilitySlot.del_flag == 0,
BizStorageFacilitySlot.tenant_no == tenant_no,
)
.order_by(
BizStorageFacilitySlot.order_num.asc(),
BizStorageFacilitySlot.create_time.asc(),
)
.all()
)
def batch_create_slots(
db: Session,
*,
facility_node_id: str,
rows: int,
cols: int,
tenant_no: str = "zkxw",
user_id: Optional[str] = None,
) -> List[Dict[str, Any]]:
fid = (facility_node_id or "").strip()
if not fid:
raise ValueError("所属设施节点不能为空")
if rows < 1 or cols < 1:
raise ValueError("行数和列数必须大于 0")
if rows > 50 or cols > 50:
raise ValueError("单次最多生成 50×50 = 2500 个格子")
node = db.query(BizStorageFacility).filter(
and_(BizStorageFacility.id == fid, BizStorageFacility.del_flag == 0)
).first()
if not node:
raise ValueError("所属设施节点不存在")
if not node.is_leaf:
raise ValueError("只能为叶子节点创建格子")
now = datetime.now()
slots: List[BizStorageFacilitySlot] = []
seq = 0
for r in range(1, rows + 1):
for c in range(1, cols + 1):
seq += 1
obj = BizStorageFacilitySlot(
id=generate_uuid(),
facility_node_id=fid,
slot_code=str(seq),
slot_status="空",
tenant_no=tenant_no,
order_num=encode_slot_layout_order(r, c),
remark=None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
db.add(obj)
slots.append(obj)
db.commit()
facility_by_id = load_facility_map(db, tenant_no)
for s in slots:
db.refresh(s)
return [_slot_to_dict(s, facility_by_id) for s in slots]
def insert_row_and_shift_slots(
db: Session,
*,
facility_node_id: str,
after_row_num: int,
cols: int,
tenant_no: str = "zkxw",
user_id: Optional[str] = None,
) -> Dict[str, int]:
fid = (facility_node_id or "").strip()
if not fid:
raise ValueError("所属设施节点不能为空")
if after_row_num < 0:
raise ValueError("插入行号参数不合法")
if cols < 1:
raise ValueError("列数必须大于 0")
node = db.query(BizStorageFacility).filter(
and_(
BizStorageFacility.id == fid,
BizStorageFacility.del_flag == 0,
BizStorageFacility.tenant_no == tenant_no,
)
).first()
if not node:
raise ValueError("所属设施节点不存在")
if not node.is_leaf:
raise ValueError("只能为叶子节点新增格子行")
slots = _list_facility_slots(db, fid, tenant_no)
now = datetime.now()
shifted_count = 0
for slot in slots:
row_no, col_no = _ensure_slot_layout_order(slot, user_id=user_id)
if row_no <= 0:
continue
if row_no > after_row_num:
slot.order_num = encode_slot_layout_order(row_no + 1, col_no)
slot.update_by = user_id
slot.update_time = now
shifted_count += 1
insert_row_num = after_row_num + 1
created_slots: List[BizStorageFacilitySlot] = []
for col in range(1, cols + 1):
created_slots.append(
BizStorageFacilitySlot(
id=generate_uuid(),
facility_node_id=fid,
slot_code=None,
slot_status="空",
tenant_no=tenant_no,
order_num=encode_slot_layout_order(insert_row_num, col),
remark=None,
create_by=user_id,
create_dept_id="",
create_time=now,
update_by=user_id,
update_time=now,
del_flag=0,
)
)
if created_slots:
db.add_all(created_slots)
db.commit()
return {"shiftedCount": shifted_count, "createdCount": len(created_slots)}