organization_service.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540
  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. require_organization_editor,
  23. )
  24. from dms.services.audit_service import business_audit
  25. CREATE_FIELDS = {
  26. "orgCode",
  27. "orgName",
  28. "parentId",
  29. "sortNo",
  30. "status",
  31. }
  32. UPDATE_FIELDS = {
  33. "orgCode",
  34. "orgName",
  35. "parentId",
  36. "sortNo",
  37. "status",
  38. "rowVersion",
  39. }
  40. def _utc_now() -> datetime:
  41. return datetime.now(timezone.utc).replace(tzinfo=None)
  42. def _string_id(value: Any, *, field: str, nullable: bool = False) -> int | None:
  43. if value is None and nullable:
  44. return None
  45. if not isinstance(value, str) or not value.isdecimal() or int(value) <= 0:
  46. raise InvalidArgumentError(f"{field}必须是正整数形式的字符串ID")
  47. return int(value)
  48. def _required_text(
  49. value: Any,
  50. *,
  51. field: str,
  52. max_length: int,
  53. ) -> str:
  54. if not isinstance(value, str) or not value.strip():
  55. raise InvalidArgumentError(f"{field}不能为空")
  56. normalized = value.strip()
  57. if len(normalized) > max_length:
  58. raise InvalidArgumentError(f"{field}长度不能超过{max_length}")
  59. return normalized
  60. def _status_value(value: Any) -> str:
  61. if not isinstance(value, str):
  62. raise InvalidArgumentError("status必须是字符串")
  63. try:
  64. return EnabledStatus(value).value
  65. except ValueError as exc:
  66. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  67. def _sort_no(value: Any) -> int:
  68. if type(value) is not int:
  69. raise InvalidArgumentError("sortNo必须是整数")
  70. return value
  71. def _row_version(value: Any) -> int:
  72. if type(value) is not int or value < 0:
  73. raise InvalidArgumentError("rowVersion必须是非负整数")
  74. return value
  75. def _exact_payload(payload: Any, expected: set[str]) -> dict[str, Any]:
  76. if not isinstance(payload, dict) or set(payload) != expected:
  77. raise InvalidArgumentError(
  78. "请求体字段必须且只能包含:" + "、".join(sorted(expected))
  79. )
  80. return payload
  81. def _build_path(parent_path: str | None, org_name: str) -> str:
  82. """根据父节点路径构建当前节点的 org_path,并校验长度上限。"""
  83. value = (
  84. f"{parent_path.rstrip('/')}/{org_name}"
  85. if parent_path is not None
  86. else f"/{org_name}"
  87. )
  88. if len(value) > 1000:
  89. raise InvalidArgumentError("orgPath长度超过数据库限制")
  90. return value
  91. def _org_path(parent: Organization | None, org_name: str) -> str:
  92. return _build_path(parent.org_path if parent is not None else None, org_name)
  93. def _dto(
  94. organization: Organization,
  95. *,
  96. children: list[dict[str, object]] | None = None,
  97. ) -> dict[str, object]:
  98. return {
  99. "id": serialize_id(organization.id),
  100. "parentId": serialize_id(organization.parent_id),
  101. "orgCode": organization.org_code,
  102. "orgName": organization.org_name,
  103. "orgPath": organization.org_path,
  104. "sortNo": organization.sort_no,
  105. "status": organization.status,
  106. "rowVersion": organization.row_version,
  107. "children": children or [],
  108. }
  109. def _children_map(
  110. organizations: list[Organization],
  111. ) -> dict[int | None, list[Organization]]:
  112. children: dict[int | None, list[Organization]] = {}
  113. for organization in organizations:
  114. children.setdefault(organization.parent_id, []).append(organization)
  115. return children
  116. def _descendants(
  117. organization_id: int,
  118. children: dict[int | None, list[Organization]],
  119. ) -> list[Organization]:
  120. result: list[Organization] = []
  121. pending = deque(children.get(organization_id, []))
  122. seen = {organization_id}
  123. while pending:
  124. organization = pending.popleft()
  125. if organization.id in seen:
  126. raise ConflictError("机构树存在循环关系")
  127. seen.add(organization.id)
  128. result.append(organization)
  129. pending.extend(children.get(organization.id, []))
  130. return result
  131. def organization_tree(
  132. *, keyword: str | None, status: str | None
  133. ) -> list[dict[str, object]]:
  134. organizations = db.session.scalars(
  135. select(Organization)
  136. .where(Organization.is_deleted.is_(False))
  137. .order_by(Organization.sort_no.asc(), Organization.id.asc())
  138. ).all()
  139. by_id = {item.id: item for item in organizations}
  140. if status:
  141. try:
  142. normalized_status = EnabledStatus(status).value
  143. except ValueError as exc:
  144. raise InvalidArgumentError("status必须是ENABLED或DISABLED") from exc
  145. else:
  146. normalized_status = EnabledStatus.ENABLED.value
  147. base_ids = {item.id for item in organizations if item.status == normalized_status}
  148. if keyword and keyword.strip():
  149. needle = keyword.strip().casefold()
  150. base_ids = {
  151. item.id
  152. for item in organizations
  153. if item.id in base_ids
  154. and (needle in item.org_name.casefold() or needle in item.org_code.casefold())
  155. }
  156. included_ids = set(base_ids)
  157. for item_id in tuple(base_ids):
  158. parent_id = by_id[item_id].parent_id
  159. while parent_id is not None and parent_id in by_id:
  160. included_ids.add(parent_id)
  161. parent_id = by_id[parent_id].parent_id
  162. nodes = {
  163. item.id: _dto(item)
  164. for item in organizations
  165. if item.id in included_ids
  166. }
  167. roots: list[dict[str, object]] = []
  168. for item in organizations:
  169. if item.id not in nodes:
  170. continue
  171. node = nodes[item.id]
  172. if item.parent_id in nodes:
  173. nodes[item.parent_id]["children"].append(node)
  174. else:
  175. roots.append(node)
  176. return roots
  177. def _locked_organizations(session) -> list[Organization]:
  178. return session.scalars(
  179. select(Organization)
  180. .where(Organization.is_deleted.is_(False))
  181. .order_by(Organization.id.asc())
  182. .with_for_update()
  183. ).all()
  184. def _valid_parent(
  185. session, parent_id: int | None
  186. ) -> Organization | None:
  187. if parent_id is None:
  188. return None
  189. parent = session.scalar(
  190. select(Organization)
  191. .where(
  192. Organization.id == parent_id,
  193. Organization.is_deleted.is_(False),
  194. )
  195. .with_for_update()
  196. )
  197. if parent is None:
  198. raise InvalidArgumentError("parentId必须指向有效机构")
  199. if parent.status != EnabledStatus.ENABLED.value:
  200. raise InvalidArgumentError("parentId必须指向已启用的机构")
  201. return parent
  202. def create_organization(payload: Any) -> dict[str, object]:
  203. body = _exact_payload(payload, CREATE_FIELDS)
  204. org_code = _required_text(body["orgCode"], field="orgCode", max_length=64)
  205. org_name = _required_text(body["orgName"], field="orgName", max_length=128)
  206. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  207. sort_no = _sort_no(body["sortNo"])
  208. status = _status_value(body["status"])
  209. actor_id = get_auth_context().user_id
  210. try:
  211. with transaction() as session:
  212. actor = require_organization_editor(session)
  213. ensure_org_in_managed_scope(session, actor, parent_id)
  214. if (
  215. session.scalar(
  216. select(Organization.id)
  217. .where(
  218. Organization.org_code == org_code,
  219. Organization.is_deleted.is_(False),
  220. )
  221. )
  222. is not None
  223. ):
  224. raise ConflictError("机构编码已存在")
  225. parent = _valid_parent(session, parent_id)
  226. organization = Organization(
  227. org_code=org_code,
  228. org_name=org_name,
  229. parent_id=parent_id,
  230. org_path=_org_path(parent, org_name),
  231. sort_no=sort_no,
  232. status=status,
  233. created_by=actor_id,
  234. updated_by=actor_id,
  235. )
  236. session.add(organization)
  237. session.flush()
  238. session.add(
  239. business_audit(
  240. action=AuditAction.CREATE_ORGANIZATION,
  241. target=AuditTarget.ORGANIZATION,
  242. target_id=organization.id,
  243. target_name=organization.org_name,
  244. detail={
  245. "orgCode": organization.org_code,
  246. "parentId": serialize_id(organization.parent_id),
  247. "orgPath": organization.org_path,
  248. "sortNo": organization.sort_no,
  249. "status": organization.status,
  250. },
  251. )
  252. )
  253. return _dto(organization)
  254. except IntegrityError as exc:
  255. raise ConflictError("机构编码已存在") from exc
  256. def update_organization(organization_id: int, payload: Any) -> dict[str, object]:
  257. body = _exact_payload(payload, UPDATE_FIELDS)
  258. org_code = _required_text(body["orgCode"], field="orgCode", max_length=64)
  259. org_name = _required_text(body["orgName"], field="orgName", max_length=128)
  260. parent_id = _string_id(body["parentId"], field="parentId", nullable=True)
  261. sort_no = _sort_no(body["sortNo"])
  262. status = _status_value(body["status"])
  263. expected_version = _row_version(body["rowVersion"])
  264. actor_id = get_auth_context().user_id
  265. now = _utc_now()
  266. with transaction() as session:
  267. actor = require_organization_editor(session)
  268. ensure_org_in_managed_scope(session, actor, organization_id)
  269. organizations = _locked_organizations(session)
  270. by_id = {item.id: item for item in organizations}
  271. organization = by_id.get(organization_id)
  272. if organization is None:
  273. raise ResourceNotFoundError("机构不存在")
  274. if organization.row_version != expected_version:
  275. raise ConflictError(
  276. "数据已被其他用户修改,请刷新后重试",
  277. details={"currentRowVersion": organization.row_version},
  278. )
  279. # 机构编码可编辑:保存时查重(排除自身,口径与创建一致——仅比对未删除机构)
  280. if (
  281. session.scalar(
  282. select(Organization.id)
  283. .where(
  284. Organization.org_code == org_code,
  285. Organization.id != organization.id,
  286. Organization.is_deleted.is_(False),
  287. )
  288. )
  289. is not None
  290. ):
  291. raise ConflictError("机构编码已存在")
  292. children = _children_map(organizations)
  293. descendants = _descendants(organization.id, children)
  294. descendant_ids = {item.id for item in descendants}
  295. if parent_id == organization.id:
  296. raise InvalidArgumentError("parentId不能等于当前机构ID")
  297. if parent_id in descendant_ids:
  298. raise InvalidArgumentError("机构不能移动到自己的后代节点下")
  299. parent: Organization | None = None
  300. if parent_id is not None:
  301. parent = by_id.get(parent_id)
  302. if (
  303. parent is None
  304. or parent.is_deleted
  305. or parent.status != EnabledStatus.ENABLED.value
  306. ):
  307. raise InvalidArgumentError("parentId必须指向有效且已启用的机构")
  308. before = {
  309. "orgCode": organization.org_code,
  310. "orgName": organization.org_name,
  311. "parentId": serialize_id(organization.parent_id),
  312. "orgPath": organization.org_path,
  313. "sortNo": organization.sort_no,
  314. "status": organization.status,
  315. "rowVersion": organization.row_version,
  316. }
  317. path_changed = (
  318. org_name != organization.org_name
  319. or parent_id != organization.parent_id
  320. )
  321. organization.org_code = org_code
  322. organization.org_name = org_name
  323. organization.parent_id = parent_id
  324. organization.org_path = _org_path(parent, org_name)
  325. organization.sort_no = sort_no
  326. organization.status = status
  327. organization.updated_by = actor_id
  328. organization.updated_at = now
  329. organization.row_version += 1
  330. affected_organizations = [organization]
  331. if path_changed:
  332. # 阶段1:预先计算所有受影响节点的新路径
  333. # 在修改任何对象前发现长度溢出,配合 transaction() 自动回滚保证一致性
  334. new_child_paths: dict[int, str] = {}
  335. validation_queue: deque[tuple[Organization, str]] = deque(
  336. [(organization, organization.org_path)]
  337. )
  338. while validation_queue:
  339. current, current_new_path = validation_queue.popleft()
  340. for child in children.get(current.id, []):
  341. new_child_path = _build_path(current_new_path, child.org_name)
  342. new_child_paths[child.id] = new_child_path
  343. validation_queue.append((child, new_child_path))
  344. # 阶段2:所有路径已校验通过,按 BFS 顺序应用变更
  345. pending = deque([organization])
  346. while pending:
  347. current = pending.popleft()
  348. for child in children.get(current.id, []):
  349. child.org_path = new_child_paths[child.id]
  350. child.updated_by = actor_id
  351. child.updated_at = now
  352. child.row_version += 1
  353. affected_organizations.append(child)
  354. pending.append(child)
  355. if path_changed:
  356. for affected in affected_organizations:
  357. session.execute(
  358. update(User)
  359. .where(
  360. User.organization_id == affected.id,
  361. User.is_deleted.is_(False),
  362. )
  363. .values(
  364. organization_name=affected.org_name,
  365. updated_by=actor_id,
  366. updated_at=now,
  367. row_version=User.row_version + 1,
  368. )
  369. )
  370. for affected in affected_organizations:
  371. session.execute(
  372. update(Document)
  373. .where(
  374. Document.organization_id == affected.id,
  375. Document.is_deleted.is_(False),
  376. )
  377. .values(
  378. organization_name=affected.org_name,
  379. updated_by=actor_id,
  380. updated_at=now,
  381. row_version=Document.row_version + 1,
  382. )
  383. )
  384. after = {
  385. "orgName": organization.org_name,
  386. "parentId": serialize_id(organization.parent_id),
  387. "orgPath": organization.org_path,
  388. "sortNo": organization.sort_no,
  389. "status": organization.status,
  390. "rowVersion": organization.row_version,
  391. }
  392. session.add(
  393. business_audit(
  394. action=AuditAction.EDIT_ORGANIZATION,
  395. target=AuditTarget.ORGANIZATION,
  396. target_id=organization.id,
  397. target_name=organization.org_name,
  398. detail={
  399. "before": before,
  400. "after": after,
  401. "updatedDescendantCount": len(affected_organizations) - 1,
  402. },
  403. )
  404. )
  405. return _dto(organization)
  406. def delete_organization(organization_id: int, row_version: int) -> dict[str, object]:
  407. actor_id = get_auth_context().user_id
  408. now = _utc_now()
  409. with transaction() as session:
  410. actor = require_organization_editor(session)
  411. ensure_org_in_managed_scope(session, actor, organization_id)
  412. organizations = _locked_organizations(session)
  413. by_id = {item.id: item for item in organizations}
  414. organization = by_id.get(organization_id)
  415. if organization is None:
  416. raise ResourceNotFoundError("机构不存在")
  417. if organization.row_version != row_version:
  418. raise ConflictError(
  419. "数据已被其他用户修改,请刷新后重试",
  420. details={"currentRowVersion": organization.row_version},
  421. )
  422. children = _children_map(organizations)
  423. descendants = _descendants(organization.id, children)
  424. child_count = len(descendants)
  425. user_count = (
  426. session.scalar(
  427. select(func.count(User.id)).where(
  428. User.organization_id == organization.id,
  429. User.is_deleted.is_(False),
  430. )
  431. )
  432. or 0
  433. )
  434. if child_count or user_count:
  435. raise OrganizationInUseError(
  436. details={
  437. "organizationId": serialize_id(organization.id),
  438. "childOrganizationCount": child_count,
  439. "userCount": user_count,
  440. }
  441. )
  442. target_name = organization.org_name
  443. organization.is_deleted = True
  444. organization.deleted_at = now
  445. organization.updated_at = now
  446. organization.updated_by = actor_id
  447. organization.row_version += 1
  448. # 部门删除后其IP分配行同事务软删,避免残留限制语义
  449. for row in session.scalars(
  450. select(OrgIpAssignment).where(
  451. OrgIpAssignment.organization_id == organization.id,
  452. OrgIpAssignment.is_deleted.is_(False),
  453. )
  454. ).all():
  455. row.is_deleted = True
  456. row.deleted_at = now
  457. row.updated_by = actor_id
  458. row.updated_at = now
  459. session.add(
  460. business_audit(
  461. action=AuditAction.DELETE_ORGANIZATION,
  462. target=AuditTarget.ORGANIZATION,
  463. target_id=organization.id,
  464. target_name=target_name,
  465. detail={
  466. "orgCode": organization.org_code,
  467. "orgPath": organization.org_path,
  468. "rowVersion": organization.row_version,
  469. },
  470. )
  471. )
  472. return {"id": serialize_id(organization.id), "deleted": True}
  473. def parse_organization_id(value: str) -> int:
  474. result = _string_id(value, field="id")
  475. assert result is not None
  476. return result
  477. def parse_delete_row_version(value: str | None) -> int:
  478. if value is None or not value.isdecimal():
  479. raise InvalidArgumentError("rowVersion必须是非负整数")
  480. return int(value)