"""Scheduled-job handler for the HR leave/time-off mirror. ``hr.leave_account_sync`` — daily refresh of every employee's SF EC Time-Off data (假期余额 + 账户假期记录) into ``hr_leave_accounts`` / ``hr_leave_postings``, deriving the legacy ``identity_persons. annual_leave_*`` snapshot columns as a side effect. Single global job: the handler fans out across every enabled HRIS instance internally (CN + EU + …), so the row count doesn't grow with the number of regions. Idempotent — re-running re-keys on SF ``externalCode`` and tombstones rows SF stopped returning. """ from __future__ import annotations from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from app.scheduled_jobs.registry import register_scheduled_job @register_scheduled_job( key="hr.leave_account_sync", display_name="假期余额与记录 · 每日同步", description=( "每日从 SuccessFactors EC Time-Off 同步所有员工的「假期余额」(TimeAccount," "逐账户类型:法定年假/福利年假/带薪病假/调休假…)与「账户假期记录」" "(TimeAccountDetail 明细行),镜像进 hr_leave_accounts / hr_leave_postings," "并据此派生回 identity_persons 的四个 annual_leave_* 快照列(年假计算器仍可用)。" "单例任务,内部遍历所有 enabled 的 HRIS 实例;按 external_code 幂等 upsert," "SF 不再返回的行软删除。EC Time-Off 未授权时优雅降级。" ), ) async def run_hr_leave_account_sync( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.hr.service import SuccessFactorsLifecycleService services = await SuccessFactorsLifecycleService.fan_out(db) totals = { "instances": 0, "accounts_upserted": 0, "postings_upserted": 0, "accounts_tombstoned": 0, "absences_upserted": 0, "absences_tombstoned": 0, "persons_touched": 0, "legacy_derived": 0, } errors: list[str] = [] for svc in services: try: summary = await svc.sync_leave_data() except Exception as exc: # noqa: BLE001 — one region must not tank the rest errors.append( f"{svc.hris_instance_id or 'primary'}: {type(exc).__name__}: {exc}" ) continue totals["instances"] += 1 for k in ( "accounts_upserted", "postings_upserted", "accounts_tombstoned", "absences_upserted", "absences_tombstoned", "persons_touched", "legacy_derived", ): totals[k] += int(summary.get(k, 0)) # Per ``feedback_handler_status_must_raise``: a region that errored # must surface as a failed run (so the failure_notifier fires) rather # than landing a silent status='ok'. Still report what did succeed. if errors: raise RuntimeError( "hr.leave_account_sync: " + "; ".join(errors) + f" | succeeded: {totals}" ) return totals @register_scheduled_job( key="hr.leave_reminder_reap", display_name="年假到期提醒 · 僵死批次收尾", description=( "把僵死在 sending 的年假到期提醒批次按 outbox 真值收尾。" "**这个任务永远不发送任何通知** —— 年假到期提醒只有 HR 手动触发一条路径," "这里只是收尸:群发的投递跑在 API 进程的 background task 里,进程被 kill" "(部署 / OOM)会让批次永久停在「正在发送」。最坏的一种是 /send 已提交批次行" "但进程在 spawn 投递任务之前就没了 —— 一个人都没收到,而 HR 看到的是" "「正在发送」,永远,且没有任何信号。已入队的 outbox 行仍由" "notification.outbox_retry 兜住投递;这里只把批次台账修成真话。幂等。" ), ) async def run_hr_leave_reminder_reap( db: AsyncSession, config: dict[str, Any], ) -> dict[str, Any]: from app.domains.hr.leave_reminder_service import reap_stale_batches return await reap_stale_batches(db)