category_service.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639
  1. """方案分类树、写操作、路径冗余和占用规则。"""
  2. from __future__ import annotations
  3. from collections import deque
  4. from datetime import datetime, timezone
  5. from typing import Any
  6. from sqlalchemy import and_, exists, func, or_, select, update
  7. from sqlalchemy.exc import IntegrityError
  8. from dms.common.enums import (
  9. AuditAction,
  10. AuditTarget,
  11. CategoryType,
  12. DocumentStatus,
  13. DocumentType,
  14. EnabledStatus,
  15. RoleCode,
  16. SECURITY_LEVEL_VALUES,
  17. SecurityLevel,
  18. SubjectType,
  19. VisibilityType,
  20. )
  21. from dms.common.errors import (
  22. CategoryInUseError,
  23. CategoryNotLeafError,
  24. ConflictError,
  25. InvalidArgumentError,
  26. ResourceNotFoundError,
  27. )
  28. from dms.common.response import serialize_id
  29. from dms.database.transaction import transaction
  30. from dms.extensions import db
  31. from dms.models import Category, Document, Permission
  32. from dms.security.auth_context import get_auth_context
  33. from dms.services.audit_service import business_audit
  34. from dms.services.authorization_service import _active_organization_ancestor_ids
  35. CREATE_FIELDS = {
  36. "categoryCode",
  37. "categoryName",
  38. "categoryType",
  39. "parentId",
  40. "sortNo",
  41. }
  42. UPDATE_FIELDS = {
  43. "categoryName",
  44. "categoryType",
  45. "parentId",
  46. "sortNo",
  47. "rowVersion",
  48. }
  49. def _utc_now() -> datetime:
  50. return datetime.now(timezone.utc).replace(tzinfo=None)
  51. def _string_id(value: Any, *, field: str, nullable: bool = False) -> int | None:
  52. if value is None and nullable:
  53. return None
  54. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  55. raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID")
  56. return int(value)
  57. def _required_text(
  58. value: Any,
  59. *,
  60. field: str,
  61. max_length: int,
  62. ) -> str:
  63. if not isinstance(value, str) or not value.strip():
  64. raise InvalidArgumentError(f"{field}不能为空")
  65. normalized = value.strip()
  66. if len(normalized) > max_length:
  67. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  68. return normalized
  69. def _category_type(value: Any) -> str:
  70. if not isinstance(value, str):
  71. raise InvalidArgumentError("categoryType必须是字符串")
  72. try:
  73. return CategoryType(value).value
  74. except ValueError as exc:
  75. raise InvalidArgumentError("categoryType不是有效枚举值") from exc
  76. def _sort_no(value: Any) -> int:
  77. if type(value) is not int:
  78. raise InvalidArgumentError("sortNo必须是整数")
  79. return value
  80. def _row_version(value: Any) -> int:
  81. if type(value) is not int or value < 0:
  82. raise InvalidArgumentError("rowVersion必须是非负整数")
  83. return value
  84. def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
  85. if not isinstance(payload, dict) or set(payload) != expected:
  86. raise InvalidArgumentError(
  87. "请求体字段必须且只能包含:" + "、".join(sorted(expected))
  88. )
  89. return payload
  90. def _path(parent: Category | None, category_name: str) -> str:
  91. value = (
  92. f"{parent.category_path.rstrip('/')}/{category_name}"
  93. if parent is not None
  94. else f"/{category_name}"
  95. )
  96. if len(value) > 1000:
  97. raise InvalidArgumentError("categoryPath长度超过数据库限制")
  98. return value
  99. def _dto(
  100. category: Category,
  101. *,
  102. parent_id: int | None = None,
  103. document_count: int | None = None,
  104. children: list[dict[str, object]] | None = None,
  105. ) -> dict[str, object]:
  106. return {
  107. "id": serialize_id(category.id),
  108. "categoryCode": category.category_code,
  109. "categoryName": category.category_name,
  110. "categoryType": category.category_type,
  111. "parentId": serialize_id(
  112. category.parent_id if parent_id is None else parent_id
  113. ),
  114. "categoryPath": category.category_path,
  115. "sortNo": category.sort_no,
  116. "documentCount": (
  117. category.document_count
  118. if document_count is None
  119. else document_count
  120. ),
  121. "rowVersion": category.row_version,
  122. "children": children or [],
  123. }
  124. def _children_map(categories: list[Category]) -> dict[int | None, list[Category]]:
  125. children: dict[int | None, list[Category]] = {}
  126. for category in categories:
  127. children.setdefault(category.parent_id, []).append(category)
  128. return children
  129. def _descendants(
  130. category_id: int,
  131. children: dict[int | None, list[Category]],
  132. ) -> list[Category]:
  133. result: list[Category] = []
  134. pending = deque(children.get(category_id, []))
  135. seen = {category_id}
  136. while pending:
  137. category = pending.popleft()
  138. if category.id in seen:
  139. raise ConflictError("分类树存在循环关系")
  140. seen.add(category.id)
  141. result.append(category)
  142. pending.extend(children.get(category.id, []))
  143. return result
  144. def category_tree(
  145. *,
  146. keyword: str | None,
  147. status: str | None,
  148. ) -> list[dict[str, object]]:
  149. if status:
  150. try:
  151. normalized_status = EnabledStatus(status).value
  152. except ValueError as exc:
  153. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  154. else:
  155. normalized_status = EnabledStatus.ENABLED.value
  156. categories = db.session.scalars(
  157. select(Category)
  158. .where(Category.is_deleted.is_(False))
  159. .order_by(Category.sort_no.asc(), Category.id.asc())
  160. ).all()
  161. context = get_auth_context()
  162. visible_main = select(Document.category_id, func.count(Document.id)).where(
  163. Document.category_id.is_not(None),
  164. Document.document_type == DocumentType.MAIN.value,
  165. Document.is_deleted.is_(False),
  166. )
  167. allowed_security_levels = [
  168. level.value
  169. for level, rank in SECURITY_LEVEL_VALUES.items()
  170. if rank <= SECURITY_LEVEL_VALUES[context.security_level]
  171. ]
  172. visible_main = visible_main.where(
  173. Document.security_level.in_(allowed_security_levels)
  174. )
  175. if context.role_code == RoleCode.USER:
  176. ancestor_ids = _active_organization_ancestor_ids(context.organization_id)
  177. org_permission = exists(
  178. select(Permission.id).where(
  179. Permission.document_id == Document.id,
  180. Permission.is_deleted.is_(False),
  181. Permission.can_view.is_(True),
  182. Permission.subject_type == SubjectType.ORG.value,
  183. Permission.subject_id.in_(ancestor_ids or {-1}),
  184. )
  185. )
  186. user_permission = exists(
  187. select(Permission.id).where(
  188. Permission.document_id == Document.id,
  189. Permission.is_deleted.is_(False),
  190. Permission.can_view.is_(True),
  191. Permission.subject_type == SubjectType.USER.value,
  192. Permission.subject_id == context.user_id,
  193. )
  194. )
  195. visible_main = visible_main.where(
  196. Document.document_status == DocumentStatus.PUBLISHED.value,
  197. or_(
  198. Document.visibility_type == VisibilityType.ALL_AUTHENTICATED.value,
  199. and_(
  200. Document.visibility_type == VisibilityType.ORGANIZATION.value,
  201. org_permission,
  202. ),
  203. and_(
  204. Document.visibility_type == VisibilityType.CUSTOM.value,
  205. or_(org_permission, user_permission),
  206. ),
  207. ),
  208. )
  209. direct_main_counts = dict(
  210. db.session.execute(
  211. visible_main.group_by(Document.category_id)
  212. ).all()
  213. )
  214. by_id = {item.id: item for item in categories}
  215. children = _children_map(categories)
  216. eligible_ids = {
  217. item.id for item in categories if item.status == normalized_status
  218. }
  219. if keyword and keyword.strip():
  220. needle = keyword.strip().casefold()
  221. matched_ids = {
  222. item.id
  223. for item in categories
  224. if item.id in eligible_ids
  225. and (
  226. needle in item.category_name.casefold()
  227. or needle in item.category_code.casefold()
  228. )
  229. }
  230. included_ids = set(matched_ids)
  231. for matched_id in tuple(matched_ids):
  232. included_ids.update(
  233. descendant.id
  234. for descendant in _descendants(matched_id, children)
  235. if descendant.id in eligible_ids
  236. )
  237. parent_id = by_id[matched_id].parent_id
  238. while parent_id is not None and parent_id in by_id:
  239. included_ids.add(parent_id)
  240. parent_id = by_id[parent_id].parent_id
  241. else:
  242. included_ids = eligible_ids
  243. subtree_counts = {
  244. category.id: 0
  245. for category in categories
  246. if category.status == EnabledStatus.ENABLED.value
  247. }
  248. for category_id, count in direct_main_counts.items():
  249. current_id = category_id
  250. seen: set[int] = set()
  251. while current_id in subtree_counts and current_id not in seen:
  252. seen.add(current_id)
  253. subtree_counts[current_id] += int(count)
  254. current_id = by_id[current_id].parent_id
  255. nodes: dict[int, dict[str, object]] = {}
  256. for category in categories:
  257. if category.id in included_ids:
  258. response_parent_id = (
  259. category.parent_id if category.parent_id in included_ids else None
  260. )
  261. nodes[category.id] = _dto(
  262. category,
  263. document_count=subtree_counts.get(category.id, 0),
  264. )
  265. nodes[category.id]["parentId"] = serialize_id(response_parent_id)
  266. roots: list[dict[str, object]] = []
  267. for category in categories:
  268. node = nodes.get(category.id)
  269. if node is None:
  270. continue
  271. if category.parent_id in nodes:
  272. nodes[category.parent_id]["children"].append(node)
  273. else:
  274. roots.append(node)
  275. return roots
  276. def _valid_parent(session, parent_id: int | None) -> Category | None:
  277. if parent_id is None:
  278. return None
  279. parent = session.scalar(
  280. select(Category)
  281. .where(
  282. Category.id == parent_id,
  283. Category.is_deleted.is_(False),
  284. Category.status == EnabledStatus.ENABLED.value,
  285. )
  286. .with_for_update()
  287. )
  288. if parent is None:
  289. raise InvalidArgumentError("parentId必须指向有效且已启用的分类")
  290. direct_main_count = session.scalar(
  291. select(func.count(Document.id)).where(
  292. Document.category_id == parent.id,
  293. Document.document_type == DocumentType.MAIN.value,
  294. Document.is_deleted.is_(False),
  295. )
  296. ) or 0
  297. if direct_main_count:
  298. child_count = session.scalar(
  299. select(func.count(Category.id)).where(
  300. Category.parent_id == parent.id,
  301. Category.is_deleted.is_(False),
  302. Category.status == EnabledStatus.ENABLED.value,
  303. )
  304. ) or 0
  305. raise CategoryNotLeafError(
  306. details={
  307. "categoryId": serialize_id(parent.id),
  308. "childCategoryCount": int(child_count),
  309. }
  310. )
  311. return parent
  312. def create_category(payload: Any) -> dict[str, object]:
  313. body = _exact_payload(payload, CREATE_FIELDS)
  314. category_code = _required_text(
  315. body["categoryCode"],
  316. field="categoryCode",
  317. max_length=64,
  318. )
  319. category_name = _required_text(
  320. body["categoryName"],
  321. field="categoryName",
  322. max_length=128,
  323. )
  324. category_type = _category_type(body["categoryType"])
  325. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  326. sort_no = _sort_no(body["sortNo"])
  327. actor_id = get_auth_context().user_id
  328. try:
  329. with transaction() as session:
  330. if session.scalar(
  331. select(Category.id).where(Category.category_code == category_code)
  332. ) is not None:
  333. raise ConflictError("分类编码已存在")
  334. parent = _valid_parent(session, parent_id)
  335. category = Category(
  336. category_code=category_code,
  337. category_name=category_name,
  338. category_type=category_type,
  339. parent_id=parent_id,
  340. category_path=_path(parent, category_name),
  341. sort_no=sort_no,
  342. document_count=0,
  343. status=EnabledStatus.ENABLED.value,
  344. created_by=actor_id,
  345. updated_by=actor_id,
  346. )
  347. session.add(category)
  348. session.flush()
  349. session.add(
  350. business_audit(
  351. action=AuditAction.CREATE_CATEGORY,
  352. target=AuditTarget.CATEGORY,
  353. target_id=category.id,
  354. target_name=category.category_name,
  355. detail={
  356. "categoryCode": category.category_code,
  357. "categoryType": category.category_type,
  358. "parentId": serialize_id(category.parent_id),
  359. "categoryPath": category.category_path,
  360. "sortNo": category.sort_no,
  361. },
  362. )
  363. )
  364. return _dto(category)
  365. except IntegrityError as exc:
  366. raise ConflictError("分类编码已存在") from exc
  367. def _locked_categories(session) -> list[Category]:
  368. return session.scalars(
  369. select(Category)
  370. .where(Category.is_deleted.is_(False))
  371. .order_by(Category.id.asc())
  372. .with_for_update()
  373. ).all()
  374. def update_category(category_id: int, payload: Any) -> dict[str, object]:
  375. body = _exact_payload(payload, UPDATE_FIELDS)
  376. category_name = _required_text(
  377. body["categoryName"],
  378. field="categoryName",
  379. max_length=128,
  380. )
  381. category_type = _category_type(body["categoryType"])
  382. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  383. sort_no = _sort_no(body["sortNo"])
  384. expected_version = _row_version(body["rowVersion"])
  385. actor_id = get_auth_context().user_id
  386. now = _utc_now()
  387. with transaction() as session:
  388. categories = _locked_categories(session)
  389. by_id = {item.id: item for item in categories}
  390. category = by_id.get(category_id)
  391. if category is None:
  392. raise ResourceNotFoundError("分类不存在")
  393. if category.row_version != expected_version:
  394. raise ConflictError(
  395. "数据已被其他用户修改,请刷新后重试",
  396. details={"currentRowVersion": category.row_version},
  397. )
  398. children = _children_map(categories)
  399. descendants = _descendants(category.id, children)
  400. descendant_ids = {item.id for item in descendants}
  401. if parent_id == category.id:
  402. raise InvalidArgumentError("parentId不能等于当前分类ID")
  403. if parent_id in descendant_ids:
  404. raise InvalidArgumentError("分类不能移动到自己的后代节点下")
  405. parent = None
  406. if parent_id is not None:
  407. parent = by_id.get(parent_id)
  408. if (
  409. parent is None
  410. or parent.is_deleted
  411. or parent.status != EnabledStatus.ENABLED.value
  412. ):
  413. raise InvalidArgumentError("parentId必须指向有效且已启用的分类")
  414. if parent_id != category.parent_id:
  415. direct_main_count = session.scalar(
  416. select(func.count(Document.id)).where(
  417. Document.category_id == parent.id,
  418. Document.document_type == DocumentType.MAIN.value,
  419. Document.is_deleted.is_(False),
  420. )
  421. ) or 0
  422. if direct_main_count:
  423. child_count = session.scalar(
  424. select(func.count(Category.id)).where(
  425. Category.parent_id == parent.id,
  426. Category.is_deleted.is_(False),
  427. Category.status == EnabledStatus.ENABLED.value,
  428. )
  429. ) or 0
  430. raise CategoryNotLeafError(
  431. details={
  432. "categoryId": serialize_id(parent.id),
  433. "childCategoryCount": int(child_count),
  434. }
  435. )
  436. before = {
  437. "categoryName": category.category_name,
  438. "categoryType": category.category_type,
  439. "parentId": serialize_id(category.parent_id),
  440. "categoryPath": category.category_path,
  441. "sortNo": category.sort_no,
  442. "rowVersion": category.row_version,
  443. }
  444. path_changed = (
  445. category_name != category.category_name
  446. or parent_id != category.parent_id
  447. )
  448. category.category_name = category_name
  449. category.category_type = category_type
  450. category.parent_id = parent_id
  451. category.category_path = _path(parent, category_name)
  452. category.sort_no = sort_no
  453. category.updated_by = actor_id
  454. category.updated_at = now
  455. category.row_version += 1
  456. affected_categories = [category]
  457. if path_changed:
  458. pending = deque([category])
  459. while pending:
  460. current = pending.popleft()
  461. for child in children.get(current.id, []):
  462. child.category_path = _path(current, child.category_name)
  463. child.updated_by = actor_id
  464. child.updated_at = now
  465. child.row_version += 1
  466. affected_categories.append(child)
  467. pending.append(child)
  468. affected_document_count = 0
  469. if path_changed:
  470. for affected in affected_categories:
  471. result = session.execute(
  472. update(Document)
  473. .where(
  474. Document.category_id == affected.id,
  475. Document.is_deleted.is_(False),
  476. Document.document_type.in_(
  477. [
  478. DocumentType.MAIN.value,
  479. DocumentType.SUB_PLAN.value,
  480. ]
  481. ),
  482. )
  483. .values(
  484. category_name=affected.category_name,
  485. category_path=affected.category_path,
  486. updated_by=actor_id,
  487. updated_at=now,
  488. row_version=Document.row_version + 1,
  489. )
  490. )
  491. affected_document_count += result.rowcount
  492. after = {
  493. "categoryName": category.category_name,
  494. "categoryType": category.category_type,
  495. "parentId": serialize_id(category.parent_id),
  496. "categoryPath": category.category_path,
  497. "sortNo": category.sort_no,
  498. "rowVersion": category.row_version,
  499. }
  500. session.add(
  501. business_audit(
  502. action=AuditAction.EDIT_CATEGORY,
  503. target=AuditTarget.CATEGORY,
  504. target_id=category.id,
  505. target_name=category.category_name,
  506. detail={
  507. "before": before,
  508. "after": after,
  509. "updatedDescendantCount": len(affected_categories) - 1,
  510. "updatedDocumentCount": affected_document_count,
  511. },
  512. )
  513. )
  514. return _dto(category)
  515. def delete_category(category_id: int, row_version: int) -> dict[str, object]:
  516. actor_id = get_auth_context().user_id
  517. now = _utc_now()
  518. with transaction() as session:
  519. categories = _locked_categories(session)
  520. by_id = {item.id: item for item in categories}
  521. category = by_id.get(category_id)
  522. if category is None:
  523. raise ResourceNotFoundError("分类不存在")
  524. if category.row_version != row_version:
  525. raise ConflictError(
  526. "数据已被其他用户修改,请刷新后重试",
  527. details={"currentRowVersion": category.row_version},
  528. )
  529. descendants = _descendants(category.id, _children_map(categories))
  530. child_count = len(descendants)
  531. document_count = (
  532. session.scalar(
  533. select(func.count(Document.id)).where(
  534. Document.category_id == category.id,
  535. Document.is_deleted.is_(False),
  536. Document.document_type.in_(
  537. [
  538. DocumentType.MAIN.value,
  539. DocumentType.SUB_PLAN.value,
  540. ]
  541. ),
  542. )
  543. )
  544. or 0
  545. )
  546. if child_count or document_count:
  547. raise CategoryInUseError(
  548. details={
  549. "categoryId": serialize_id(category.id),
  550. "childCategoryCount": child_count,
  551. "documentCount": document_count,
  552. }
  553. )
  554. target_name = category.category_name
  555. category.is_deleted = True
  556. category.deleted_at = now
  557. category.updated_at = now
  558. category.updated_by = actor_id
  559. category.row_version += 1
  560. session.add(
  561. business_audit(
  562. action=AuditAction.DELETE_CATEGORY,
  563. target=AuditTarget.CATEGORY,
  564. target_id=category.id,
  565. target_name=target_name,
  566. detail={
  567. "categoryCode": category.category_code,
  568. "categoryPath": category.category_path,
  569. "rowVersion": category.row_version,
  570. },
  571. )
  572. )
  573. return {"id": serialize_id(category.id), "deleted": True}
  574. def parse_category_id(value: str) -> int:
  575. result = _string_id(value, field="id")
  576. assert result is not None
  577. return result
  578. def parse_delete_row_version(value: str | None) -> int:
  579. if value is None or not value.isdecimal():
  580. raise InvalidArgumentError("rowVersion必须是非负整数")
  581. return int(value)