Files
X-Financial/server/src/app/services/finance_report_renderer.py
caoxiaozhu 15006a05a7 feat: 数字员工财务报告体系与定时提醒及看板快照调度
- 新增数字员工财务报告生成、邮件投递与渲染调度器
- 引入员工画像扫描调度与定时提醒任务
- 完善财务看板快照、排行口径与部门人员占比计算
- 优化数字员工工作看板仪表盘与技能目录
- 增强前端总览页图表、工作台摘要与顶部导航栏交互
- 新增差旅申请规划推动提醒与报销创建会话状态管理
- 补充财务报告、看板调度、数字员工工作记录测试覆盖
2026-06-03 09:25:23 +08:00

398 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import html
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from app.core.config import get_settings
@dataclass(frozen=True, slots=True)
class RenderedFinanceReport:
html_path: Path
pdf_path: Path
storage_key: str
title: str
page_count: int
class FinanceReportRenderer:
def render(self, context: dict[str, Any]) -> RenderedFinanceReport:
report_dir = self._report_dir(context)
report_dir.mkdir(parents=True, exist_ok=True)
title = str((context.get("period") or {}).get("title") or "财务经营报告")
html_text = self.render_html(context)
html_path = report_dir / "report.html"
pdf_path = report_dir / "report.pdf"
html_path.write_text(html_text, encoding="utf-8")
page_count = SimpleFinancePdfWriter().write(pdf_path, context)
return RenderedFinanceReport(
html_path=html_path,
pdf_path=pdf_path,
storage_key=self._storage_key(pdf_path),
title=title,
page_count=page_count,
)
def render_html(self, context: dict[str, Any]) -> str:
period = context.get("period") or {}
dashboard = context.get("dashboard") or {}
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
trend = dashboard.get("trend") if isinstance(dashboard.get("trend"), dict) else {}
departments = list(dashboard.get("department_ranking") or [])
top_claims = list(dashboard.get("top_claims") or [])
actions = list(context.get("action_items") or [])
insights = list(context.get("insights") or [])
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>{_e(period.get("title"))}</title>
<style>
body {{
margin: 0;
font-family: "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
color: #1f2937;
background: #f7f9fc;
}}
.page {{ width: 980px; margin: 0 auto; padding: 36px 42px; background: #fff; }}
.cover {{ border-bottom: 3px solid #2f6fed; padding-bottom: 24px; }}
h1 {{ margin: 0 0 10px; font-size: 30px; color: #172554; }}
h2 {{ margin: 28px 0 14px; font-size: 20px; color: #1e3a8a; }}
.muted {{ color: #64748b; }}
.grid {{ display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }}
.metric {{ border: 1px solid #dbe4f0; border-radius: 6px; padding: 14px; background: #f8fbff; }}
.metric .label {{ color: #64748b; font-size: 13px; }}
.metric .value {{ margin-top: 8px; font-size: 24px; font-weight: 700; color: #0f172a; }}
.insight {{
border-left: 4px solid #2f6fed;
padding: 10px 14px;
background: #f8fbff;
margin: 8px 0;
}}
.bar-row {{ display: flex; align-items: center; gap: 10px; margin: 8px 0; }}
.bar-label {{ width: 120px; color: #475569; }}
.bar-track {{ flex: 1; height: 12px; background: #e2e8f0; border-radius: 0; overflow: hidden; }}
.bar-fill {{ height: 100%; background: #2f6fed; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 8px; }}
th, td {{ padding: 10px 8px; border-bottom: 1px solid #e5edf7; text-align: left; }}
th {{ color: #475569; background: #f8fbff; }}
</style>
</head>
<body>
<main class="page">
<section class="cover">
<h1>{_e(period.get("title"))}</h1>
<div class="muted">
周期:{_e(period.get("label"))} 生成时间:{_e(context.get("generated_at"))}
</div>
</section>
<h2>管理摘要</h2>
{''.join(f'<div class="insight">{_e(item)}</div>' for item in insights)}
<h2>关键指标</h2>
<section class="grid">
{_metric_html("报销金额", _money(totals.get("reimbursementAmount")))}
{_metric_html("报销单数", f'{int(totals.get("reimbursementCount") or 0)}')}
{_metric_html("待付款", _money(totals.get("pendingPaymentAmount")))}
{_metric_html("预算使用率", f'{float(totals.get("budgetUsageRate") or 0):.1f}%')}
</section>
<h2>每日报销趋势</h2>
{_trend_html(trend)}
<h2>部门费用排行</h2>
{_ranking_html(departments, "amount")}
<h2>高额单据</h2>
{_top_claims_html(top_claims)}
<h2>行动清单</h2>
{_actions_html(actions)}
</main>
</body>
</html>"""
def _report_dir(self, context: dict[str, Any]) -> Path:
settings = get_settings()
period = context.get("period") or {}
report_type = str(context.get("report_type") or "weekly")
label = re.sub(r"[^0-9A-Za-z\u4e00-\u9fff_-]+", "_", str(period.get("label") or "latest"))
return settings.resolved_storage_root_dir / "finance_reports" / report_type / label
@staticmethod
def _storage_key(pdf_path: Path) -> str:
root = get_settings().resolved_storage_root_dir.resolve()
return pdf_path.resolve().relative_to(root).as_posix()
class SimpleFinancePdfWriter:
width = 595
height = 842
margin = 48
def write(self, path: Path, context: dict[str, Any]) -> int:
pages = self._build_pages(context)
objects: list[bytes] = []
page_ids: list[int] = []
font_id = 3
for page in pages:
content = self._content_stream(page)
content_id = len(objects) + 4
page_id = len(objects) + 5
objects.append(
f"<< /Length {len(content)} >>\nstream\n".encode("latin-1")
+ content
+ b"\nendstream"
)
objects.append(
(
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {self.width} {self.height}] "
f"/Resources << /Font << /F1 {font_id} 0 R >> >> /Contents {content_id} 0 R >>"
).encode("latin-1")
)
page_ids.append(page_id)
catalog = b"<< /Type /Catalog /Pages 2 0 R >>"
kids = " ".join(f"{page_id} 0 R" for page_id in page_ids)
pages_obj = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_ids)} >>".encode("latin-1")
font_obj = (
b"<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light "
b"/Encoding /UniGB-UCS2-H /DescendantFonts ["
b"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light "
b"/CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 5 >> >>] >>"
)
all_objects = [catalog, pages_obj, font_obj, *objects]
self._write_pdf(path, all_objects)
return len(pages)
def _build_pages(self, context: dict[str, Any]) -> list[list[dict[str, Any]]]:
period = context.get("period") or {}
dashboard = context.get("dashboard") or {}
totals = dashboard.get("totals") if isinstance(dashboard.get("totals"), dict) else {}
trend = dashboard.get("trend") if isinstance(dashboard.get("trend"), dict) else {}
departments = list(dashboard.get("department_ranking") or [])
actions = list(context.get("action_items") or [])
insights = list(context.get("insights") or [])
pages: list[list[dict[str, Any]]] = []
pages.append(
[
{"type": "title", "text": str(period.get("title") or "财务经营报告")},
{"type": "text", "text": f"报告周期:{period.get('label') or ''}"},
{"type": "text", "text": f"生成时间:{context.get('generated_at') or ''}"},
{"type": "heading", "text": "管理摘要"},
*[{"type": "bullet", "text": str(item)} for item in insights],
{"type": "heading", "text": "关键指标"},
{
"type": "metrics",
"items": [
("报销金额", _money(totals.get("reimbursementAmount"))),
("报销单数", f"{int(totals.get('reimbursementCount') or 0)}"),
("待付款", _money(totals.get("pendingPaymentAmount"))),
("预算使用率", f"{float(totals.get('budgetUsageRate') or 0):.1f}%"),
],
},
]
)
pages.append(
[
{"type": "heading", "text": "每日报销趋势"},
{
"type": "bars",
"labels": trend.get("labels") or [],
"values": trend.get("claimAmount") or [],
},
{"type": "heading", "text": "部门费用排行"},
{
"type": "bars",
"labels": [str(item.get("name") or "") for item in departments[:8]],
"values": [float(item.get("amount") or 0) for item in departments[:8]],
},
]
)
pages.append(
[
{"type": "heading", "text": "行动清单"},
*[
{
"type": "bullet",
"text": (
f"{item.get('title')} / {item.get('owner')}"
f"{item.get('suggestion')}"
),
}
for item in actions
],
]
)
return pages
def _content_stream(self, blocks: list[dict[str, Any]]) -> bytes:
commands: list[str] = ["q", "1 1 1 rg 0 0 595 842 re f"]
y = self.height - self.margin
for block in blocks:
block_type = block["type"]
if block_type == "title":
commands.extend(self._text(block["text"], self.margin, y, 24, "0.05 0.15 0.35"))
y -= 42
elif block_type == "heading":
y -= 8
commands.extend(self._text(block["text"], self.margin, y, 15, "0.10 0.25 0.55"))
y -= 26
elif block_type == "text":
commands.extend(self._text(block["text"], self.margin, y, 10, "0.25 0.30 0.38"))
y -= 18
elif block_type == "bullet":
lines = self._wrap(str(block["text"]), 34)
for line in lines:
commands.extend(self._text(f"{line}", self.margin, y, 10, "0.12 0.16 0.22"))
y -= 17
elif block_type == "metrics":
y = self._metrics(commands, block["items"], y)
elif block_type == "bars":
y = self._bars(commands, block.get("labels") or [], block.get("values") or [], y)
commands.append("Q")
return "\n".join(commands).encode("latin-1")
def _metrics(self, commands: list[str], items: list[tuple[str, str]], y: int) -> int:
box_w = 122
for index, (label, value) in enumerate(items):
x = self.margin + index * (box_w + 8)
commands.append("0.95 0.97 1.00 rg")
commands.append(f"{x} {y - 48} {box_w} 46 re f")
commands.extend(self._text(label, x + 8, y - 18, 8, "0.35 0.42 0.50"))
commands.extend(self._text(value, x + 8, y - 36, 13, "0.05 0.15 0.35"))
return y - 68
def _bars(self, commands: list[str], labels: list[Any], values: list[Any], y: int) -> int:
pairs = [
(str(label), float(value or 0))
for label, value in zip(labels, values, strict=False)
]
max_value = max([value for _label, value in pairs] or [1])
for label, value in pairs[:10]:
width = 310 * (value / max_value) if max_value else 0
commands.extend(self._text(_trim(label, 14), self.margin, y, 9, "0.25 0.30 0.38"))
commands.append("0.88 0.92 0.96 rg")
commands.append(f"{self.margin + 90} {y - 4} 320 8 re f")
commands.append("0.18 0.44 0.93 rg")
commands.append(f"{self.margin + 90} {y - 4} {width:.1f} 8 re f")
commands.extend(self._text(_money(value), self.margin + 420, y, 8, "0.25 0.30 0.38"))
y -= 22
return y - 8
@staticmethod
def _text(text: Any, x: int | float, y: int | float, size: int, color: str) -> list[str]:
return [
f"{color} rg",
"BT",
f"/F1 {size} Tf",
f"{x:.1f} {y:.1f} Td",
f"<{_pdf_hex(str(text))}> Tj",
"ET",
]
@staticmethod
def _wrap(text: str, length: int) -> list[str]:
value = str(text or "").strip()
return [value[index : index + length] for index in range(0, len(value), length)] or [""]
@staticmethod
def _write_pdf(path: Path, objects: list[bytes]) -> None:
offsets: list[int] = []
payload = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
for index, obj in enumerate(objects, start=1):
offsets.append(len(payload))
payload.extend(f"{index} 0 obj\n".encode("latin-1"))
payload.extend(obj)
payload.extend(b"\nendobj\n")
xref_at = len(payload)
payload.extend(f"xref\n0 {len(objects) + 1}\n0000000000 65535 f \n".encode("latin-1"))
for offset in offsets:
payload.extend(f"{offset:010d} 00000 n \n".encode("latin-1"))
payload.extend(
(
f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
f"startxref\n{xref_at}\n%%EOF"
).encode("latin-1")
)
path.write_bytes(bytes(payload))
def _metric_html(label: str, value: str) -> str:
return (
f'<div class="metric"><div class="label">{_e(label)}</div>'
f'<div class="value">{_e(value)}</div></div>'
)
def _trend_html(trend: dict[str, Any]) -> str:
labels = list(trend.get("labels") or [])
values = [float(value or 0) for value in list(trend.get("claimAmount") or [])]
return _bar_html(labels, values)
def _ranking_html(rows: list[dict[str, Any]], value_key: str) -> str:
labels = [str(item.get("name") or "") for item in rows[:8]]
values = [float(item.get(value_key) or 0) for item in rows[:8]]
return _bar_html(labels, values)
def _bar_html(labels: list[Any], values: list[float]) -> str:
max_value = max(values or [1])
rows = []
for label, value in zip(labels, values, strict=False):
width = 100 * value / max_value if max_value else 0
rows.append(
'<div class="bar-row">'
f'<div class="bar-label">{_e(label)}</div>'
f'<div class="bar-track"><div class="bar-fill" style="width:{width:.1f}%"></div></div>'
f'<div>{_e(_money(value))}</div>'
"</div>"
)
return "".join(rows) or '<div class="muted">暂无数据</div>'
def _top_claims_html(rows: list[dict[str, Any]]) -> str:
body = "".join(
"<tr>"
f"<td>{_e(item.get('claimNo'))}</td>"
f"<td>{_e(item.get('employeeName'))}</td>"
f"<td>{_e(item.get('departmentName'))}</td>"
f"<td>{_e(item.get('amountLabel') or _money(item.get('amount')))}</td>"
"</tr>"
for item in rows[:6]
)
return (
"<table><thead><tr><th>单号</th><th>员工</th><th>部门</th><th>金额</th>"
f"</tr></thead><tbody>{body}</tbody></table>"
)
def _actions_html(rows: list[dict[str, Any]]) -> str:
if not rows:
return '<div class="muted">暂无需要升级的行动项。</div>'
return "".join(
(
f'<div class="insight"><strong>{_e(item.get("title"))}</strong>'
f'{_e(item.get("owner"))}<br>{_e(item.get("suggestion"))}</div>'
)
for item in rows
)
def _e(value: Any) -> str:
return html.escape(str(value or ""))
def _money(value: Any) -> str:
try:
return f"¥{float(value or 0):,.0f}"
except (TypeError, ValueError):
return "¥0"
def _trim(value: str, max_len: int) -> str:
return value if len(value) <= max_len else value[: max_len - 1] + ""
def _pdf_hex(value: str) -> str:
return value.encode("utf-16-be").hex().upper()