| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255 |
- """存储设施(树结构)+ 小格 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 _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)}
|