"""「公司还没有这个流程,我想要一个」 — and what happens after IT says yes. The register publishes what exists. This is how something gets to exist, and it is deliberately the *whole* lifecycle rather than a suggestion box: 员工提申请 → IT 批 → IT 在飞书控制台建流程 → 回填 code → 台账自动登记,负责人 = 申请人 → 通知申请人 The last two steps are the ones that usually go missing. An approved request nobody built is an employee still waiting, and a flow built without being registered is one more row the register does not know about — so ``approved`` is treated as an OPEN state and only ``registered`` is success. **Why Helix does not create the flow itself.** Feishu's API can create an approval definition, and it is the wrong tool: a definition created that way can never be deactivated or deleted — Feishu documents that outright, recommends self-built apps not use it, and this tenant already carries two undeletable probe definitions as proof. Building it in the console keeps the owner's ability to change or stop it later, which is half of what owning it means. """ from __future__ import annotations import logging import uuid from datetime import UTC, datetime from sqlalchemy import Select, func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from app.core.exceptions import ConflictError, NotFoundError, ValidationError from app.domains.approval_registry import constants as const from app.domains.approval_registry import service as svc from app.domains.approval_registry.models import ApprovalFlow, ApprovalFlowRequest from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) def _now() -> datetime: return datetime.now(UTC) def _naive(dt: datetime) -> datetime: return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt def _base_query() -> Select[tuple[ApprovalFlowRequest]]: return select(ApprovalFlowRequest).options( selectinload(ApprovalFlowRequest.requester), selectinload(ApprovalFlowRequest.proposed_owner), ).execution_options(populate_existing=True) async def get_request( db: AsyncSession, request_id: uuid.UUID, ) -> ApprovalFlowRequest: row = ( await db.execute(_base_query().where(ApprovalFlowRequest.id == request_id)) ).scalar_one_or_none() if row is None: raise NotFoundError("ApprovalFlowRequest", str(request_id)) return row async def list_requests( db: AsyncSession, *, status: str | None = None, requester_person_id: uuid.UUID | None = None, open_only: bool = False, page: int = 1, page_size: int = 20, ) -> tuple[list[ApprovalFlowRequest], int]: stmt = _base_query() if status: stmt = stmt.where(ApprovalFlowRequest.status == status) if open_only: stmt = stmt.where( ApprovalFlowRequest.status.in_(sorted(const.FLOW_REQ_OPEN_STATUSES)), ) if requester_person_id is not None: stmt = stmt.where( ApprovalFlowRequest.requester_person_id == requester_person_id, ) total = ( await db.execute( select(func.count()).select_from(stmt.order_by(None).subquery()), ) ).scalar_one() rows = ( await db.execute( stmt.order_by(ApprovalFlowRequest.created_at.desc()) .offset((page - 1) * page_size) .limit(page_size), ) ).scalars().all() return list(rows), int(total) async def submit_request( db: AsyncSession, *, requester: IdentityPerson, proposed_name: str, purpose: str, proposed_category: str | None = None, proposed_owner_person_id: uuid.UUID | None = None, ) -> ApprovalFlowRequest: """File a request for a flow that does not exist yet. ``purpose`` is required and is the single most useful field on the row: IT decides from it, and it becomes the published description so the next person knows when to use the flow. A request that only says 「要个流程」 costs a round-trip that the form can prevent. """ name = (proposed_name or "").strip() if not name: raise ValidationError("proposed_name is required") if len(name) > const.MAX_NAME_CHARS: raise ValidationError( f"proposed_name exceeds {const.MAX_NAME_CHARS} characters", ) reason = (purpose or "").strip() if len(reason) < 5: raise ValidationError( "说明一下这个流程用来做什么 —— IT 靠它判断,之后它也会显示在" "公示页上,告诉下一个人什么时候该走这条流程", ) if len(reason) > const.MAX_PURPOSE_CHARS: raise ValidationError( f"purpose exceeds {const.MAX_PURPOSE_CHARS} characters", ) owner_id = proposed_owner_person_id or requester.id # A proposed owner who has left is refused here rather than at # registration: telling the requester now costs one message, telling # them after IT built the flow costs the flow a second handover. svc.assert_assignable(await svc.load_person(db, owner_id)) row = ApprovalFlowRequest( requester_person_id=requester.id, proposed_name=name, purpose=reason, proposed_category=(proposed_category or "").strip() or None, proposed_owner_person_id=owner_id, status=const.FLOW_REQ_PENDING, ) db.add(row) await db.flush() return row async def cancel_request( db: AsyncSession, row: ApprovalFlowRequest, *, actor: IdentityPerson, ) -> ApprovalFlowRequest: if row.requester_person_id != actor.id: raise ConflictError( "only the requester can withdraw this request", code="APPROVAL_FLOW_REQUEST_NOT_YOURS", ) if row.status != const.FLOW_REQ_PENDING: raise ConflictError( f"request is {row.status}; only a pending request can be withdrawn", code="APPROVAL_FLOW_REQUEST_NOT_PENDING", ) row.status = const.FLOW_REQ_CANCELLED row.decided_at = _naive(_now()) return row async def decide_request( db: AsyncSession, row: ApprovalFlowRequest, *, approve: bool, actor: IdentityPerson | None, note: str | None = None, ) -> ApprovalFlowRequest: """IT's decision. A rejection MUST carry a reason. 「不批」 without one sends the employee back to guessing, and the guess is usually 「再提一次」 — the same request, the same cost, one week later. """ if row.status != const.FLOW_REQ_PENDING: raise ConflictError( f"request is already {row.status}", code="APPROVAL_FLOW_REQUEST_DECIDED", ) clean_note = (note or "").strip() or None if not approve and not clean_note: raise ValidationError("驳回时请写明原因 —— 申请人要据此决定下一步") row.status = const.FLOW_REQ_APPROVED if approve else const.FLOW_REQ_REJECTED row.decided_by = actor.id if actor else None row.decided_at = _naive(_now()) row.decision_note = clean_note return row async def register_built_flow( db: AsyncSession, row: ApprovalFlowRequest, *, approval_code: str, actor: IdentityPerson | None, name: str | None = None, ) -> ApprovalFlow: """IT built it in Feishu; this closes the loop. Registers the flow with **the requester (or their nominee) as owner** and links it back to the request. Idempotent on the code: if the nightly discovery sweep already found the flow, this adopts that row rather than failing on the UNIQUE index — the sweep and the person are describing the same object. """ # APPROVED only. The message always said so; accepting ``pending`` as # well was an accidental path that registered a flow AND stamped the # request as decided without anybody deciding it. if row.status != const.FLOW_REQ_APPROVED: raise ConflictError( f"request is {row.status}; only an approved request can be " "completed with a flow code", code="APPROVAL_FLOW_REQUEST_NOT_APPROVED", ) code = svc.normalize_code(approval_code) owner_id = row.proposed_owner_person_id or row.requester_person_id owner = await svc.load_person(db, owner_id) existing = await svc.get_by_code(db, code) if existing is not None: flow = existing if flow.owner_person_id != owner.id: await svc.set_owner( db, flow, owner_person_id=owner.id, actor=actor, owner_source=const.OWNER_SOURCE_MANUAL, note=f"按申请登记:{row.proposed_name}", # The caller announces this one itself, and better: # ``flow_request_registered`` tells the requester their # flow is ready AND that they own it, in one message # that knows what they asked for. A generic 「你现在是 # 负责人」 alongside it is the same fact twice. announce=None, ) # The sweep's name is upstream's and wins; the request's purpose # is still the best description anybody has written. if not flow.description: await svc.update_flow( db, flow, description=row.purpose, actor=actor, ) else: flow = await svc.create_flow( db, approval_code=code, name=(name or row.proposed_name), category=row.proposed_category, description=row.purpose, owner_person_id=owner.id, actor=actor, source=const.SOURCE_MANUAL, # See the adopt branch above: the caller sends # ``flow_request_registered``, which says this and more. announce=None, ) await db.flush() # Load the requester rather than reading ``row.requester``: the # relationship is ``lazy="raise"``, so this function would only work # for rows that happened to come through a query with the right # ``selectinload`` — it raises for a caller that built the row any # other way, and the caller is a notification path wrapped in a # try/except, so the failure is silent. requester = await svc.load_person_optional(db, row.requester_person_id) await svc.add_event( db, flow, event_type=const.EVENT_CREATED, actor=actor, body=( f"按 {svc.person_label(requester) or '员工'} 的申请建立," f"负责人 {svc.person_label(owner)}" ), meta={"request_id": str(row.id), "approval_code": code}, ) row.status = const.FLOW_REQ_REGISTERED row.registered_flow_id = flow.id row.registered_at = _naive(_now()) if actor is not None and row.decided_by is None: row.decided_by = actor.id row.decided_at = row.decided_at or _naive(_now()) return flow async def open_request_count(db: AsyncSession) -> int: """What the admin page badges. ``approved`` counts as open: it looks finished from IT's side and is not from the employee's.""" return int( ( await db.execute( select(func.count()) .select_from(ApprovalFlowRequest) .where( ApprovalFlowRequest.status.in_( sorted(const.FLOW_REQ_OPEN_STATUSES), ), ), ) ).scalar_one(), )