"""「年假到期提醒」的请求/响应契约。 设计上只有一个圈人口径对象(:class:`ReminderFilters`),预览和发送**共用**它 ——预览看到什么就发出什么,是这个功能唯一说得过去的语义。发送再额外接一个 ``person_ids`` 显式选择(HR 在预览表里勾掉几个人 / 只发一个人),服务端会与 重新解析出来的名单取交集,所以一个伪造/过期的 id 不可能把卡片发给口径外 的人。 """ from __future__ import annotations import re import uuid from datetime import date, datetime from pydantic import BaseModel, Field, field_validator, model_validator # SF accountType 码的形状(租户配置,不是 enum),用于挡住注入型输入。 _CODE_RE = re.compile(r"^[A-Za-z0-9_.\-]{1,64}$") #: 允许的发送渠道。站内通知不是渠道 —— 它就是 outbox 行本身,永远会写。 ALLOWED_CHANNELS = ("feishu", "email") class ReminderFilters(BaseModel): """圈人口径。 ``account_types`` 刻意**必填**:「给所有假期类型的所有人发提醒」不是一个 HR 会真心想要的默认值,而是一次误操作的形状。前端从 ``GET /config`` 的 ``account_types`` 真实码表里预选「会清零」的那几个。 """ account_types: list[str] = Field(min_length=1, max_length=32) # 空 = 不限财年。 fiscal_years: list[int] = Field(default_factory=list, max_length=20) # 剩余天数下限(含)。默认 0.5 天:剩 0 天的人没有可休的额度,提醒他只会 # 制造噪音。 min_balance_days: float = Field(default=0.5, ge=0, le=366) # 清零日在今天起 N 天内(含)。None = 不看窗口,只看余额。 expiring_within_days: int | None = Field(default=120, ge=1, le=1000) # 已过清零日的桶(SF 常年不正式 close 的历史桶)默认排除 —— 提醒一个 # 已经清零的额度是纯粹的错误信息。 include_expired: bool = False # SF 已 close 的账户默认排除。 include_closed_accounts: bool = False @field_validator("account_types") @classmethod def _codes_ok(cls, v: list[str]) -> list[str]: out: list[str] = [] for raw in v: code = (raw or "").strip() if not _CODE_RE.match(code): raise ValueError(f"invalid account type code: {raw!r}") if code not in out: out.append(code) if not out: raise ValueError("account_types must not be empty") return out @field_validator("fiscal_years") @classmethod def _years_ok(cls, v: list[int]) -> list[int]: for year in v: if not (2000 <= year <= 2100): raise ValueError(f"fiscal year out of range: {year}") return sorted(set(v)) class AccountSnapshot(BaseModel): """一个命中账户在「发送那一刻」的样子。""" account_id: uuid.UUID account_type: str account_type_label: str | None = None fiscal_year: int | None = None balance_days: float entitled_days: float | None = None used_days: float | None = None # 清零日 = booking_end_date or end_date(与员工卡片/洞察板同一口径)。 deadline: date | None = None # 额度周期的结束日(SF 的 ``end_date``)。**FY 标签是从它推出来的**,不是从 # ``fiscal_year`` —— 见 ``fiscal_label``:1~3 月入职的同事,SF 给的 # ``fiscal_year`` 比同周期的其他人大 1,但周期和清零日完全一样。 period_end: date | None = None expires_in_days: int | None = None # 该类型是否属于 use-it-or-lose-it(forfeits_at_period_end)。HR 可以 # 刻意提醒不清零的类型(比如福利年假),所以这只是展示信号,不是过滤条件。 forfeits: bool = False is_current: bool = False account_closed: bool = False class CandidateRow(BaseModel): """预览名单里的一行 = 一个人 + 他命中的账户。""" person_id: uuid.UUID display_name: str employee_no: str | None = None work_email: str | None = None department: str | None = None bu: str | None = None # 这个人会收到哪种语言的文案 —— HR 事后解释「为什么他收到的是英文」。 language: str accounts: list[AccountSnapshot] total_balance_days: float earliest_deadline: date | None = None expires_in_days: int | None = None # HRIS 数据新鲜度:命中账户里最旧的一次同步时间。 data_synced_at: datetime | None = None # HR 维护的长期排除决定(``hr_leave_reminder_exclusions``)。命中的人**照常 # 出现在名单里**、带标记、默认不勾选 —— 是默认值不是硬闸,HR 想在某一次带上 # 他,勾上就是了。 excluded: bool = False excluded_note: str | None = None # 去重信号。``reminded_recently`` = 在 resend_window_days 内收到过, # 前端默认不勾选(但 HR 可以强制再发)。 last_reminded_at: datetime | None = None last_reminded_batch_id: uuid.UUID | None = None reminded_recently: bool = False class CohortSummary(BaseModel): """名单的汇总口径 —— HR 汇报和「会不会发多了」的自检都看这里。""" recipient_count: int account_count: int total_at_risk_days: float reminded_recently_count: int # 默认会被发送的人数。两条默认不勾的理由(近期已提醒 / HR 排除)都要扣掉, # 否则这个数字和 HR 按下发送时的人数对不上。 default_selected_count: int # 其中有多少人是被 HR 长期排除的。 excluded_count: int = 0 by_account_type: dict[str, int] # 诚实的覆盖面:全公司在职 vs 有任何 HRIS 假期账户的人数。SF EC # Time-Off 只承载中国主体,海外同事根本没有账户 —— 不写出来会让 HR # 以为「全公司都提醒到了」。 active_person_count: int persons_with_leave_data: int # 因为没有工作邮箱而无法投递、被排除在名单外的人数。 skipped_no_email: int # 最旧/最新的 HRIS 同步时间,提示 HR 是否该先同步一次。 oldest_synced_at: datetime | None = None newest_synced_at: datetime | None = None # 口径锚点(Asia/Shanghai 的今天),与员工卡片、洞察板同一天。 as_of_date: date class PreviewResponse(BaseModel): summary: CohortSummary candidates: list[CandidateRow] class AccountTypeOption(BaseModel): """码表选项 —— 从真实数据里长出来,不是硬编码。""" code: str label: str | None = None # annual / comp / exclude(leave_insights 的分类) classification: str forfeits: bool account_count: int person_count: int fiscal_years: list[int] #: 各文案字段的长度上限。没有上限时一份 2 MB 的正文能过校验 → 落进 #: ``platform_settings`` → 复制进每个批次的 ``copy_snapshot`` → 原样投递: #: 飞书卡片有大小上限会整批失败,SMTP 则会照发 2 MB × 280 封。 _SUBJECT_MAX = 200 _BODY_MAX = 5000 _CARD_LINE_MAX = 300 class TemplateCopy(BaseModel): """三语文案。字段与 email_template 的存储契约一致。""" subject: str = Field(default="", max_length=_SUBJECT_MAX) subject_en: str = Field(default="", max_length=_SUBJECT_MAX) subject_ja: str = Field(default="", max_length=_SUBJECT_MAX) body_text: str = Field(default="", max_length=_BODY_MAX) body_text_en: str = Field(default="", max_length=_BODY_MAX) body_text_ja: str = Field(default="", max_length=_BODY_MAX) card_subtitle: str = Field(default="", max_length=_CARD_LINE_MAX) card_subtitle_en: str = Field(default="", max_length=_CARD_LINE_MAX) card_subtitle_ja: str = Field(default="", max_length=_CARD_LINE_MAX) card_button_label: str = Field(default="", max_length=_CARD_LINE_MAX) card_button_label_en: str = Field(default="", max_length=_CARD_LINE_MAX) card_button_label_ja: str = Field(default="", max_length=_CARD_LINE_MAX) class ConfigResponse(BaseModel): """页面初始化需要的一切:文案、CTA、默认口径、码表、覆盖面。""" template_key: str # 生效文案(HR 覆盖 ∪ 内置默认),以及未被覆盖时的内置默认(供「恢复默认」) template_copy: TemplateCopy builtin_template_copy: TemplateCopy copy_overridden: bool allowed_placeholders: list[str] auto_placeholders: list[str] hris_url: str = "" resend_window_days: int # 结尾寄语:``{"mode": "random"|"fixed", "fixed_index": n, "lines": [...]}`` signoff: dict = Field(default_factory=dict) builtin_signoff: dict = Field(default_factory=dict) defaults: ReminderFilters default_channels: list[str] account_types: list[AccountTypeOption] fiscal_years: list[int] as_of_date: date active_person_count: int persons_with_leave_data: int class ConfigUpdateRequest(BaseModel): """部分更新。只带某一段就只改那一段。""" template_copy: TemplateCopy | None = None # 传空串 = 清掉 HRIS 入口(卡片就只剩「查看我的假期」按钮)。 hris_url: str | None = None resend_window_days: int | None = Field(default=None, ge=0, le=365) signoff: dict | None = None defaults: ReminderFilters | None = None default_channels: list[str] | None = None # 显式把文案恢复成内置默认(删除 platform_settings 覆盖行)。 reset_copy: bool = False @model_validator(mode="after") def _something_to_do(self) -> ConfigUpdateRequest: if ( self.template_copy is None and self.hris_url is None and self.resend_window_days is None and self.signoff is None and self.defaults is None and self.default_channels is None and not self.reset_copy ): raise ValueError("nothing to update") if self.reset_copy and self.template_copy is not None: raise ValueError("reset_copy and template_copy are mutually exclusive") return self class PreviewRequest(BaseModel): filters: ReminderFilters class PreviewMessageRequest(BaseModel): """用**真实收件人的数字**渲染一份成品,给 HR 发之前过目。""" filters: ReminderFilters # 不传就取名单里最紧急的那个人。 person_id: uuid.UUID | None = None # 不传就按该收件人自己的语言;传了就强制看某一语言的版本。 language: str | None = Field(default=None, pattern="^(zh|en|ja)$") # 未保存的草稿文案:让 HR 边改边看,不必先保存再预览。 template_copy: TemplateCopy | None = None class RenderedMessage(BaseModel): person_id: uuid.UUID | None = None display_name: str | None = None language: str subject: str body_text: str card_subtitle: str = "" card_button_label: str = "" # 卡片落款(HR 维护的寄语)。随机模式下这里是**本次预览**抽到的那一句 —— # 真实发送会按批次 id 另抽一次,前端要如实说明,不能让 HR 以为它固定。 card_footer: str = "" card_stats: list[dict] = Field(default_factory=list) card_actions: list[dict] = Field(default_factory=list) # 渲染时用到的 context,HR 可以据此理解每个占位符会变成什么。 context: dict = Field(default_factory=dict) # 文案里出现了但 context 没有的占位符 —— 会原样漏给员工,必须显式警告。 unresolved_placeholders: list[str] = Field(default_factory=list) class ExclusionRow(BaseModel): """排除名单里的一行 —— 人 + 为什么 + 谁加的。""" person_id: uuid.UUID display_name: str work_email: str | None = None employee_no: str | None = None department: str | None = None note: str | None = None created_by_name: str | None = None created_at: datetime | None = None class ExclusionRequest(BaseModel): person_id: uuid.UUID note: str | None = Field(default=None, max_length=500) class TestRecipientsRequest(BaseModel): """试发预检:「我挑的这几个人,各自会收到什么?」 上限刻意大于 ``MAX_TEST_RECIPIENTS``:预检要能把「你挑得太多了」也如实说出 来,而不是在校验层就 422 掉、让 HR 猜哪里错了。 """ filters: ReminderFilters person_ids: list[uuid.UUID] = Field(default_factory=list, max_length=100) class TestRecipientRow(BaseModel): """预检结果的一行 —— 前端逐人显示,发送前就看得见结局。""" person_id: uuid.UUID display_name: str work_email: str | None = None #: ``cohort`` 在本次名单内 · ``out_of_cohort`` 有真额度但不在口径内 · #: ``sample`` 明确标注的示例数字(仅发起人自己) · ``blocked`` 发不了 source: str #: 仅 ``blocked``:``no_matching_leave`` / ``past_deadline`` / ``no_email`` … reason: str | None = None #: ``blocked`` 之外有值 —— 让 HR 一眼看出「这封信里会写多少天」。 total_balance_days: float | None = None earliest_deadline: date | None = None expires_in_days: int | None = None language: str | None = None class SendRequest(BaseModel): filters: ReminderFilters # 空 = 名单里默认选中的全部(即排除近期已提醒的人)。 person_ids: list[uuid.UUID] = Field(default_factory=list, max_length=2000) channels: list[str] = Field(default_factory=lambda: ["feishu", "email"]) # 乐观并发闸:HR 确认时看到的人数。不一致 → 409,让 HR 重新看一眼名单。 expected_recipient_count: int | None = Field(default=None, ge=0) # 允许把「近期已提醒」的人也发一遍(9 月冲刺期再催一轮)。 allow_resend: bool = False # 只发给自己试水。 test_mode: bool = False @field_validator("channels") @classmethod def _channels_ok(cls, v: list[str]) -> list[str]: out = [c for c in dict.fromkeys(v) if c in ALLOWED_CHANNELS] if not out: raise ValueError( f"channels must be a non-empty subset of {list(ALLOWED_CHANNELS)}", ) return out class BatchRow(BaseModel): id: uuid.UUID status: str test_mode: bool channels: list[str] filters: dict recipient_count: int account_count: int at_risk_days: float | None = None enqueued: int delivered: int failed: int error_summary: str | None = None created_by_person_id: uuid.UUID | None = None created_by_name: str | None = None created_at: datetime started_at: datetime | None = None finished_at: datetime | None = None class BatchRecipientRow(BaseModel): """批次里一个收件人的投递结果(来自 outbox,不是第二份真相)。""" outbox_id: uuid.UUID person_id: uuid.UUID | None = None display_name: str | None = None work_email: str | None = None status: str channels: list[str] = Field(default_factory=list) delivered_channels: list[str] = Field(default_factory=list) failed_channels: list[str] = Field(default_factory=list) last_error: str | None = None attempts: int = 0 delivered_at: datetime | None = None read_at: datetime | None = None created_at: datetime | None = None # 发送时冻结的数字,用于回答「他当时被告知剩几天」。 balance_days: float | None = None deadline: str | None = None class BatchDetailResponse(BaseModel): batch: BatchRow copy_snapshot: dict | None = None recipients: list[BatchRecipientRow] __all__ = [ "ALLOWED_CHANNELS", "AccountSnapshot", "AccountTypeOption", "BatchDetailResponse", "BatchRecipientRow", "BatchRow", "CandidateRow", "CohortSummary", "ConfigResponse", "ConfigUpdateRequest", "ExclusionRequest", "ExclusionRow", "PreviewMessageRequest", "PreviewRequest", "PreviewResponse", "RenderedMessage", "ReminderFilters", "SendRequest", "TemplateCopy", ]