"""方案分类树、写操作、路径冗余和占用规则。""" from __future__ import annotations from collections import deque from datetime import datetime, timezone from typing import Any from sqlalchemy import exists, func, select, update from sqlalchemy.exc import IntegrityError from dms.common.enums import ( AuditAction, AuditTarget, CategoryType, DocumentType, EnabledStatus, ) from dms.common.errors import ( CategoryFlatModeConflictError, CategoryInUseError, CategoryNotLeafError, ConflictError, InvalidArgumentError, ResourceNotFoundError, ) from dms.common.response import serialize_id from dms.database.transaction import execute_with_deadlock_retry, transaction from dms.extensions import db from dms.models import AttachmentBinding, Category, Document, SubPlanBinding from dms.security.auth_context import get_auth_context from dms.services.audit_service import business_audit CREATE_FIELDS = { "categoryCode", "categoryName", "categoryType", "parentId", "sortNo", "flatMode", "typeDisplayName", } UPDATE_FIELDS = { "categoryName", "categoryType", "parentId", "sortNo", "rowVersion", "flatMode", "typeDisplayName", } def _utc_now() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) def _string_id(value: Any, *, field: str, nullable: bool = False) -> int | None: if value is None and nullable: return None if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0: raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID") return int(value) def _required_text( value: Any, *, field: str, max_length: int, ) -> str: if not isinstance(value, str) or not value.strip(): raise InvalidArgumentError(f"{field}不能为空") normalized = value.strip() if len(normalized) > max_length: raise InvalidArgumentError(f"{field}长度不能超过{max_length}") return normalized def _category_type(value: Any) -> str: if not isinstance(value, str): raise InvalidArgumentError("categoryType必须是字符串") try: return CategoryType(value).value except ValueError as exc: raise InvalidArgumentError("categoryType不是有效枚举值") from exc def _sort_no(value: Any) -> int: if type(value) is not int: raise InvalidArgumentError("sortNo必须是整数") return value def _row_version(value: Any) -> int: if type(value) is not int or value < 0: raise InvalidArgumentError("rowVersion必须是非负整数") return value def _bool_field(value: Any, *, field: str) -> bool: if not isinstance(value, bool): raise InvalidArgumentError(f"{field}必须是布尔值") return value def _nullable_text(value: Any, *, field: str, max_length: int) -> str | None: if value is None: return None if not isinstance(value, str): raise InvalidArgumentError(f"{field}必须是字符串或null") normalized = value.strip() if not normalized: return None if len(normalized) > max_length: raise InvalidArgumentError(f"{field}长度不能超过{max_length}") return normalized def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]: if not isinstance(payload, dict) or set(payload) != expected: raise InvalidArgumentError( "请求体字段必须且只能包含:" + "、".join(sorted(expected)) ) return payload def _path(parent: Category | None, category_name: str) -> str: value = ( f"{parent.category_path.rstrip('/')}/{category_name}" if parent is not None else f"/{category_name}" ) if len(value) > 1000: raise InvalidArgumentError("categoryPath长度超过数据库限制") return value def _dto( category: Category, *, parent_id: int | None = None, document_count: int | None = None, children: list[dict[str, object]] | None = None, ) -> dict[str, object]: return { "id": serialize_id(category.id), "categoryCode": category.category_code, "categoryName": category.category_name, "categoryType": category.category_type, "parentId": serialize_id( category.parent_id if parent_id is None else parent_id ), "categoryPath": category.category_path, "sortNo": category.sort_no, "documentCount": ( category.document_count if document_count is None else document_count ), "flatMode": bool(category.flat_mode), "typeDisplayName": category.type_display_name, "rowVersion": category.row_version, "children": children or [], } def _children_map(categories: list[Category]) -> dict[int | None, list[Category]]: children: dict[int | None, list[Category]] = {} for category in categories: children.setdefault(category.parent_id, []).append(category) return children def _descendants( category_id: int, children: dict[int | None, list[Category]], ) -> list[Category]: result: list[Category] = [] pending = deque(children.get(category_id, [])) seen = {category_id} while pending: category = pending.popleft() if category.id in seen: raise ConflictError("分类树存在循环关系") seen.add(category.id) result.append(category) pending.extend(children.get(category.id, [])) return result def category_tree( *, keyword: str | None, status: str | None, ) -> list[dict[str, object]]: if status: try: normalized_status = EnabledStatus(status).value except ValueError as exc: raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc else: normalized_status = EnabledStatus.ENABLED.value categories = db.session.scalars( select(Category) .where(Category.is_deleted.is_(False)) .order_by(Category.sort_no.asc(), Category.id.asc()) ).all() # 计数口径:本分类与全部后代分类下的所有有效主体方案(MAIN), # 不按密级/状态/可见性/授权过滤——与"列表全量可见、内容访问受控"一致, # 括号数字反映分类真实挂载规模,无权限方案由列表/详情接口单独控制。 direct_main_counts = dict( db.session.execute( select(Document.category_id, func.count(Document.id)) .where( Document.category_id.is_not(None), Document.document_type == DocumentType.MAIN.value, Document.is_deleted.is_(False), ) .group_by(Document.category_id) ).all() ) by_id = {item.id: item for item in categories} children = _children_map(categories) eligible_ids = { item.id for item in categories if item.status == normalized_status } if keyword and keyword.strip(): needle = keyword.strip().casefold() matched_ids = { item.id for item in categories if item.id in eligible_ids and ( needle in item.category_name.casefold() or needle in item.category_code.casefold() ) } included_ids = set(matched_ids) for matched_id in tuple(matched_ids): included_ids.update( descendant.id for descendant in _descendants(matched_id, children) if descendant.id in eligible_ids ) parent_id = by_id[matched_id].parent_id while parent_id is not None and parent_id in by_id: included_ids.add(parent_id) parent_id = by_id[parent_id].parent_id else: included_ids = eligible_ids # 沿父链向上累加时穿过停用分类节点,保证父分类计数 = 子树全部主案数。 subtree_counts = {category.id: 0 for category in categories} for category_id, count in direct_main_counts.items(): current_id = category_id seen: set[int] = set() while current_id in subtree_counts and current_id not in seen: seen.add(current_id) subtree_counts[current_id] += int(count) current_id = by_id[current_id].parent_id nodes: dict[int, dict[str, object]] = {} for category in categories: if category.id in included_ids: response_parent_id = ( category.parent_id if category.parent_id in included_ids else None ) nodes[category.id] = _dto( category, document_count=subtree_counts.get(category.id, 0), ) nodes[category.id]["parentId"] = serialize_id(response_parent_id) roots: list[dict[str, object]] = [] for category in categories: node = nodes.get(category.id) if node is None: continue if category.parent_id in nodes: nodes[category.parent_id]["children"].append(node) else: roots.append(node) return roots def _valid_parent(session, parent_id: int | None) -> Category | None: if parent_id is None: return None parent = session.scalar( select(Category) .where( Category.id == parent_id, Category.is_deleted.is_(False), Category.status == EnabledStatus.ENABLED.value, ) .with_for_update() ) if parent is None: raise InvalidArgumentError("parentId必须指向有效且已启用的分类") direct_main_count = session.scalar( select(func.count(Document.id)).where( Document.category_id == parent.id, Document.document_type == DocumentType.MAIN.value, Document.is_deleted.is_(False), ) ) or 0 if direct_main_count: child_count = session.scalar( select(func.count(Category.id)).where( Category.parent_id == parent.id, Category.is_deleted.is_(False), Category.status == EnabledStatus.ENABLED.value, ) ) or 0 raise CategoryNotLeafError( details={ "categoryId": serialize_id(parent.id), "childCategoryCount": int(child_count), } ) return parent def create_category(payload: Any) -> dict[str, object]: body = _exact_payload(payload, CREATE_FIELDS) category_code = _required_text( body["categoryCode"], field="categoryCode", max_length=64, ) category_name = _required_text( body["categoryName"], field="categoryName", max_length=128, ) category_type = _category_type(body["categoryType"]) parent_id = _string_id(body["parentId"], field="parentId", nullable=True) sort_no = _sort_no(body["sortNo"]) # flat_mode 仅一级分类(parent_id IS NULL)有效;子分类强制 False flat_mode_input = _bool_field(body["flatMode"], field="flatMode") effective_flat_mode = False if parent_id is not None else flat_mode_input # type_display_name 仅一级分类有效;子分类强制 None type_display_name_input = _nullable_text( body["typeDisplayName"], field="typeDisplayName", max_length=128 ) effective_type_display_name = ( None if parent_id is not None else type_display_name_input ) actor_id = get_auth_context().user_id try: with transaction() as session: if session.scalar( select(Category.id).where(Category.category_code == category_code) ) is not None: raise ConflictError("分类编码已存在") parent = _valid_parent(session, parent_id) category = Category( category_code=category_code, category_name=category_name, category_type=category_type, parent_id=parent_id, category_path=_path(parent, category_name), sort_no=sort_no, document_count=0, status=EnabledStatus.ENABLED.value, flat_mode=effective_flat_mode, type_display_name=effective_type_display_name, created_by=actor_id, updated_by=actor_id, ) session.add(category) session.flush() session.add( business_audit( action=AuditAction.CREATE_CATEGORY, target=AuditTarget.CATEGORY, target_id=category.id, target_name=category.category_name, detail={ "categoryCode": category.category_code, "categoryType": category.category_type, "parentId": serialize_id(category.parent_id), "categoryPath": category.category_path, "sortNo": category.sort_no, "flatMode": bool(category.flat_mode), "typeDisplayName": category.type_display_name, }, ) ) return _dto(category) except IntegrityError as exc: raise ConflictError("分类编码已存在") from exc def _locked_categories(session) -> list[Category]: return session.scalars( select(Category) .where(Category.is_deleted.is_(False)) .order_by(Category.id.asc()) .with_for_update() ).all() def update_category(category_id: int, payload: Any) -> dict[str, object]: body = _exact_payload(payload, UPDATE_FIELDS) category_name = _required_text( body["categoryName"], field="categoryName", max_length=128, ) category_type = _category_type(body["categoryType"]) parent_id = _string_id(body["parentId"], field="parentId", nullable=True) sort_no = _sort_no(body["sortNo"]) flat_mode_input = _bool_field(body["flatMode"], field="flatMode") # type_display_name 仅一级分类有效;子分类强制 None(与 flat_mode 口径一致) type_display_name_input = _nullable_text( body["typeDisplayName"], field="typeDisplayName", max_length=128 ) expected_version = _row_version(body["rowVersion"]) actor_id = get_auth_context().user_id now = _utc_now() with transaction() as session: categories = _locked_categories(session) by_id = {item.id: item for item in categories} category = by_id.get(category_id) if category is None: raise ResourceNotFoundError("分类不存在") if category.row_version != expected_version: raise ConflictError( "数据已被其他用户修改,请刷新后重试", details={"currentRowVersion": category.row_version}, ) children = _children_map(categories) descendants = _descendants(category.id, children) descendant_ids = {item.id for item in descendants} if parent_id == category.id: raise InvalidArgumentError("parentId不能等于当前分类ID") if parent_id in descendant_ids: raise InvalidArgumentError("分类不能移动到自己的后代节点下") parent = None if parent_id is not None: parent = by_id.get(parent_id) if ( parent is None or parent.is_deleted or parent.status != EnabledStatus.ENABLED.value ): raise InvalidArgumentError("parentId必须指向有效且已启用的分类") if parent_id != category.parent_id: direct_main_count = session.scalar( select(func.count(Document.id)).where( Document.category_id == parent.id, Document.document_type == DocumentType.MAIN.value, Document.is_deleted.is_(False), ) ) or 0 if direct_main_count: child_count = session.scalar( select(func.count(Category.id)).where( Category.parent_id == parent.id, Category.is_deleted.is_(False), Category.status == EnabledStatus.ENABLED.value, ) ) or 0 raise CategoryNotLeafError( details={ "categoryId": serialize_id(parent.id), "childCategoryCount": int(child_count), } ) # flat_mode 切换保护:仅一级分类可启用;false→true 时拒绝已存在挂载数据 new_flat_mode = False if category.parent_id is not None else flat_mode_input new_type_display_name = ( None if category.parent_id is not None else type_display_name_input ) if new_flat_mode and not category.flat_mode: check_category_ids = descendant_ids | {category.id} main_plan_ids_subq = select(Document.id).where( Document.document_type == DocumentType.MAIN.value, Document.is_deleted.is_(False), Document.category_id.in_(check_category_ids), ) has_sub_plan = session.scalar( select( exists().where( Document.document_type == DocumentType.SUB_PLAN.value, Document.is_deleted.is_(False), Document.parent_document_id.in_(main_plan_ids_subq), ) ) ) has_attachment = session.scalar( select( exists().where( AttachmentBinding.is_deleted.is_(False), AttachmentBinding.main_document_id.in_( main_plan_ids_subq ), ) ) ) has_sub_mount = session.scalar( select( exists().where( SubPlanBinding.is_deleted.is_(False), SubPlanBinding.main_document_id.in_( main_plan_ids_subq ), ) ) ) if has_sub_plan or has_attachment or has_sub_mount: raise CategoryFlatModeConflictError( details={ "categoryId": serialize_id(category.id), "hasSubPlan": bool(has_sub_plan), "hasAttachment": bool(has_attachment), "hasSubPlanMount": bool(has_sub_mount), } ) before = { "categoryName": category.category_name, "categoryType": category.category_type, "parentId": serialize_id(category.parent_id), "categoryPath": category.category_path, "sortNo": category.sort_no, "flatMode": bool(category.flat_mode), "typeDisplayName": category.type_display_name, "rowVersion": category.row_version, } path_changed = ( category_name != category.category_name or parent_id != category.parent_id ) category.category_name = category_name category.category_type = category_type category.parent_id = parent_id category.category_path = _path(parent, category_name) category.flat_mode = new_flat_mode category.type_display_name = new_type_display_name category.sort_no = sort_no category.updated_by = actor_id category.updated_at = now category.row_version += 1 affected_categories = [category] if path_changed: pending = deque([category]) while pending: current = pending.popleft() for child in children.get(current.id, []): child.category_path = _path(current, child.category_name) child.updated_by = actor_id child.updated_at = now child.row_version += 1 affected_categories.append(child) pending.append(child) affected_document_count = 0 if path_changed: for affected in affected_categories: result = session.execute( update(Document) .where( Document.category_id == affected.id, Document.is_deleted.is_(False), Document.document_type.in_( [ DocumentType.MAIN.value, DocumentType.SUB_PLAN.value, ] ), ) .values( category_name=affected.category_name, category_path=affected.category_path, updated_by=actor_id, updated_at=now, row_version=Document.row_version + 1, ) ) affected_document_count += result.rowcount after = { "categoryName": category.category_name, "categoryType": category.category_type, "parentId": serialize_id(category.parent_id), "categoryPath": category.category_path, "sortNo": category.sort_no, "flatMode": bool(category.flat_mode), "typeDisplayName": category.type_display_name, "rowVersion": category.row_version, } session.add( business_audit( action=AuditAction.EDIT_CATEGORY, target=AuditTarget.CATEGORY, target_id=category.id, target_name=category.category_name, detail={ "before": before, "after": after, "updatedDescendantCount": len(affected_categories) - 1, "updatedDocumentCount": affected_document_count, }, ) ) return _dto(category) def delete_category(category_id: int, row_version: int) -> dict[str, object]: actor_id = get_auth_context().user_id now = _utc_now() with transaction() as session: categories = _locked_categories(session) by_id = {item.id: item for item in categories} category = by_id.get(category_id) if category is None: raise ResourceNotFoundError("分类不存在") if category.row_version != row_version: raise ConflictError( "数据已被其他用户修改,请刷新后重试", details={"currentRowVersion": category.row_version}, ) descendants = _descendants(category.id, _children_map(categories)) child_count = len(descendants) document_count = ( session.scalar( select(func.count(Document.id)).where( Document.category_id == category.id, Document.is_deleted.is_(False), Document.document_type.in_( [ DocumentType.MAIN.value, DocumentType.SUB_PLAN.value, ] ), ) ) or 0 ) if child_count or document_count: raise CategoryInUseError( details={ "categoryId": serialize_id(category.id), "childCategoryCount": child_count, "documentCount": document_count, } ) target_name = category.category_name category.is_deleted = True category.deleted_at = now category.updated_at = now category.updated_by = actor_id category.row_version += 1 session.add( business_audit( action=AuditAction.DELETE_CATEGORY, target=AuditTarget.CATEGORY, target_id=category.id, target_name=target_name, detail={ "categoryCode": category.category_code, "categoryPath": category.category_path, "rowVersion": category.row_version, }, ) ) return {"id": serialize_id(category.id), "deleted": True} REORDER_FIELDS = {"parentId", "orderedIds"} def reorder_categories(payload: Any) -> dict[str, int]: """管理员拖动重排分类:同一父分类下的同级范围排序。 - ``parentId`` 指明排序范围:null 为顶级分类,否则为该父分类的直接子级; - ``orderedIds`` 为拖动后的目标顺序(允许只提交范围内部分ID, 未提交的槽位保持相对顺序,仅被提交ID"置换"填充),按步长10全量重编号。 """ if not isinstance(payload, dict) or set(payload) != REORDER_FIELDS: raise InvalidArgumentError("请求字段必须且只能包含parentId和orderedIds") parent_id = _string_id(payload["parentId"], field="parentId", nullable=True) raw_ids = payload["orderedIds"] if not isinstance(raw_ids, list) or len(raw_ids) < 2: raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组") ordered_ids: list[int] = [] seen: set[int] = set() for index, raw_id in enumerate(raw_ids): category_id = _string_id(raw_id, f"orderedIds[{index}]") if category_id not in seen: ordered_ids.append(category_id) seen.add(category_id) context = get_auth_context() def operation() -> dict[str, int]: with transaction() as session: if parent_id is not None: parent = session.get(Category, parent_id) if parent is None or parent.is_deleted: raise ResourceNotFoundError("排序范围的父分类不存在或已删除") siblings = session.scalars( select(Category) .where( Category.is_deleted.is_(False), Category.parent_id.is_(None) if parent_id is None else Category.parent_id == parent_id, ) .order_by(Category.sort_no.asc(), Category.id.asc()) .with_for_update() ).all() sibling_ids = {item.id for item in siblings} if any(item_id not in sibling_ids for item_id in ordered_ids): raise InvalidArgumentError("orderedIds包含不在同级范围内的分类") all_ids = [item.id for item in siblings] slot_ids = sorted(ordered_ids, key=all_ids.index) new_order = list(all_ids) for position, item_id in zip( (all_ids.index(iid) for iid in slot_ids), ordered_ids ): new_order[position] = item_id target_sort_by_id = { item_id: (index + 1) * 10 for index, item_id in enumerate(new_order) } now = _utc_now() reordered_count = 0 for item in siblings: target = target_sort_by_id[item.id] if item.sort_no == target: continue item.sort_no = target item.updated_by = context.user_id item.updated_at = now item.row_version += 1 reordered_count += 1 first = next(item for item in siblings if item.id == ordered_ids[0]) session.add( business_audit( action=AuditAction.REORDER_CATEGORY, target=AuditTarget.CATEGORY, target_id=first.id, target_name=first.category_name, detail={ "scope": serialize_id(parent_id) if parent_id is not None else "ROOT", "orderedIds": [serialize_id(iid) for iid in ordered_ids], "reorderedCount": reordered_count, }, ) ) return {"reorderedCount": reordered_count} return execute_with_deadlock_retry(operation) def parse_category_id(value: str) -> int: result = _string_id(value, field="id") assert result is not None return result def parse_delete_row_version(value: str | None) -> int: if value is None or not value.isdecimal(): raise InvalidArgumentError("rowVersion必须是非负整数") return int(value)