organization_service.py 18 KB

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