feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器 - 引入员工画像扫描调度与定时提醒任务 - 完善财务看板快照、排行口径与部门人员占比计算 - 优化数字员工工作看板仪表盘与技能目录 - 增强前端总览页图表、工作台摘要与顶部导航栏交互 - 新增差旅申请规划推动提醒与报销创建会话状态管理 - 补充财务报告、看板调度、数字员工工作记录测试覆盖
This commit is contained in:
205
server/src/app/services/finance_report_mailer.py
Normal file
205
server/src/app/services/finance_report_mailer.py
Normal 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)
|
||||
Reference in New Issue
Block a user