organization_service.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640
  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 func, select, update
  7. from sqlalchemy.exc import IntegrityError
  8. from dms.common.enums import AuditAction, AuditTarget, EnabledStatus
  9. from dms.common.errors import (
  10. ConflictError,
  11. InvalidArgumentError,
  12. OrganizationInUseError,
  13. ResourceNotFoundError,
  14. )
  15. from dms.common.response import serialize_id
  16. from dms.database.transaction import transaction
  17. from dms.extensions import db
  18. from dms.models import Document, Organization, OrgIpAssignment, User
  19. from dms.security.auth_context import get_auth_context
  20. from dms.services.admin_permission import (
  21. ensure_org_in_managed_scope,
  22. managed_scope_ids,
  23. require_organization_editor,
  24. )
  25. from dms.services.audit_service import business_audit
  26. CREATE_FIELDS = {
  27. "orgCode",
  28. "orgName",
  29. "parentId",
  30. "sortNo",
  31. "status",
  32. }
  33. UPDATE_FIELDS = {
  34. "orgCode",
  35. "orgName",
  36. "parentId",
  37. "sortNo",
  38. "status",
  39. "rowVersion",
  40. }
  41. def _utc_now() -> datetime:
  42. return datetime.now(timezone.utc).replace(tzinfo=None)
  43. def _string_id(value: Any, *, field: str, nullable: bool = False) -> int | None:
  44. if value is None and nullable:
  45. return None
  46. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  47. raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID")
  48. return int(value)
  49. def _required_text(
  50. value: Any,
  51. *,
  52. field: str,
  53. max_length: int,
  54. ) -> str:
  55. if not isinstance(value, str) or not value.strip():
  56. raise InvalidArgumentError(f"{field}不能为空")
  57. normalized = value.strip()
  58. if len(normalized) > max_length:
  59. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  60. return normalized
  61. def _status_value(value: Any) -> str:
  62. if not isinstance(value, str):
  63. raise InvalidArgumentError("status必须是字符串")
  64. try:
  65. return EnabledStatus(value).value
  66. except ValueError as exc:
  67. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  68. def _sort_no(value: Any) -> int:
  69. if type(value) is not int:
  70. raise InvalidArgumentError("sortNo必须是整数")
  71. return value
  72. def _row_version(value: Any) -> int:
  73. if type(value) is not int or value < 0:
  74. raise InvalidArgumentError("rowVersion必须是非负整数")
  75. return value
  76. def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
  77. if not isinstance(payload, dict) or set(payload) != expected:
  78. raise InvalidArgumentError(
  79. "请求体字段必须且只能包含:" + "、".join(sorted(expected))
  80. )
  81. return payload
  82. def _build_path(parent_path: str | None, org_name: str) -> str:
  83. """根据父节点路径构建当前节点的 org_path,并校验长度上限。"""
  84. value = (
  85. f"{parent_path.rstrip('/')}/{org_name}"
  86. if parent_path is not None
  87. else f"/{org_name}"
  88. )
  89. if len(value) > 1000:
  90. raise InvalidArgumentError("orgPath长度超过数据库限制")
  91. return value
  92. def _org_path(parent: Organization | None, org_name: str) -> str:
  93. return _build_path(parent.org_path if parent is not None else None, org_name)
  94. def _dto(
  95. organization: Organization,
  96. *,
  97. children: list[dict[str, object]] | None = None,
  98. ) -> dict[str, object]:
  99. return {
  100. "id": serialize_id(organization.id),
  101. "parentId": serialize_id(organization.parent_id),
  102. "orgCode": organization.org_code,
  103. "orgName": organization.org_name,
  104. "orgPath": organization.org_path,
  105. "sortNo": organization.sort_no,
  106. "status": organization.status,
  107. "rowVersion": organization.row_version,
  108. "children": children or [],
  109. }
  110. def _children_map(
  111. organizations: list[Organization],
  112. ) -> dict[int | None, list[Organization]]:
  113. children: dict[int | None, list[Organization]] = {}
  114. for organization in organizations:
  115. children.setdefault(organization.parent_id, []).append(organization)
  116. return children
  117. def _descendants(
  118. organization_id: int,
  119. children: dict[int | None, list[Organization]],
  120. ) -> list[Organization]:
  121. result: list[Organization] = []
  122. pending = deque(children.get(organization_id, []))
  123. seen = {organization_id}
  124. while pending:
  125. organization = pending.popleft()
  126. if organization.id in seen:
  127. raise ConflictError("机构树存在循环关系")
  128. seen.add(organization.id)
  129. result.append(organization)
  130. pending.extend(children.get(organization.id, []))
  131. return result
  132. def organization_tree(
  133. *, keyword: str | None, status: str | None
  134. ) -> list[dict[str, object]]:
  135. organizations = db.session.scalars(
  136. select(Organization)
  137. .where(Organization.is_deleted.is_(False))
  138. .order_by(Organization.sort_no.asc(), Organization.id.asc())
  139. ).all()
  140. # 安全需求8:锚点管理员的机构树裁剪到管理范围子树(全局管理员不受限)。
  141. # 机构页/用户页/用户机构调整下拉均消费本接口,裁剪后各处只见可管理机构。
  142. actor = db.session.get(User, get_auth_context().user_id)
  143. scope = managed_scope_ids(db.session, actor) if actor is not None else None
  144. if scope is not None:
  145. organizations = [item for item in organizations if item.id in scope]
  146. by_id = {item.id: item for item in organizations}
  147. # 不传 status 返回全部(含停用,管理界面可见可重新启用);传值时按状态过滤
  148. if status:
  149. try:
  150. normalized_status: str | None = EnabledStatus(status).value
  151. except ValueError as exc:
  152. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  153. else:
  154. normalized_status = None
  155. base_ids = {
  156. item.id
  157. for item in organizations
  158. if normalized_status is None or item.status == normalized_status
  159. }
  160. if keyword and keyword.strip():
  161. needle = keyword.strip().casefold()
  162. base_ids = {
  163. item.id
  164. for item in organizations
  165. if item.id in base_ids
  166. and (needle in item.org_name.casefold() or needle in item.org_code.casefold())
  167. }
  168. included_ids = set(base_ids)
  169. for item_id in tuple(base_ids):
  170. parent_id = by_id[item_id].parent_id
  171. while parent_id is not None and parent_id in by_id:
  172. included_ids.add(parent_id)
  173. parent_id = by_id[parent_id].parent_id
  174. nodes = {
  175. item.id: _dto(item)
  176. for item in organizations
  177. if item.id in included_ids
  178. }
  179. roots: list[dict[str, object]] = []
  180. for item in organizations:
  181. if item.id not in nodes:
  182. continue
  183. node = nodes[item.id]
  184. if item.parent_id in nodes:
  185. nodes[item.parent_id]["children"].append(node)
  186. else:
  187. roots.append(node)
  188. return roots
  189. def _locked_organizations(session) -> list[Organization]:
  190. return session.scalars(
  191. select(Organization)
  192. .where(Organization.is_deleted.is_(False))
  193. .order_by(Organization.id.asc())
  194. .with_for_update()
  195. ).all()
  196. def _valid_parent(
  197. session, parent_id: int | None
  198. ) -> Organization | None:
  199. if parent_id is None:
  200. return None
  201. parent = session.scalar(
  202. select(Organization)
  203. .where(
  204. Organization.id == parent_id,
  205. Organization.is_deleted.is_(False),
  206. )
  207. .with_for_update()
  208. )
  209. if parent is None:
  210. raise InvalidArgumentError("parentId必须指向有效机构")
  211. if parent.status != EnabledStatus.ENABLED.value:
  212. raise InvalidArgumentError("parentId必须指向已启用的机构")
  213. return parent
  214. def create_organization(payload: Any) -> dict[str, object]:
  215. body = _exact_payload(payload, CREATE_FIELDS)
  216. org_code = _required_text(body["orgCode"], field="orgCode", max_length=64)
  217. org_name = _required_text(body["orgName"], field="orgName", max_length=128)
  218. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  219. sort_no = _sort_no(body["sortNo"])
  220. status = _status_value(body["status"])
  221. actor_id = get_auth_context().user_id
  222. try:
  223. with transaction() as session:
  224. actor = require_organization_editor(session)
  225. ensure_org_in_managed_scope(session, actor, parent_id)
  226. if (
  227. session.scalar(
  228. select(Organization.id)
  229. .where(
  230. Organization.org_code == org_code,
  231. Organization.is_deleted.is_(False),
  232. )
  233. )
  234. is not None
  235. ):
  236. raise ConflictError("机构编码已存在")
  237. parent = _valid_parent(session, parent_id)
  238. organization = Organization(
  239. org_code=org_code,
  240. org_name=org_name,
  241. parent_id=parent_id,
  242. org_path=_org_path(parent, org_name),
  243. sort_no=sort_no,
  244. status=status,
  245. created_by=actor_id,
  246. updated_by=actor_id,
  247. )
  248. session.add(organization)
  249. session.flush()
  250. session.add(
  251. business_audit(
  252. action=AuditAction.CREATE_ORGANIZATION,
  253. target=AuditTarget.ORGANIZATION,
  254. target_id=organization.id,
  255. target_name=organization.org_name,
  256. detail={
  257. "orgCode": organization.org_code,
  258. "parentId": serialize_id(organization.parent_id),
  259. "orgPath": organization.org_path,
  260. "sortNo": organization.sort_no,
  261. "status": organization.status,
  262. },
  263. )
  264. )
  265. return _dto(organization)
  266. except IntegrityError as exc:
  267. raise ConflictError("机构编码已存在") from exc
  268. def update_organization(organization_id: int, payload: Any) -> dict[str, object]:
  269. body = _exact_payload(payload, UPDATE_FIELDS)
  270. org_code = _required_text(body["orgCode"], field="orgCode", max_length=64)
  271. org_name = _required_text(body["orgName"], field="orgName", max_length=128)
  272. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  273. sort_no = _sort_no(body["sortNo"])
  274. status = _status_value(body["status"])
  275. expected_version = _row_version(body["rowVersion"])
  276. actor_id = get_auth_context().user_id
  277. now = _utc_now()
  278. with transaction() as session:
  279. actor = require_organization_editor(session)
  280. ensure_org_in_managed_scope(session, actor, organization_id)
  281. organizations = _locked_organizations(session)
  282. by_id = {item.id: item for item in organizations}
  283. organization = by_id.get(organization_id)
  284. if organization is None:
  285. raise ResourceNotFoundError("机构不存在")
  286. if organization.row_version != expected_version:
  287. raise ConflictError(
  288. "数据已被其他用户修改,请刷新后重试",
  289. details={"currentRowVersion": organization.row_version},
  290. )
  291. # 机构编码可编辑:保存时查重(排除自身,口径与创建一致——仅比对未删除机构)
  292. if (
  293. session.scalar(
  294. select(Organization.id)
  295. .where(
  296. Organization.org_code == org_code,
  297. Organization.id != organization.id,
  298. Organization.is_deleted.is_(False),
  299. )
  300. )
  301. is not None
  302. ):
  303. raise ConflictError("机构编码已存在")
  304. children = _children_map(organizations)
  305. descendants = _descendants(organization.id, children)
  306. descendant_ids = {item.id for item in descendants}
  307. if parent_id == organization.id:
  308. raise InvalidArgumentError("parentId不能等于当前机构ID")
  309. if parent_id in descendant_ids:
  310. raise InvalidArgumentError("机构不能移动到自己的后代节点下")
  311. parent: Organization | None = None
  312. if parent_id is not None:
  313. parent = by_id.get(parent_id)
  314. if (
  315. parent is None
  316. or parent.is_deleted
  317. or parent.status != EnabledStatus.ENABLED.value
  318. ):
  319. raise InvalidArgumentError("parentId必须指向有效且已启用的机构")
  320. before = {
  321. "orgCode": organization.org_code,
  322. "orgName": organization.org_name,
  323. "parentId": serialize_id(organization.parent_id),
  324. "orgPath": organization.org_path,
  325. "sortNo": organization.sort_no,
  326. "status": organization.status,
  327. "rowVersion": organization.row_version,
  328. }
  329. path_changed = (
  330. org_name != organization.org_name
  331. or parent_id != organization.parent_id
  332. )
  333. organization.org_code = org_code
  334. organization.org_name = org_name
  335. organization.parent_id = parent_id
  336. organization.org_path = _org_path(parent, org_name)
  337. organization.sort_no = sort_no
  338. organization.status = status
  339. organization.updated_by = actor_id
  340. organization.updated_at = now
  341. organization.row_version += 1
  342. affected_organizations = [organization]
  343. if path_changed:
  344. # 阶段1:预先计算所有受影响节点的新路径
  345. # 在修改任何对象前发现长度溢出,配合 transaction() 自动回滚保证一致性
  346. new_child_paths: dict[int, str] = {}
  347. validation_queue: deque[tuple[Organization, str]] = deque(
  348. [(organization, organization.org_path)]
  349. )
  350. while validation_queue:
  351. current, current_new_path = validation_queue.popleft()
  352. for child in children.get(current.id, []):
  353. new_child_path = _build_path(current_new_path, child.org_name)
  354. new_child_paths[child.id] = new_child_path
  355. validation_queue.append((child, new_child_path))
  356. # 阶段2:所有路径已校验通过,按 BFS 顺序应用变更
  357. pending = deque([organization])
  358. while pending:
  359. current = pending.popleft()
  360. for child in children.get(current.id, []):
  361. child.org_path = new_child_paths[child.id]
  362. child.updated_by = actor_id
  363. child.updated_at = now
  364. child.row_version += 1
  365. affected_organizations.append(child)
  366. pending.append(child)
  367. if path_changed:
  368. for affected in affected_organizations:
  369. session.execute(
  370. update(User)
  371. .where(
  372. User.organization_id == affected.id,
  373. User.is_deleted.is_(False),
  374. )
  375. .values(
  376. organization_name=affected.org_name,
  377. updated_by=actor_id,
  378. updated_at=now,
  379. row_version=User.row_version + 1,
  380. )
  381. )
  382. for affected in affected_organizations:
  383. session.execute(
  384. update(Document)
  385. .where(
  386. Document.organization_id == affected.id,
  387. Document.is_deleted.is_(False),
  388. )
  389. .values(
  390. organization_name=affected.org_name,
  391. updated_by=actor_id,
  392. updated_at=now,
  393. row_version=Document.row_version + 1,
  394. )
  395. )
  396. after = {
  397. "orgName": organization.org_name,
  398. "parentId": serialize_id(organization.parent_id),
  399. "orgPath": organization.org_path,
  400. "sortNo": organization.sort_no,
  401. "status": organization.status,
  402. "rowVersion": organization.row_version,
  403. }
  404. session.add(
  405. business_audit(
  406. action=AuditAction.EDIT_ORGANIZATION,
  407. target=AuditTarget.ORGANIZATION,
  408. target_id=organization.id,
  409. target_name=organization.org_name,
  410. detail={
  411. "before": before,
  412. "after": after,
  413. "updatedDescendantCount": len(affected_organizations) - 1,
  414. },
  415. )
  416. )
  417. return _dto(organization)
  418. def delete_organization(organization_id: int, row_version: int) -> dict[str, object]:
  419. actor_id = get_auth_context().user_id
  420. now = _utc_now()
  421. with transaction() as session:
  422. actor = require_organization_editor(session)
  423. ensure_org_in_managed_scope(session, actor, organization_id)
  424. organizations = _locked_organizations(session)
  425. by_id = {item.id: item for item in organizations}
  426. organization = by_id.get(organization_id)
  427. if organization is None:
  428. raise ResourceNotFoundError("机构不存在")
  429. if organization.row_version != row_version:
  430. raise ConflictError(
  431. "数据已被其他用户修改,请刷新后重试",
  432. details={"currentRowVersion": organization.row_version},
  433. )
  434. children = _children_map(organizations)
  435. descendants = _descendants(organization.id, children)
  436. child_count = len(descendants)
  437. user_count = (
  438. session.scalar(
  439. select(func.count(User.id)).where(
  440. User.organization_id == organization.id,
  441. User.is_deleted.is_(False),
  442. )
  443. )
  444. or 0
  445. )
  446. if child_count or user_count:
  447. raise OrganizationInUseError(
  448. details={
  449. "organizationId": serialize_id(organization.id),
  450. "childOrganizationCount": child_count,
  451. "userCount": user_count,
  452. }
  453. )
  454. target_name = organization.org_name
  455. organization.is_deleted = True
  456. organization.deleted_at = now
  457. organization.updated_at = now
  458. organization.updated_by = actor_id
  459. organization.row_version += 1
  460. # 部门删除后其IP分配行同事务软删,避免残留限制语义
  461. for row in session.scalars(
  462. select(OrgIpAssignment).where(
  463. OrgIpAssignment.organization_id == organization.id,
  464. OrgIpAssignment.is_deleted.is_(False),
  465. )
  466. ).all():
  467. row.is_deleted = True
  468. row.deleted_at = now
  469. row.updated_by = actor_id
  470. row.updated_at = now
  471. session.add(
  472. business_audit(
  473. action=AuditAction.DELETE_ORGANIZATION,
  474. target=AuditTarget.ORGANIZATION,
  475. target_id=organization.id,
  476. target_name=target_name,
  477. detail={
  478. "orgCode": organization.org_code,
  479. "orgPath": organization.org_path,
  480. "rowVersion": organization.row_version,
  481. },
  482. )
  483. )
  484. return {"id": serialize_id(organization.id), "deleted": True}
  485. def parse_organization_id(value: str) -> int:
  486. result = _string_id(value, field="id")
  487. assert result is not None
  488. return result
  489. def parse_delete_row_version(value: str | None) -> int:
  490. if value is None or not value.isdecimal():
  491. raise InvalidArgumentError("rowVersion必须是非负整数")
  492. return int(value)
  493. REORDER_FIELDS = {"parentId", "orderedIds"}
  494. def reorder_organizations(payload: Any) -> dict[str, object]:
  495. """机构树同级拖动重排(契约1.27):parentId为null表示顶层,否则为该父级直接子级。
  496. orderedIds 为拖动后的目标顺序,同级槽位置换后按步长10重编号 sort_no;
  497. 范围校验(锚点管理员仅可重排管理范围子树)复用 ensure_org_in_managed_scope。
  498. """
  499. body = _exact_payload(payload, REORDER_FIELDS)
  500. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  501. ordered_ids = body["orderedIds"]
  502. if not isinstance(ordered_ids, list) or any(
  503. not isinstance(item, str) or not item.isdecimal() or int(item) <= 0
  504. for item in ordered_ids
  505. ):
  506. raise InvalidArgumentError("orderedIds必须是正整数形式的字符串ID数组")
  507. if len(set(ordered_ids)) != len(ordered_ids):
  508. raise InvalidArgumentError("orderedIds存在重复ID")
  509. if len(ordered_ids) < 2:
  510. raise InvalidArgumentError("orderedIds必须是至少包含2个ID的数组")
  511. actor_id = get_auth_context().user_id
  512. with transaction() as session:
  513. actor = require_organization_editor(session)
  514. if parent_id is not None:
  515. parent = session.scalar(
  516. select(Organization).where(
  517. Organization.id == parent_id,
  518. Organization.is_deleted.is_(False),
  519. )
  520. )
  521. if parent is None:
  522. raise ResourceNotFoundError("上级机构不存在")
  523. ensure_org_in_managed_scope(session, actor, parent_id)
  524. siblings = session.scalars(
  525. select(Organization)
  526. .where(
  527. Organization.is_deleted.is_(False),
  528. Organization.parent_id.is_(None)
  529. if parent_id is None
  530. else Organization.parent_id == parent_id,
  531. )
  532. .order_by(Organization.sort_no.asc(), Organization.id.asc())
  533. .with_for_update()
  534. ).all()
  535. sibling_ids = {item.id for item in siblings}
  536. if any(item_id not in sibling_ids for item_id in ordered_ids):
  537. raise InvalidArgumentError("orderedIds包含不在同级范围内的机构")
  538. all_ids = [item.id for item in siblings]
  539. ordered = [int(item) for item in ordered_ids]
  540. # 槽位置换:被提交ID按原槽位顺序排好,依次填入目标顺序
  541. slot_ids = sorted(ordered, key=all_ids.index)
  542. new_order = list(all_ids)
  543. for position, item_id in enumerate(slot_ids):
  544. new_order[all_ids.index(item_id)] = ordered[position]
  545. row_by_id = {item.id: item for item in siblings}
  546. now = _utc_now()
  547. reordered_count = 0
  548. for index, item_id in enumerate(new_order):
  549. row = row_by_id[item_id]
  550. target = (index + 1) * 10
  551. if row.sort_no == target:
  552. continue
  553. row.sort_no = target
  554. row.updated_by = actor_id
  555. row.updated_at = now
  556. row.row_version += 1
  557. reordered_count += 1
  558. first = row_by_id[ordered[0]]
  559. session.add(
  560. business_audit(
  561. action=AuditAction.REORDER_ORGANIZATION,
  562. target=AuditTarget.ORGANIZATION,
  563. target_id=first.id,
  564. target_name=first.org_name,
  565. detail={
  566. "scope": serialize_id(parent_id)
  567. if parent_id is not None
  568. else "ROOT",
  569. "orderedIds": list(ordered_ids),
  570. "reorderedCount": reordered_count,
  571. },
  572. )
  573. )
  574. return {"reorderedCount": reordered_count}