"""年假到期提醒的发送批次台账。 一次「发送」= 一行 ``hr_leave_reminder_batches``。每张卡片的 outbox 行通过 ``related_resource_type='hr_leave_reminder_batch'`` + ``related_resource_id=`` 挂回批次,所以**不需要**再建一张 per-recipient 表:投递状态、渠道结果、错误、重试次数全都是 outbox 已经在管 的东西,复制一份只会产生两套会互相打架的真相。 批次行自己负责三件 outbox 管不了的事: 1. **收件人 + 数字快照**(``recipients_snapshot``)。HR 在预览页看到的每个人 的余额/清零日,原样冻结进这一行,后台发送任务只读它、不重新算。否则 「确认时 N 人」和「实际发出 M 人」之间会因为 cron 在中间跑过一次 HRIS 同步而漂移,而 HR 对着预览点的「确认」就名不副实了。 2. **文案快照**(``copy_snapshot``)。outbox 行的 ``body_text`` 存的是 ``(see template)`` 哨兵,正文是读时按**当前**模板重渲染的——HR 明天改一版 文案,历史通知的正文就跟着变了。批次留一份发送当时的三语文案,是唯一 能回答「这批人当时到底看到什么话」的地方。 3. **谁最近被提醒过**。去重扫描走 outbox → 批次的 join,并只认 ``test_mode = 0`` 的批次:测试发送(HR 发给自己试水)不该让真实收件人 被判定成「已提醒」而在下一次圈人里被默认排除。 """ from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import ( JSON, Boolean, DateTime, Index, Integer, Numeric, String, Text, UniqueConstraint, func, ) from sqlalchemy.orm import Mapped, mapped_column from app.core.database import Base from app.core.sql_types import GUID as Uuid # 批次状态。``sending`` 是后台 fan-out 在跑;``completed`` 全成功; # ``partial`` 有失败但也有成功;``failed`` 一条都没发出去。 BATCH_STATUS_SENDING = "sending" BATCH_STATUS_COMPLETED = "completed" BATCH_STATUS_PARTIAL = "partial" BATCH_STATUS_FAILED = "failed" #: outbox 行挂回批次用的 ``related_resource_type``。 BATCH_RESOURCE_TYPE = "hr_leave_reminder_batch" class HrLeaveReminderBatch(Base): """一次年假到期提醒群发。""" __tablename__ = "hr_leave_reminder_batches" __table_args__ = ( Index("ix_hr_leave_reminder_batches_created", "created_at"), Index("ix_hr_leave_reminder_batches_status", "status"), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) # 发起人。无 FK(leave 域的 TiDB 约定,见 leave_models 的模块注释)。 created_by_person_id: Mapped[str | None] = mapped_column(String(36)) trace_id: Mapped[str | None] = mapped_column(String(64)) status: Mapped[str] = mapped_column( String(16), nullable=False, server_default=BATCH_STATUS_SENDING, ) # 测试发送:只发给发起人自己。不计入「已提醒」去重,也不进 HR 的 # 真实发送记录默认视图。 test_mode: Mapped[bool] = mapped_column( Boolean, nullable=False, server_default="0", ) # 请求渠道(["feishu", "email"] 的子集)。 channels: Mapped[list[str]] = mapped_column(JSON, nullable=False) # 圈人条件原样留档 —— 事后回答「这批是按什么口径圈的」。 filters: Mapped[dict] = mapped_column(JSON, nullable=False) # 发送时刻的三语文案(模板覆盖 ∪ 内置默认的**有效值**)。 copy_snapshot: Mapped[dict | None] = mapped_column(JSON) # 冻结的收件人 + 余额快照(见模块注释第 1 点)。 recipients_snapshot: Mapped[list] = mapped_column(JSON, nullable=False) recipient_count: Mapped[int] = mapped_column( Integer, nullable=False, server_default="0", ) account_count: Mapped[int] = mapped_column( Integer, nullable=False, server_default="0", ) # 这批人加起来有多少天面临清零 —— HR 汇报口径。 at_risk_days: Mapped[float | None] = mapped_column(Numeric(10, 2)) enqueued: Mapped[int] = mapped_column( Integer, nullable=False, server_default="0", ) delivered: Mapped[int] = mapped_column( Integer, nullable=False, server_default="0", ) failed: Mapped[int] = mapped_column( Integer, nullable=False, server_default="0", ) error_summary: Mapped[str | None] = mapped_column(Text) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) class HrLeaveReminderExclusion(Base): """HR 维护的「默认不勾选」名单 —— 站在圈人口径之上的一层人工判断。 名单是从 HRIS 假期数据长出来的,而数据判不出「这个人在长期病假 / 正在交接 / HR 已经线下处理过了」。有这张表之前,唯一的办法是每次发送前在 ~145 行里手工 取消勾选,而且**每次都要重新记一遍**。 刻意是**默认值而不是硬闸**:命中的人照常出现在名单里、带标记、初始不勾选, 与既有的 ``reminded_recently`` 完全同一形状。HR 想在某一次发送里带上他,勾上 就是了 —— 平台的职责是记住那条长期决定,不是替按鼠标的人做决定。 全局(不按批次),与 ``attendance_roster_overrides`` 同构:一条决定对之后每次 发送都生效,直到 HR 删掉它。 """ __tablename__ = "hr_leave_reminder_exclusions" __mapper_args__ = {"eager_defaults": True} __table_args__ = ( UniqueConstraint( "person_id", name="uq_hr_leave_reminder_exclusions_person", ), ) id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4) person_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False) #: 为什么排除。可空,但它是下一个打开这张表的人**唯一**能据以判断「这条还成不 #: 成立」的东西 —— 没有理由的行会活得比它的理由久,然后永远地把某个人从每一次 #: 发送里悄悄拿掉。 note: Mapped[str | None] = mapped_column(Text) created_by: Mapped[uuid.UUID | None] = mapped_column(Uuid) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now(), ) __all__ = [ "BATCH_RESOURCE_TYPE", "BATCH_STATUS_COMPLETED", "BATCH_STATUS_FAILED", "BATCH_STATUS_PARTIAL", "BATCH_STATUS_SENDING", "HrLeaveReminderBatch", "HrLeaveReminderExclusion", ]