organization_service.py 19 KB

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