- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
206 lines
7.4 KiB
Python
206 lines
7.4 KiB
Python
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)
|