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