"""Attendance report confirmation lifecycle (two-stage: intern → leader). State machine (guards enforced here; vocabulary in ``constants.py``): generated ─send─▶ pending_intern pending_intern ─intern_confirm─▶ pending_leader ─leader_confirm─▶ completed pending_intern ─intern_return──▶ returned pending_leader ─leader_return──▶ returned pending_leader ─intern_undo────▶ pending_intern (only before leader acts) returned ─HR resend (re-pull)──▶ pending_intern Returns (by intern OR leader) always go back to HR, who re-pulls Feishu (reflecting 补卡) and resends — until the leader finally confirms. Every transition is idempotent-guarded, writes an append-only event (by ``report_id`` — never via the lazy relationship), and stamps the request ``trace_id``. Notifications are best-effort: a Feishu hiccup never rolls back a committed state change. """ from __future__ import annotations import logging from datetime import UTC, datetime from uuid import UUID from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ForbiddenError, NotFoundError, ValidationError from app.core.security import create_action_token from app.domains.attendance import constants as c from app.domains.attendance.cards import build_confirmation_card from app.domains.attendance.models import ( AttendanceCycle, AttendanceReport, AttendanceReportEvent, ) from app.domains.attendance.service import AttendanceService from app.identity.person_models import IdentityPerson from app.notifications.service import NotificationEnvelope, NotificationService logger = logging.getLogger(__name__) class AttendanceLifecycleService: def __init__(self, db: AsyncSession) -> None: self.db = db # ── internal helpers ──────────────────────────────────────────── def _event( self, report: AttendanceReport, event_type: str, *, actor_person_id: UUID | None, from_status: str, to_status: str, reason: str | None = None, trace_id: str = "", ) -> None: # Add by report_id (never via report.events) to avoid a # sync-context lazy-load on autoflush — see service.py note. self.db.add(AttendanceReportEvent( report_id=report.id, event_type=event_type, actor_person_id=actor_person_id, from_status=from_status, to_status=to_status, reason=reason, trace_id=trace_id, )) async def _person(self, person_id: UUID | None) -> IdentityPerson | None: if person_id is None: return None return await self.db.get(IdentityPerson, person_id) async def _cycle(self, report: AttendanceReport) -> AttendanceCycle | None: return await self.db.get(AttendanceCycle, report.cycle_id) @staticmethod def _period_label(cycle: AttendanceCycle | None) -> str: if cycle is None: return "" return f"{cycle.start_date.isoformat()} ~ {cycle.end_date.isoformat()}" async def _safe_dispatch(self, envelope: NotificationEnvelope) -> None: """Best-effort notification — never let a delivery failure roll back a committed lifecycle transition.""" try: await NotificationService(self.db).dispatch(envelope) except Exception: # noqa: BLE001 logger.warning( "attendance: notification dispatch failed (report=%s, to=%s)", envelope.related_resource_id, getattr(envelope.recipient, "id", None), exc_info=True, ) async def _notify_hr(self, report: AttendanceReport, *, title: str, body: str) -> None: """Route attendance notices to the intern's **HRBP** — not the cycle generator / admin pool. The monthly report is an HRBP-owned flow; pinging the whole admin set on every completion/return is noise the owner explicitly rejected. When no HRBP resolves we skip the push (and log) rather than fall back to an admin — the report status stays visible in the HR console, so no signal is lost.""" intern = await self._person(report.person_id) recipient = None if intern is not None: from app.integrations.feishu_hrbp import resolve_hrbp_for_person res = await resolve_hrbp_for_person(self.db, intern) if res.person_id is not None: recipient = await self._person(res.person_id) if recipient is None: logger.info( "attendance: no HRBP resolved for intern %s — skipping HR push " "(report=%s); status remains visible in the console", report.person_id, report.id, ) return await self._safe_dispatch(NotificationEnvelope( recipient=recipient, title=title, body_text=body, channels=["feishu"], related_resource_type="attendance_report", related_resource_id=report.id, url=f"/admin/attendance?report={report.id}", )) # ── HR: send / resend ─────────────────────────────────────────── async def send_to_intern( self, report: AttendanceReport, *, actor_user_id: UUID, trace_id: str = "", ) -> AttendanceReport: if report.status not in (c.STATUS_GENERATED, c.STATUS_RETURNED): raise ValidationError( f"只能发送处于「{c.STATUS_GENERATED}/{c.STATUS_RETURNED}」" f"状态的报告(当前 {report.status})" ) prev = report.status report.status = c.STATUS_PENDING_INTERN report.last_sent_at = datetime.now(UTC) # A fresh send clears any stale return metadata. report.return_reason = None report.returned_by = None report.returned_by_person_id = None self._event( report, c.EVENT_SENT_TO_INTERN, actor_person_id=actor_user_id, from_status=prev, to_status=report.status, trace_id=trace_id, ) await self.db.flush() await self._dispatch_intern_card(report) return report async def resend( self, report: AttendanceReport, *, actor_user_id: UUID, trace_id: str = "", ) -> AttendanceReport: # Resend is for reports awaiting the intern or already returned — # never one that is correctly progressing at the leader stage # (resending would silently yank it back from the leader and # discard the intern's confirmation) or already completed. if report.status not in ( c.STATUS_GENERATED, c.STATUS_PENDING_INTERN, c.STATUS_RETURNED, ): raise ValidationError( f"当前状态不支持重发(已转交 leader 复核或已完成:{report.status})" ) # Re-pull the latest Feishu data (reflects 补卡), then send. await AttendanceService(self.db).repull_report( report, actor_user_id=actor_user_id, trace_id=trace_id, ) report.status = c.STATUS_GENERATED # normalise before send guard return await self.send_to_intern( report, actor_user_id=actor_user_id, trace_id=trace_id, ) async def withdraw( self, report: AttendanceReport, *, actor_user_id: UUID, trace_id: str = "", ) -> AttendanceReport: """HR pulls a sent report back to ``generated``, cancelling the pending confirmation. Use when a report was sent prematurely or the data needs to be re-pulled before anyone confirms. Only in-flight reports (待实习生/待 leader 确认) can be withdrawn — a completed report uses ``reinitiate`` instead.""" if report.status not in (c.STATUS_PENDING_INTERN, c.STATUS_PENDING_LEADER): raise ValidationError( f"只能撤回待实习生/待 leader 确认的报告(当前 {report.status})" ) prev = report.status report.status = c.STATUS_GENERATED # A withdraw discards any in-flight intern confirmation — the next # send starts a fresh round from the intern. report.intern_confirmed_at = None self._event( report, c.EVENT_WITHDRAWN, actor_person_id=actor_user_id, from_status=prev, to_status=report.status, trace_id=trace_id, ) await self.db.flush() await self._notify_withdraw(report, prev_status=prev) return report async def reinitiate( self, report: AttendanceReport, *, actor_user_id: UUID, trace_id: str = "", ) -> AttendanceReport: """Re-open a completed report for a fresh confirmation round. Re-pulls the latest Feishu data (reflecting a re-sync), clears the prior intern/leader confirmations and bumps the version, then sends to the intern again. Used when attendance data changed after the report was already finalised.""" if report.status != c.STATUS_COMPLETED: raise ValidationError( f"只能对已完成的报告再次发起(当前 {report.status})" ) await AttendanceService(self.db).repull_report( report, actor_user_id=actor_user_id, trace_id=trace_id, ) # New round — drop the prior sign-offs and start clean. report.intern_confirmed_at = None report.leader_confirmed_at = None report.status = c.STATUS_GENERATED # normalise before send guard self._event( report, c.EVENT_REINITIATED, actor_person_id=actor_user_id, from_status=c.STATUS_COMPLETED, to_status=c.STATUS_GENERATED, trace_id=trace_id, ) return await self.send_to_intern( report, actor_user_id=actor_user_id, trace_id=trace_id, ) async def _notify_withdraw( self, report: AttendanceReport, *, prev_status: str, ) -> None: """Tell whoever was holding the pending confirmation that HR withdrew it (best-effort).""" holder_id = ( report.person_id if prev_status == c.STATUS_PENDING_INTERN else report.leader_person_id ) holder = await self._person(holder_id) if holder is None: return await self._safe_dispatch(NotificationEnvelope( recipient=holder, title="一份考勤月报确认请求已被 HR 撤回", body_text="HR 撤回了该考勤月报的确认请求,稍后可能会重新发送,当前无需处理。", channels=["feishu"], related_resource_type="attendance_report", related_resource_id=report.id, )) # ── intern actions ────────────────────────────────────────────── async def intern_confirm( self, report: AttendanceReport, *, actor_person_id: UUID, trace_id: str = "", ) -> AttendanceReport: self._assert_actor(report, actor_person_id, c.SIDE_INTERN) if report.status != c.STATUS_PENDING_INTERN: # Idempotent: already past intern stage → no-op. if report.status in (c.STATUS_PENDING_LEADER, c.STATUS_COMPLETED): return report raise ValidationError(f"报告当前状态不可确认({report.status})") report.status = c.STATUS_PENDING_LEADER report.intern_confirmed_at = datetime.now(UTC) self._event( report, c.EVENT_INTERN_CONFIRMED, actor_person_id=actor_person_id, from_status=c.STATUS_PENDING_INTERN, to_status=report.status, trace_id=trace_id, ) await self.db.flush() if report.leader_person_id is None: # Do not leave a report in a state no one can act on. Returning # it lets HR fix the reporting line and resend; repull_report # refreshes the leader snapshot for that new round. report.status = c.STATUS_RETURNED report.returned_by = None report.returned_by_person_id = None report.return_reason = "未配置直属 Leader,无法完成复核" report.returned_at = datetime.now(UTC) self._event( report, c.EVENT_LEADER_UNAVAILABLE, actor_person_id=None, from_status=c.STATUS_PENDING_LEADER, to_status=c.STATUS_RETURNED, reason=report.return_reason, trace_id=trace_id, ) await self.db.flush() await self._notify_hr( report, title="考勤月报缺少 Leader", body="实习生已确认,但未配置直属 Leader。请补齐汇报关系后重新发送。", ) return report await self._dispatch_leader_card(report) return report async def intern_return( self, report: AttendanceReport, *, actor_person_id: UUID, reason: str, trace_id: str = "", ) -> AttendanceReport: self._assert_actor(report, actor_person_id, c.SIDE_INTERN) if report.status != c.STATUS_PENDING_INTERN: raise ValidationError(f"报告当前状态不可退回({report.status})") self._mark_returned( report, side=c.SIDE_INTERN, actor_person_id=actor_person_id, reason=reason, event=c.EVENT_INTERN_RETURNED, trace_id=trace_id, ) await self.db.flush() await self._notify_hr( report, title="实习生退回了考勤月报", body=f"实习生退回考勤月报,原因:{reason or '(未填写)'}。请处理补卡后重发。", ) return report async def intern_undo( self, report: AttendanceReport, *, actor_person_id: UUID, trace_id: str = "", ) -> AttendanceReport: self._assert_actor(report, actor_person_id, c.SIDE_INTERN) # Only undoable while it's sitting with the leader and the leader # hasn't confirmed yet. Anything else (already back, completed) is # a no-op / rejected. if report.status == c.STATUS_PENDING_INTERN: return report # already back with intern — idempotent if report.status != c.STATUS_PENDING_LEADER: raise ValidationError( "已超过可撤销阶段(leader 已处理 / 报告已完成),无法撤销确认" ) report.status = c.STATUS_PENDING_INTERN report.intern_confirmed_at = None self._event( report, c.EVENT_INTERN_UNDO, actor_person_id=actor_person_id, from_status=c.STATUS_PENDING_LEADER, to_status=report.status, trace_id=trace_id, ) await self.db.flush() # Tell the leader the report was recalled (best-effort). leader = await self._person(report.leader_person_id) if leader is not None: await self._safe_dispatch(NotificationEnvelope( recipient=leader, title="一份待复核的考勤月报已被撤回", body_text="实习生撤回了刚提交的考勤月报,稍后会重新提交,暂无需处理。", channels=["feishu"], related_resource_type="attendance_report", related_resource_id=report.id, )) return report # ── leader actions ────────────────────────────────────────────── async def leader_confirm( self, report: AttendanceReport, *, actor_person_id: UUID, trace_id: str = "", ) -> AttendanceReport: self._assert_actor(report, actor_person_id, c.SIDE_LEADER) if report.status == c.STATUS_COMPLETED: return report # idempotent if report.status != c.STATUS_PENDING_LEADER: raise ValidationError(f"报告当前状态不可确认({report.status})") report.status = c.STATUS_COMPLETED report.leader_confirmed_at = datetime.now(UTC) self._event( report, c.EVENT_LEADER_CONFIRMED, actor_person_id=actor_person_id, from_status=c.STATUS_PENDING_LEADER, to_status=report.status, trace_id=trace_id, ) await self.db.flush() await self._notify_hr( report, title="考勤月报已最终确认", body="实习生与 leader 均已确认,该考勤月报已完成归档。", ) return report async def leader_return( self, report: AttendanceReport, *, actor_person_id: UUID, reason: str, trace_id: str = "", ) -> AttendanceReport: self._assert_actor(report, actor_person_id, c.SIDE_LEADER) if report.status != c.STATUS_PENDING_LEADER: raise ValidationError(f"报告当前状态不可退回({report.status})") self._mark_returned( report, side=c.SIDE_LEADER, actor_person_id=actor_person_id, reason=reason, event=c.EVENT_LEADER_RETURNED, trace_id=trace_id, ) await self.db.flush() await self._notify_hr( report, title="Leader 退回了考勤月报", body=f"Leader 退回考勤月报,原因:{reason or '(未填写)'}。请处理后重发。", ) return report # ── shared mechanics ──────────────────────────────────────────── def _assert_actor( self, report: AttendanceReport, actor_person_id: UUID, side: str, ) -> None: expected = ( report.person_id if side == c.SIDE_INTERN else report.leader_person_id ) if expected is None or actor_person_id != expected: raise ForbiddenError("你不是该考勤报告的相关人,无权操作") def _mark_returned( self, report: AttendanceReport, *, side: str, actor_person_id: UUID, reason: str, event: str, trace_id: str, ) -> None: prev = report.status report.status = c.STATUS_RETURNED report.returned_by = side report.returned_by_person_id = actor_person_id report.return_reason = (reason or "").strip() or None report.returned_at = datetime.now(UTC) self._event( report, event, actor_person_id=actor_person_id, from_status=prev, to_status=report.status, reason=report.return_reason, trace_id=trace_id, ) async def _dispatch_intern_card(self, report: AttendanceReport) -> None: intern = await self._person(report.person_id) cycle = await self._cycle(report) if intern is None: return token = create_action_token( purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, subject_id=str(intern.id), resource_id=str(report.id), ) await self._safe_dispatch(build_confirmation_card( report=report, recipient=intern, role=c.SIDE_INTERN, confirm_token=token, period_label=self._period_label(cycle), )) async def _dispatch_leader_card(self, report: AttendanceReport) -> None: leader = await self._person(report.leader_person_id) intern = await self._person(report.person_id) cycle = await self._cycle(report) if leader is None: # No leader to route to — tell HR so it doesn't silently stall. await self._notify_hr( report, title="考勤月报无可推送的 leader", body="实习生已确认,但其未配置直属 leader,无法自动推送复核,请人工处理。", ) return token = create_action_token( purpose=c.ACTION_TOKEN_PURPOSE_CONFIRM, subject_id=str(leader.id), resource_id=str(report.id), ) await self._safe_dispatch(build_confirmation_card( report=report, recipient=leader, role=c.SIDE_LEADER, confirm_token=token, period_label=self._period_label(cycle), intern_display=intern.display_name if intern else None, )) # ── one-click confirm dispatch (token-bound actor) ────────────── async def confirm_via_token( self, report: AttendanceReport, *, actor_person_id: UUID, trace_id: str = "", ) -> tuple[AttendanceReport, str]: """Resolve the token subject to intern-or-leader and confirm the right stage. Returns (report, side). Raises ForbiddenError if the subject is neither the intern nor the leader of this report.""" if actor_person_id == report.person_id: await self.intern_confirm( report, actor_person_id=actor_person_id, trace_id=trace_id, ) return report, c.SIDE_INTERN if actor_person_id == report.leader_person_id: await self.leader_confirm( report, actor_person_id=actor_person_id, trace_id=trace_id, ) return report, c.SIDE_LEADER raise ForbiddenError("你不是该考勤报告的相关人,无权确认") async def load_report( self, report_id: UUID, *, lock: bool = False, ) -> AttendanceReport: # ``lock=True`` (SELECT ... FOR UPDATE) is load-bearing on the mutating # one-click confirm path: that POST is authenticated solely by the # action token (no login) and is reachable concurrently (double-click / # mail-client prefetch + click). Without the row lock two concurrent # POSTs both read status==PENDING, both pass the state-machine guard, # both flip to COMPLETED and double-fire the HR notification + event # log. ``populate_existing`` is required so the lock isn't satisfied # from the identity-map cache. Read paths stay lock-free. report = await self.db.get( AttendanceReport, report_id, with_for_update=True if lock else None, populate_existing=lock, ) if report is None: raise NotFoundError("考勤报告不存在") return report