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