"""Employee self-service onboarding endpoint — ``/me/onboarding``. PR-4 of the symmetric-onboarding series (2026-06-01). Backs the new-hire-facing page where the employee sees their own Day-1 readiness and the self-service entries (VPN request / AI tool request / extra software seat). Auth: any authenticated person; the endpoint scopes to ``current_user.id`` itself. 404 if the caller has no open onboarding process (the backfill window is 60 days, so this only fires for very old hires). """ from __future__ import annotations from datetime import datetime from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.exceptions import AppException from app.core.response import success_response from app.domains.onboarding.ack_service import OnboardingAckService from app.domains.onboarding.cockpit_service import ( _DIM_TO_RESOURCE, DIMENSIONS, ) from app.domains.onboarding.models import ( PROC_OPEN_STATUSES, RESO_PENDING, OnboardingBlocker, OnboardingProcess, compute_triage_bucket, ) from app.identity.identity_reconcile import LOGIN_PROVIDERS from app.identity.person_models import IdentityExternalIdentity, IdentityPerson router = APIRouter( prefix="/api/v1/me/onboarding", tags=["onboarding", "me"], ) @router.get("") async def get_my_onboarding( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Caller's own onboarding process + blockers + readiness + 入职须知.""" payload = await _build_self_payload(current_user.id, db) return success_response(data=payload, request=request) @router.post("/acknowledgements/{item_id}") async def acknowledge_item( item_id: UUID, request: Request, source: str = Query(default="web", pattern="^(web|mobile)$"), current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Confirm a 入职须知 checklist item. Idempotent — re-confirming an already-acknowledged item returns the original confirmation. ``source=mobile`` marks confirms made from the H5 surface (the MCP tool stamps ``mcp`` itself).""" trace_id = getattr(request.state, "trace_id", str(item_id)) ack = await OnboardingAckService(db).acknowledge( current_user.id, item_id, source=source, trace_id=trace_id, ) await db.commit() return success_response( data={ "item_id": str(ack.item_id), "acknowledged_at": ( ack.acknowledged_at.isoformat() if ack.acknowledged_at else None ), }, request=request, ) async def _build_self_payload( person_id: UUID, db: AsyncSession, ) -> dict: proc = ( await db.execute( select(OnboardingProcess).where( OnboardingProcess.person_id == person_id, OnboardingProcess.status.in_(PROC_OPEN_STATUSES), ) ) ).scalar_one_or_none() if proc is None: raise AppException( code="NO_OPEN_ONBOARDING_PROCESS", message=( "no open onboarding process for this user — either " "you're not within the onboarding window, or the " "platform never tracked your hire end-to-end" ), status_code=404, ) blockers = ( ( await db.execute( select(OnboardingBlocker).where( OnboardingBlocker.process_id == proc.id, ) ) ) .scalars() .all() ) person = await db.get(IdentityPerson, person_id) manager = ( await db.get(IdentityPerson, person.manager_person_id) if person and person.manager_person_id else None ) pending_resource_types = { b.resource_type for b in blockers if b.resolution == RESO_PENDING } # #1783: the 账号 dimension must reflect a REAL login account, not # ``employment_status``. The admin cockpit was fixed 2026-07-06 to # derive account/sso/email readiness from an active ``is_login_identity`` # external identity (a hire with no Google account is not "account # ready"); the employee self-view kept the old ``employment_status == # 'active'`` check, which false-greened the 账号 light for a brand-new # hire who has no account yet while sso/work_email correctly showed # pending. Mirror the cockpit's single source of truth. has_login_identity = bool( person and ( await db.execute( select(IdentityExternalIdentity.id).where( IdentityExternalIdentity.person_id == person.id, IdentityExternalIdentity.is_login_identity.is_(True), IdentityExternalIdentity.identity_status == "active", IdentityExternalIdentity.provider.in_(LOGIN_PROVIDERS), IdentityExternalIdentity.deleted_at.is_(None), ) ) ).first() ) dims = {} for d in DIMENSIONS: if d == "account": dims[d] = has_login_identity else: rt = _DIM_TO_RESOURCE[d] dims[d] = rt not in pending_resource_types ready_count = sum(1 for v in dims.values() if v) return { "process": { "id": str(proc.id), "hire_date": proc.hire_date.isoformat(), "status": proc.status, "sla_breach_level": proc.sla_breach_level, "live_at": _iso(proc.live_at), "completed_at": _iso(proc.completed_at), }, "self": { "display_name": ( (person.display_name or person.work_email) if person else None ), "work_email": person.work_email if person else None, "job_title": person.job_title if person else None, "department": person.company_name if person else None, "hire_date": ( person.hire_date.isoformat() if person and person.hire_date else None ), }, "manager": ( { "id": str(manager.id), "display_name": manager.display_name or manager.work_email, "work_email": manager.work_email, } if manager else None ), "dimensions": dims, "total_dimensions": len(DIMENSIONS), "ready_count": ready_count, "fully_ready": ready_count == len(DIMENSIONS), "acknowledgements": await OnboardingAckService(db).my_ack_status( person_id, ), "blockers": [ { "id": str(b.id), "resource_type": b.resource_type, "label": b.label, "severity": b.severity, "resolution": b.resolution, "triage_bucket": compute_triage_bucket( resolution=b.resolution, severity=b.severity, error_count=int(b.error_count), ), } for b in blockers if b.resolution == RESO_PENDING ], } def _iso(value: datetime | None) -> str | None: return value.isoformat() if value else None