feat: 数字员工财务报告体系与定时提醒及看板快照调度

- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-03 09:25:23 +08:00
parent 0c74b4ab4a
commit 15006a05a7
114 changed files with 7356 additions and 650 deletions

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
import smtplib
from dataclasses import asdict, dataclass
from email.message import EmailMessage
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app.core.secret_box import decrypt_secret
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.services.settings import SettingsService
@dataclass(frozen=True, slots=True)
class FinanceReportDeliveryResult:
status: str
recipients: list[str]
subject: str
message: str
smtp_host: str
attachment_name: str
def to_dict(self) -> dict[str, Any]:
return asdict(self)
class FinanceReportMailer:
def __init__(self, db: Session) -> None:
self.db = db
def send_report(
self,
*,
context: dict[str, Any],
pdf_path: Path,
recipients: list[str] | None = None,
dry_run: bool = False,
) -> FinanceReportDeliveryResult:
settings_row, secrets_row = SettingsService(self.db).ensure_settings_ready()
resolved_recipients = self._resolve_recipients(settings_row, recipients)
subject = self._subject(context)
missing = self._missing_config(settings_row, secrets_row, resolved_recipients)
if missing:
return FinanceReportDeliveryResult(
status="pending_configuration",
recipients=resolved_recipients,
subject=subject,
message=f"邮件未发送,缺少配置:{', '.join(missing)}",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
if dry_run:
return FinanceReportDeliveryResult(
status="dry_run",
recipients=resolved_recipients,
subject=subject,
message="邮件 dry-run 完成,未连接 SMTP。",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
password = self._decrypt_password(secrets_row)
message = self._message(
settings_row=settings_row,
context=context,
pdf_path=pdf_path,
recipients=resolved_recipients,
subject=subject,
)
try:
self._send(settings_row, message, password)
except Exception as exc:
return FinanceReportDeliveryResult(
status="failed",
recipients=resolved_recipients,
subject=subject,
message=f"邮件发送失败:{exc}",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
return FinanceReportDeliveryResult(
status="sent",
recipients=resolved_recipients,
subject=subject,
message="邮件已发送。",
smtp_host=str(settings_row.smtp_host or ""),
attachment_name=pdf_path.name,
)
def _message(
self,
*,
settings_row: SystemSetting,
context: dict[str, Any],
pdf_path: Path,
recipients: list[str],
subject: str,
) -> EmailMessage:
sender_address = str(
settings_row.sender_address or settings_row.smtp_username or ""
).strip()
sender_name = str(
settings_row.sender_name or settings_row.company_name or "X-Financial"
).strip()
summary = context.get("summary") if isinstance(context.get("summary"), dict) else {}
insights = list(context.get("insights") or [])[:3]
body = "\n".join(
[
"各位好,",
"",
"数字员工已生成本期财务经营报告,摘要如下:",
*[f"- {item}" for item in insights],
"",
f"报销单数:{summary.get('reimbursement_count', 0)}",
f"报销金额:¥{float(summary.get('reimbursement_amount') or 0):,.0f}",
f"行动项:{summary.get('action_count', 0)}",
"",
"详细内容请查看附件 PDF。",
]
)
message = EmailMessage()
message["Subject"] = subject
message["From"] = f"{sender_name} <{sender_address}>"
message["To"] = ", ".join(recipients)
message.set_content(body)
message.add_attachment(
pdf_path.read_bytes(),
maintype="application",
subtype="pdf",
filename=pdf_path.name,
)
return message
@staticmethod
def _resolve_recipients(
settings_row: SystemSetting,
override_recipients: list[str] | None,
) -> list[str]:
raw_values = override_recipients or [
str(settings_row.default_receiver or ""),
str(settings_row.notice_email or ""),
str(settings_row.admin_email or ""),
]
recipients: list[str] = []
for raw in raw_values:
for item in str(raw or "").replace(";", ",").split(","):
email = item.strip()
if email and "@" in email and email not in recipients:
recipients.append(email)
return recipients
@staticmethod
def _missing_config(
settings_row: SystemSetting,
secrets_row: SystemSettingSecret,
recipients: list[str],
) -> list[str]:
missing: list[str] = []
if not str(settings_row.smtp_host or "").strip():
missing.append("smtp_host")
if not int(settings_row.smtp_port or 0):
missing.append("smtp_port")
if not str(settings_row.sender_address or settings_row.smtp_username or "").strip():
missing.append("sender_address")
if not str(settings_row.smtp_username or "").strip():
missing.append("smtp_username")
if not str(secrets_row.smtp_password_encrypted or "").strip():
missing.append("smtp_password")
if not recipients:
missing.append("recipients")
return missing
@staticmethod
def _decrypt_password(secrets_row: SystemSettingSecret) -> str:
encrypted = str(secrets_row.smtp_password_encrypted or "").strip()
return decrypt_secret(encrypted) if encrypted else ""
@staticmethod
def _subject(context: dict[str, Any]) -> str:
period = context.get("period") if isinstance(context.get("period"), dict) else {}
title = str(period.get("title") or "财务经营报告")
label = str(period.get("label") or "").strip()
return f"X-Financial {title} | {label}".strip()
@staticmethod
def _send(settings_row: SystemSetting, message: EmailMessage, password: str) -> None:
host = str(settings_row.smtp_host or "").strip()
port = int(settings_row.smtp_port or 465)
username = str(settings_row.smtp_username or "").strip()
encryption = str(settings_row.smtp_encryption or "").strip().lower()
if "ssl" in encryption:
with smtplib.SMTP_SSL(host, port, timeout=20) as smtp:
smtp.login(username, password)
smtp.send_message(message)
return
with smtplib.SMTP(host, port, timeout=20) as smtp:
if "tls" in encryption or "starttls" in encryption:
smtp.starttls()
smtp.login(username, password)
smtp.send_message(message)