"""存储设施(树结构)+ 小格 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 "" gen_code = bool(code_prefix.strip() or code_suffix.strip()) 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 = "" if gen_code: 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 _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 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 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] if not page_ids: return [], total rows_by_id = { row.id: row for row in filtered.filter(BizStorageFacilitySlot.id.in_(page_ids)).all() } 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() ) 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)}