"""HR-facing attendance monthly-report endpoints. Most routes are gated to ``hr`` / ``global_admin`` — generation, the report board, and send/resend are HR operations. The one-click confirm endpoints (``/confirm``) are intentionally unauthenticated: the signed capability token in the URL authorises a single person to confirm a single report (no Helix login needed in the Feishu card). The intern/leader self-service H5 surface lives in ``me_router.py``. """ from __future__ import annotations import html from uuid import UUID import jwt from fastapi import APIRouter, Depends, Form, Query, Request from fastapi.responses import HTMLResponse from sqlalchemy.ext.asyncio import AsyncSession from app.audit.service import AuditService from app.core.database import get_db from app.core.dependencies import ( enforce_action_token_subject, get_current_user, require_roles, ) from app.core.exceptions import AppException, NotFoundError from app.core.rbac import GLOBAL_ADMIN_ROLE, HR_ROLE from app.core.response import paginated_response, success_response from app.core.security import decode_action_token from app.domains.attendance import constants as c from app.domains.attendance.lifecycle import AttendanceLifecycleService from app.domains.attendance.schemas import ( GenerateCycleRequest, RosterOverrideRequest, ) from app.domains.attendance.serializers import ( load_person_index, serialize_cycle, serialize_event, serialize_report, serialize_roster_override, ) from app.domains.attendance.service import AttendanceService from app.identity.person_models import IdentityPerson router = APIRouter(prefix="/api/v1/attendance", tags=["attendance"]) _hr_gate = Depends(require_roles(HR_ROLE, GLOBAL_ADMIN_ROLE)) @router.post("/cycles/generate", dependencies=[_hr_gate]) async def generate_cycle( body: GenerateCycleRequest, request: Request, current_user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Generate (or idempotently refresh) all intern reports for the cycle whose end date falls in ``year``-``month``.""" svc = AttendanceService(db) trace_id = getattr(request.state, "trace_id", None) stats = await svc.generate_cycle( year=body.year, month=body.month, actor_user_id=current_user.id, trace_id=str(trace_id) if trace_id else "", ) await AuditService(db).log( trace_id=trace_id or "", actor_type="user", actor_user_id=current_user.id, action="attendance.cycle.generate", status="success", resource_type="attendance_cycle", resource_id=stats.get("cycle_id"), detail={k: v for k, v in stats.items() if k != "cycle_id"}, ip_address=request.client.host if request.client else None, ) await db.commit() return success_response(data=stats, request=request) # ── HR: roster overlay (include / exclude) ───────────────────────── @router.get("/roster/overrides", dependencies=[_hr_gate]) async def list_roster_overrides( request: Request, db: AsyncSession = Depends(get_db), ) -> dict: """List the HR-maintained roster overlay — people manually pulled into (``include``) or kept out of (``exclude``) the report on top of the Feishu department base.""" svc = AttendanceService(db) rows = await svc.list_roster_overrides() person_index = await load_person_index(db, {r.person_id for r in rows}) includes, excludes = [], [] for r in rows: item = serialize_roster_override(r, person_index=person_index) (includes if r.mode == "include" else excludes).append(item) return success_response( data={"includes": includes, "excludes": excludes}, request=request, ) @router.put("/roster/overrides", dependencies=[_hr_gate]) async def set_roster_override( body: RosterOverrideRequest, request: Request, current_user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Add or switch a person on the roster overlay (idempotent upsert).""" person = await db.get(IdentityPerson, body.person_id) if person is None: raise NotFoundError("人员不存在") svc = AttendanceService(db) row = await svc.set_roster_override( person_id=body.person_id, mode=body.mode, note=body.note, actor_user_id=current_user.id, ) trace_id = getattr(request.state, "trace_id", None) await AuditService(db).log( trace_id=trace_id or "", actor_type="user", actor_user_id=current_user.id, action="attendance.roster.override.set", status="success", resource_type="identity_person", resource_id=str(body.person_id), detail={"mode": body.mode, "note": body.note}, ip_address=request.client.host if request.client else None, ) await db.commit() person_index = await load_person_index(db, {row.person_id}) return success_response( data=serialize_roster_override(row, person_index=person_index), request=request, ) @router.delete("/roster/overrides/{person_id}", dependencies=[_hr_gate]) async def delete_roster_override( person_id: UUID, request: Request, current_user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Remove a person's roster override (back to Feishu-department default).""" svc = AttendanceService(db) removed = await svc.remove_roster_override(person_id) if not removed: raise NotFoundError("该人员没有名单调整记录") trace_id = getattr(request.state, "trace_id", None) await AuditService(db).log( trace_id=trace_id or "", actor_type="user", actor_user_id=current_user.id, action="attendance.roster.override.delete", status="success", resource_type="identity_person", resource_id=str(person_id), ip_address=request.client.host if request.client else None, ) await db.commit() return success_response(data={"person_id": str(person_id)}, request=request) @router.get("/cycles", dependencies=[_hr_gate]) async def list_cycles( request: Request, db: AsyncSession = Depends(get_db), ) -> dict: svc = AttendanceService(db) cycles = await svc.list_cycles() counts = await svc.report_counts([c.id for c in cycles]) data = [ serialize_cycle(c, report_count=counts.get(c.id, 0)) for c in cycles ] return success_response(data=data, request=request) @router.get("/reports", dependencies=[_hr_gate]) async def list_reports( request: Request, cycle_id: UUID | None = Query(None), status: str | None = Query(None), person_id: UUID | None = Query(None), page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200), db: AsyncSession = Depends(get_db), ) -> dict: if status is not None and status not in c.ALL_STATUSES: from app.core.exceptions import ValidationError raise ValidationError(f"unknown status: {status}") svc = AttendanceService(db) reports, total = await svc.list_reports( cycle_id=cycle_id, status=status, person_id=person_id, page=page, page_size=page_size, ) # Resolve person names (interns + leaders) + cycle keys in batch. person_ids = {r.person_id for r in reports} person_ids |= {r.leader_person_id for r in reports if r.leader_person_id} person_index = await load_person_index(db, person_ids) cycle_index = await svc.get_cycles({r.cycle_id for r in reports}) cycle_keys: dict[UUID, str] = { cid: cyc.cycle_key for cid, cyc in cycle_index.items() } data = [ serialize_report( r, person_index=person_index, cycle_key=cycle_keys.get(r.cycle_id), ) for r in reports ] return paginated_response( data=data, total=total, page=page, page_size=page_size, request=request, ) @router.get("/reports/{report_id}", dependencies=[_hr_gate]) async def get_report_detail( report_id: UUID, request: Request, db: AsyncSession = Depends(get_db), ) -> dict: svc = AttendanceService(db) report = await svc.get_report(report_id) if report is None: raise NotFoundError("考勤报告不存在") events = await svc.get_report_events(report_id) cycle = await svc.get_cycle(report.cycle_id) person_ids: set[UUID] = {report.person_id} if report.leader_person_id: person_ids.add(report.leader_person_id) person_ids |= {e.actor_person_id for e in events if e.actor_person_id} person_index = await load_person_index(db, person_ids) data = serialize_report( report, person_index=person_index, cycle_key=cycle.cycle_key if cycle else None, include_snapshot=True, ) data["events"] = [serialize_event(e, person_index=person_index) for e in events] return success_response(data=data, request=request) # ── HR: send / resend ─────────────────────────────────────────────── @router.post("/reports/{report_id}/send", dependencies=[_hr_gate]) async def send_report( report_id: UUID, request: Request, current_user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Send (or re-send after return) a report to its intern for confirmation. Only ``generated`` / ``returned`` reports can be sent.""" svc = AttendanceLifecycleService(db) report = await svc.load_report(report_id, lock=True) trace_id = str(getattr(request.state, "trace_id", "") or "") await svc.send_to_intern(report, actor_user_id=current_user.id, trace_id=trace_id) await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=current_user.id, action="attendance.report.send", status="success", resource_type="attendance_report", resource_id=str(report.id), ip_address=request.client.host if request.client else None, ) await db.commit() return success_response( data={"id": str(report.id), "status": report.status}, request=request, ) @router.post("/reports/{report_id}/resend", dependencies=[_hr_gate]) async def resend_report( report_id: UUID, request: Request, current_user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Re-pull the latest Feishu attendance for one report (reflecting 补卡) and send it to the intern again. Used after a return.""" svc = AttendanceLifecycleService(db) report = await svc.load_report(report_id, lock=True) trace_id = str(getattr(request.state, "trace_id", "") or "") await svc.resend(report, actor_user_id=current_user.id, trace_id=trace_id) await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=current_user.id, action="attendance.report.resend", status="success", resource_type="attendance_report", resource_id=str(report.id), detail={"version": report.version}, ip_address=request.client.host if request.client else None, ) await db.commit() return success_response( data={"id": str(report.id), "status": report.status, "version": report.version}, request=request, ) @router.post("/reports/{report_id}/withdraw", dependencies=[_hr_gate]) async def withdraw_report( report_id: UUID, request: Request, current_user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Pull a sent report back to ``generated`` (cancels a pending intern/leader confirmation). HR-only.""" svc = AttendanceLifecycleService(db) report = await svc.load_report(report_id, lock=True) trace_id = str(getattr(request.state, "trace_id", "") or "") await svc.withdraw(report, actor_user_id=current_user.id, trace_id=trace_id) await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=current_user.id, action="attendance.report.withdraw", status="success", resource_type="attendance_report", resource_id=str(report.id), ip_address=request.client.host if request.client else None, ) await db.commit() return success_response( data={"id": str(report.id), "status": report.status}, request=request, ) @router.post("/reports/{report_id}/reinitiate", dependencies=[_hr_gate]) async def reinitiate_report( report_id: UUID, request: Request, current_user: IdentityPerson = Depends(get_current_user), db: AsyncSession = Depends(get_db), ) -> dict: """Re-open a completed report: re-pull the latest Feishu data, reset the prior sign-offs (bumping the version), and send to the intern again for a fresh confirmation round. HR-only.""" svc = AttendanceLifecycleService(db) report = await svc.load_report(report_id, lock=True) trace_id = str(getattr(request.state, "trace_id", "") or "") await svc.reinitiate(report, actor_user_id=current_user.id, trace_id=trace_id) await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=current_user.id, action="attendance.report.reinitiate", status="success", resource_type="attendance_report", resource_id=str(report.id), detail={"version": report.version}, ip_address=request.client.host if request.client else None, ) await db.commit() return success_response( data={"id": str(report.id), "status": report.status, "version": report.version}, request=request, ) # ── One-click confirm (no login — capability token in the URL) ────── _PAGE_TMPL = ( "" "" "{title}
{body}
" ) def _page(*, title: str, body_html: str, tone: str = "") -> HTMLResponse: return HTMLResponse(_PAGE_TMPL.format( title=html.escape(title), body=body_html, tone=tone, )) def _invalid_link() -> HTMLResponse: return _page( title="链接无效", tone="err", body_html=( "

链接无效或已过期

" "

请回到飞书卡片重新点击,或在 Helix「我的考勤」页面操作。

" ), ) @router.get("/confirm", include_in_schema=False) async def confirm_page( token: str, db: AsyncSession = Depends(get_db), ) -> HTMLResponse: """Show a confirm page with a POST form (GET never mutates).""" try: payload = decode_action_token( token, expected_purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, ) report_id = UUID(payload["res"]) subject_id = UUID(payload["sub"]) # The token is signed but carries no lifecycle state, so it keeps # authorising for its full 14-day life. Every other mutating # action-token consumer calls this helper; attendance was the one # that did not, which made a leaver's un-clicked link the only # way to reach a payroll input after force-logout (#2125). It # raises ValueError precisely so the except below renders the # neutral "link invalid" page. await enforce_action_token_subject(db, payload) except (jwt.InvalidTokenError, ValueError, KeyError): return _invalid_link() report = await AttendanceService(db).get_report(report_id) if report is None: return _invalid_link() # Defence-in-depth: the token is signed, but still only reveal the # report to the person it was issued to (its intern or leader) so a # leaked/mis-routed token can't be used to enumerate report status. if subject_id not in (report.person_id, report.leader_person_id): return _invalid_link() if report.status == c.STATUS_COMPLETED: return _page(title="已完成", tone="ok", body_html=( "

该考勤月报已完成

无需再操作。

")) if report.status == c.STATUS_RETURNED: return _page(title="已退回", tone="", body_html=( "

该报告已退回 HR

补卡处理后 HR 会重新发送,届时再确认。

")) safe = html.escape(token) return _page(title="确认考勤月报", body_html=( "

确认本期考勤月报无误?

" "

点击下方按钮即代表你已核对本考勤周期的打卡结果。" "如有缺卡未补等问题,请改到「我的考勤」页面退回。

" "
" f"" "
" )) @router.post("/confirm", include_in_schema=False) async def confirm_submit( request: Request, token: str = Form(...), db: AsyncSession = Depends(get_db), ) -> HTMLResponse: try: payload = decode_action_token( token, expected_purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, ) report_id = UUID(payload["res"]) actor_id = UUID(payload["sub"]) # See the GET twin: this is the write half — it advances the # report (intern → pending_leader, leader → completed) with no # login, no cookie and no bearer, and the report is the payroll # basis. await enforce_action_token_subject(db, payload) except (jwt.InvalidTokenError, ValueError, KeyError): return _invalid_link() svc = AttendanceLifecycleService(db) trace_id = str(getattr(request.state, "trace_id", "") or "") try: report = await svc.load_report(report_id, lock=True) _, side = await svc.confirm_via_token( report, actor_person_id=actor_id, trace_id=trace_id, ) await AuditService(db).log( trace_id=trace_id, actor_type="user", actor_user_id=actor_id, action="attendance.report.confirm", status="success", resource_type="attendance_report", resource_id=str(report.id), detail={"via": "one_click", "role": side, "status": report.status}, ip_address=request.client.host if request.client else None, ) await db.commit() except AppException as exc: await db.rollback() return _page(title="无法确认", tone="err", body_html=( f"

无法确认

{html.escape(exc.message or exc.code)}

")) nxt = ( "已转交你的 leader 复核。" if side == c.SIDE_INTERN else "考勤月报已最终确认完成。" ) return _page(title="已确认", tone="ok", body_html=( f"

已确认

{nxt}感谢!

"))