```
feat: 添加生命周期报告和AI规则管理功能 - 在Dockerfile中添加pillow依赖包用于图像处理 - 实现生命周期报告调度任务,支持日报和周报两种类型 - 新增TASK_RUN_LIFECYCLE任务常量和相关配置选项 - 扩展AI Agent服务以支持用户规则,并在分析时应用规则 - 添加AI用户规则创建、更新和查询接口 - 增加项目生命周期和财务需求分析技能 - 扩展现有模型以支持更完整的业务数据字段 - 实现飞书图片上传功能用于报告展示 ```
This commit is contained in:
182
app/modules/reports/chart.py
Normal file
182
app/modules/reports/chart.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from io import BytesIO
|
||||
from typing import Any
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
WIDTH = 1200
|
||||
HEIGHT = 720
|
||||
FINANCE_HEIGHT = 1200
|
||||
BACKGROUND = (248, 250, 252)
|
||||
FOREGROUND = (17, 24, 39)
|
||||
MUTED = (75, 85, 99)
|
||||
GRID = (209, 213, 219)
|
||||
SERIES = ((37, 99, 235), (245, 158, 11), (220, 38, 38))
|
||||
|
||||
|
||||
def render_lifecycle_chart(chart_data: dict[str, Any]) -> bytes:
|
||||
finance = chart_data.get("finance") or {}
|
||||
image = Image.new("RGB", (WIDTH, FINANCE_HEIGHT if finance else HEIGHT), BACKGROUND)
|
||||
draw = ImageDraw.Draw(image)
|
||||
title_font = _font(36)
|
||||
section_font = _font(28)
|
||||
label_font = _font(22)
|
||||
value_font = _font(22)
|
||||
|
||||
draw.text((60, 35), "Lifecycle Analysis", fill=FOREGROUND, font=title_font)
|
||||
draw.text((60, 82), str(chart_data.get("period") or ""), fill=MUTED, font=label_font)
|
||||
|
||||
projects = chart_data.get("projects") or {}
|
||||
draw.text((60, 135), "Project Portfolio", fill=FOREGROUND, font=section_font)
|
||||
_stacked_bar(
|
||||
draw,
|
||||
y=190,
|
||||
values=[
|
||||
("Unarchived", int(projects.get("unarchived") or 0), SERIES[0]),
|
||||
("Archived", int(projects.get("archived") or 0), GRID),
|
||||
],
|
||||
total=max(int(projects.get("total") or 0), 1),
|
||||
label_font=label_font,
|
||||
value_font=value_font,
|
||||
)
|
||||
|
||||
risks = chart_data.get("risks") or {}
|
||||
draw.text((60, 295), "Current Risks", fill=FOREGROUND, font=section_font)
|
||||
_horizontal_bars(
|
||||
draw,
|
||||
y=345,
|
||||
values=[
|
||||
("Overdue milestones", int(risks.get("overdue_milestones") or 0)),
|
||||
("Overdue tasks", int(risks.get("overdue_tasks") or 0)),
|
||||
("Open events", int(risks.get("open_events") or 0)),
|
||||
],
|
||||
label_font=label_font,
|
||||
value_font=value_font,
|
||||
)
|
||||
|
||||
if finance:
|
||||
cashflows = finance.get("cashflows") or {}
|
||||
receivables = finance.get("receivables") or {}
|
||||
draw.text((60, 640), "Project Finance Needs (CNY)", fill=FOREGROUND, font=section_font)
|
||||
_horizontal_bars(
|
||||
draw,
|
||||
y=695,
|
||||
values=[
|
||||
("Confirmed inflow", int(cashflows.get("confirmed_inflow") or 0)),
|
||||
("Confirmed outflow", int(cashflows.get("confirmed_outflow") or 0)),
|
||||
("Pending outflow", int(cashflows.get("pending_outflow") or 0)),
|
||||
("Overdue receivable", int(receivables.get("overdue") or 0)),
|
||||
("Receivable due 30d", int(receivables.get("due_30d") or 0)),
|
||||
],
|
||||
label_font=label_font,
|
||||
value_font=value_font,
|
||||
)
|
||||
top_projects = finance.get("top_projects") or []
|
||||
draw.text((650, 640), "Top 5 Funding Needs", fill=FOREGROUND, font=section_font)
|
||||
_horizontal_bars(
|
||||
draw,
|
||||
x=650,
|
||||
y=695,
|
||||
width=480,
|
||||
values=[
|
||||
(str(item.get("name") or "Project"), int(item.get("amount") or 0))
|
||||
for item in top_projects[:5]
|
||||
],
|
||||
label_font=label_font,
|
||||
value_font=value_font,
|
||||
)
|
||||
|
||||
people = chart_data.get("people") or {}
|
||||
draw.text((650, 295), "People Coverage", fill=FOREGROUND, font=section_font)
|
||||
_horizontal_bars(
|
||||
draw,
|
||||
x=650,
|
||||
y=345,
|
||||
width=480,
|
||||
values=[
|
||||
("Active", int(people.get("active") or 0)),
|
||||
("Needs attention", int(people.get("attention") or 0)),
|
||||
("Attendance mapped", int(people.get("attendance_mapped") or 0)),
|
||||
],
|
||||
label_font=label_font,
|
||||
value_font=value_font,
|
||||
)
|
||||
|
||||
output = BytesIO()
|
||||
image.save(output, format="PNG", optimize=True)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def lifecycle_chart_alt(chart_data: dict[str, Any]) -> str:
|
||||
projects = chart_data.get("projects") or {}
|
||||
risks = chart_data.get("risks") or {}
|
||||
people = chart_data.get("people") or {}
|
||||
content = (
|
||||
f"项目总数 {projects.get('total', 0)},未归档 {projects.get('unarchived', 0)},"
|
||||
f"里程碑逾期 {risks.get('overdue_milestones', 0)},任务逾期 {risks.get('overdue_tasks', 0)},"
|
||||
f"在职人员 {people.get('active', 0)},需关注人员 {people.get('attention', 0)}"
|
||||
)
|
||||
finance = chart_data.get("finance") or {}
|
||||
if finance:
|
||||
cashflows = finance.get("cashflows") or {}
|
||||
content += (
|
||||
f",已确认资金流入 {cashflows.get('confirmed_inflow', 0)} 元,"
|
||||
f"待确认支出 {cashflows.get('pending_outflow', 0)} 元"
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def _stacked_bar(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
y: int,
|
||||
values: list[tuple[str, int, tuple[int, int, int]]],
|
||||
total: int,
|
||||
label_font: ImageFont.ImageFont,
|
||||
value_font: ImageFont.ImageFont,
|
||||
) -> None:
|
||||
x = 60
|
||||
width = 1070
|
||||
cursor = x
|
||||
for label, value, color in values:
|
||||
segment = round(width * value / total)
|
||||
if segment:
|
||||
draw.rectangle((cursor, y, cursor + segment, y + 42), fill=color)
|
||||
cursor += segment
|
||||
label_x = x
|
||||
for label, value, color in values:
|
||||
draw.rectangle((label_x, y + 62, label_x + 18, y + 80), fill=color)
|
||||
draw.text((label_x + 28, y + 56), f"{label} {value}", fill=FOREGROUND, font=value_font)
|
||||
label_x += 280
|
||||
|
||||
|
||||
def _horizontal_bars(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
y: int,
|
||||
values: list[tuple[str, int]],
|
||||
label_font: ImageFont.ImageFont,
|
||||
value_font: ImageFont.ImageFont,
|
||||
x: int = 60,
|
||||
width: int = 520,
|
||||
) -> None:
|
||||
maximum = max((value for _, value in values), default=0) or 1
|
||||
for index, (label, value) in enumerate(values):
|
||||
row_y = y + index * 90
|
||||
draw.text((x, row_y), label, fill=MUTED, font=label_font)
|
||||
bar_y = row_y + 34
|
||||
draw.rectangle((x, bar_y, x + width, bar_y + 24), fill=GRID)
|
||||
bar_width = round(width * value / maximum)
|
||||
if bar_width:
|
||||
draw.rectangle(
|
||||
(x, bar_y, x + bar_width, bar_y + 24),
|
||||
fill=SERIES[index % len(SERIES)],
|
||||
)
|
||||
label = f"{value:,}"
|
||||
label_width = draw.textlength(label, font=value_font)
|
||||
draw.text((x + width - label_width, row_y), label, fill=FOREGROUND, font=value_font)
|
||||
|
||||
|
||||
def _font(size: int) -> ImageFont.ImageFont:
|
||||
try:
|
||||
return ImageFont.truetype("DejaVuSans.ttf", size=size)
|
||||
except OSError:
|
||||
return ImageFont.load_default(size=size)
|
||||
Reference in New Issue
Block a user