|
|
@@ -8,6 +8,7 @@ insights (title + body per paragraph) aligned with reference PPT style.
|
|
|
import copy
|
|
|
import os
|
|
|
import sys
|
|
|
+import re as re_module
|
|
|
from pathlib import Path
|
|
|
from datetime import datetime, timedelta
|
|
|
|
|
|
@@ -19,13 +20,31 @@ from pptx.dml.color import RGBColor
|
|
|
from pptx.enum.text import PP_ALIGN
|
|
|
from pptx.enum.shapes import MSO_SHAPE
|
|
|
|
|
|
-from data_loader import load_daily, load_weekly, load_monthly, load_date_range
|
|
|
-from metrics_calculator import calc_daily_metrics, calc_weekly_metrics, calc_monthly_metrics, generate_deep_insights
|
|
|
+from data_loader import (
|
|
|
+ load_daily, load_weekly, load_monthly, load_date_range,
|
|
|
+ load_generic_excel,
|
|
|
+)
|
|
|
+from metrics_calculator import (
|
|
|
+ calc_daily_metrics, calc_weekly_metrics, calc_monthly_metrics, generate_deep_insights,
|
|
|
+ calc_generic_metrics, calc_generic_trend, calc_generic_distribution,
|
|
|
+ calc_generic_ranking, generate_generic_insights,
|
|
|
+)
|
|
|
from chart_factory import (
|
|
|
add_column_chart, add_bar_chart, add_line_chart, add_doughnut_chart,
|
|
|
add_pie_chart, add_funnel_chart, add_horizontal_bar_chart,
|
|
|
add_grouped_bar_chart, add_table
|
|
|
)
|
|
|
+from page_layouts import (
|
|
|
+ get_kpi_grid, get_chart_left_zone, get_insight_right_zone,
|
|
|
+ get_full_width_zone, get_two_column_zones,
|
|
|
+)
|
|
|
+from quality_inspector import QualityInspector
|
|
|
+from theme_manager import theme_to_rgb_colors, get_theme
|
|
|
+from report_config import (
|
|
|
+ ReportConfig, PageDef, MetricDef, PeriodType, ChartType,
|
|
|
+ validate_six_confirmations,
|
|
|
+)
|
|
|
+from quality_rules import SLIDE_WIDTH, SLIDE_HEIGHT, CONTENT_LEFT, CONTENT_TOP_BASE, FOOTER_TOP
|
|
|
|
|
|
# Colors — aligned with reference design theme YAML
|
|
|
C_PRIMARY = RGBColor(0x1E, 0x3A, 0x5F)
|
|
|
@@ -113,6 +132,53 @@ def _replace_all_placeholders(slide, mapping: dict):
|
|
|
_replace_placeholder(slide, placeholder, new_text)
|
|
|
|
|
|
|
|
|
+def _remove_shape(shape):
|
|
|
+ """Remove a python-pptx shape from its parent tree."""
|
|
|
+ el = shape.element
|
|
|
+ el.getparent().remove(el)
|
|
|
+
|
|
|
+
|
|
|
+def _remove_empty_cover_kpi_placeholders(slide):
|
|
|
+ """
|
|
|
+ Remove template KPI cards when generic cover data does not provide values.
|
|
|
+ This prevents empty rounded rectangles from staying on the cover.
|
|
|
+ """
|
|
|
+ kpi_pattern = re_module.compile(r'\{kpi\d+_(label|value)\}')
|
|
|
+ placeholder_shapes = [
|
|
|
+ shape for shape in slide.shapes
|
|
|
+ if shape.has_text_frame and kpi_pattern.search(shape.text_frame.text or '')
|
|
|
+ ]
|
|
|
+ if not placeholder_shapes:
|
|
|
+ return
|
|
|
+
|
|
|
+ x_min = min(int(shape.left) for shape in placeholder_shapes)
|
|
|
+ x_max = max(int(shape.left) + int(shape.width) for shape in placeholder_shapes)
|
|
|
+ y_min = min(int(shape.top) for shape in placeholder_shapes)
|
|
|
+ y_max = max(int(shape.top) + int(shape.height) for shape in placeholder_shapes)
|
|
|
+ pad = Emu(220000)
|
|
|
+
|
|
|
+ to_remove = []
|
|
|
+ for shape in slide.shapes:
|
|
|
+ sx = int(shape.left)
|
|
|
+ sy = int(shape.top)
|
|
|
+ sw = int(shape.width)
|
|
|
+ sh = int(shape.height)
|
|
|
+ in_region = (
|
|
|
+ sx >= x_min - pad and sx + sw <= x_max + pad and
|
|
|
+ sy >= y_min - pad and sy + sh <= y_max + pad
|
|
|
+ )
|
|
|
+ is_text_placeholder = shape in placeholder_shapes
|
|
|
+ is_empty_kpi_card = (
|
|
|
+ in_region and
|
|
|
+ getattr(shape, 'auto_shape_type', None) == MSO_SHAPE.ROUNDED_RECTANGLE
|
|
|
+ )
|
|
|
+ if is_text_placeholder or is_empty_kpi_card:
|
|
|
+ to_remove.append(shape)
|
|
|
+
|
|
|
+ for shape in to_remove:
|
|
|
+ _remove_shape(shape)
|
|
|
+
|
|
|
+
|
|
|
# ==============================================================================
|
|
|
# NAVIGATION TABS
|
|
|
# ==============================================================================
|
|
|
@@ -903,6 +969,746 @@ def _safe_div(a, b):
|
|
|
|
|
|
|
|
|
# ==============================================================================
|
|
|
+# DYNAMIC / UNIVERSAL REPORT BUILDER
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def build_report(data_file: str, config: ReportConfig, output_path: str) -> str:
|
|
|
+ master_path = config.template_path or get_master_template('daily')
|
|
|
+ prs = Presentation(master_path)
|
|
|
+
|
|
|
+ df = load_generic_excel(data_file)
|
|
|
+ if config.require_six_confirmations:
|
|
|
+ confirmation_issues = validate_six_confirmations(config, list(df.columns))
|
|
|
+ if confirmation_issues:
|
|
|
+ raise ValueError('生成前六项确认未通过:\n- ' + '\n- '.join(confirmation_issues))
|
|
|
+ profile = config.data_profiling or {}
|
|
|
+
|
|
|
+ colors = theme_to_rgb_colors(config.theme)
|
|
|
+
|
|
|
+ metrics = calc_generic_metrics(df, config)
|
|
|
+
|
|
|
+ content_top = _detect_content_top(prs.slides[1]) if len(prs.slides) > 1 else 1524000
|
|
|
+
|
|
|
+ total_pages = len([p for p in config.pages if p.selected])
|
|
|
+ if total_pages == 0:
|
|
|
+ total_pages = len(config.pages)
|
|
|
+
|
|
|
+ for page_idx, page_def in enumerate(config.pages):
|
|
|
+ if not page_def.selected:
|
|
|
+ continue
|
|
|
+
|
|
|
+ page_num = page_idx + 1
|
|
|
+
|
|
|
+ if page_def.page_type == 'cover':
|
|
|
+ _build_cover_page(prs, config, colors)
|
|
|
+ elif page_def.page_type == 'toc':
|
|
|
+ _build_toc_page(prs, config, colors)
|
|
|
+ elif page_def.page_type == 'kpi_overview':
|
|
|
+ _build_kpi_overview_page(prs, config, metrics, colors, content_top, df, profile)
|
|
|
+ elif page_def.page_type == 'trend':
|
|
|
+ _build_trend_page(prs, config, df, profile, colors, content_top)
|
|
|
+ elif page_def.page_type == 'distribution':
|
|
|
+ _build_distribution_page(prs, config, df, profile, colors, content_top, page_def)
|
|
|
+ elif page_def.page_type == 'ranking':
|
|
|
+ _build_ranking_page(prs, config, df, profile, colors, content_top, page_def)
|
|
|
+ elif page_def.page_type == 'summary':
|
|
|
+ _build_summary_page(prs, config, metrics, profile, colors, content_top, page_def)
|
|
|
+ elif page_def.page_type == 'end':
|
|
|
+ _build_end_page(prs, config, colors)
|
|
|
+
|
|
|
+ for slide in prs.slides:
|
|
|
+ _ensure_word_wrap_all(slide)
|
|
|
+
|
|
|
+ _delete_template_slides(prs)
|
|
|
+ prs.save(output_path)
|
|
|
+ print(f"Report saved: {output_path}")
|
|
|
+ return output_path
|
|
|
+
|
|
|
+
|
|
|
+def quality_assured_build(data_file: str, config: ReportConfig,
|
|
|
+ output_path: str) -> tuple:
|
|
|
+ if config.require_six_confirmations:
|
|
|
+ df = load_generic_excel(data_file)
|
|
|
+ confirmation_issues = validate_six_confirmations(config, list(df.columns))
|
|
|
+ if confirmation_issues:
|
|
|
+ raise ValueError('生成前六项确认未通过:\n- ' + '\n- '.join(confirmation_issues))
|
|
|
+
|
|
|
+ inspector = QualityInspector(theme_to_rgb_colors(config.theme))
|
|
|
+
|
|
|
+ return inspector.quality_assured_build(
|
|
|
+ build_fn=lambda d, c: _build_without_save(d, c, config),
|
|
|
+ data=data_file,
|
|
|
+ config=config,
|
|
|
+ output_path=output_path,
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def _build_without_save(data_file, temp_config, original_config):
|
|
|
+ from pptx import Presentation as Prs
|
|
|
+ prs = Prs(get_master_template('daily'))
|
|
|
+ df = load_generic_excel(data_file)
|
|
|
+ profile = original_config.data_profiling or {}
|
|
|
+ colors = theme_to_rgb_colors(original_config.theme)
|
|
|
+ metrics = calc_generic_metrics(df, original_config)
|
|
|
+ content_top = _detect_content_top(prs.slides[1]) if len(prs.slides) > 1 else 1524000
|
|
|
+
|
|
|
+ for page_def in original_config.pages:
|
|
|
+ if not page_def.selected:
|
|
|
+ continue
|
|
|
+ if page_def.page_type == 'cover':
|
|
|
+ _build_cover_page(prs, original_config, colors)
|
|
|
+ elif page_def.page_type == 'kpi_overview':
|
|
|
+ _build_kpi_overview_page(prs, original_config, metrics, colors, content_top, df, profile)
|
|
|
+ elif page_def.page_type == 'trend':
|
|
|
+ if not _build_trend_page(prs, original_config, df, profile, colors, content_top):
|
|
|
+ _build_fallback_analysis_page(prs, original_config, page_def, df, profile, metrics, colors, content_top)
|
|
|
+ elif page_def.page_type == 'distribution':
|
|
|
+ if not _build_distribution_page(prs, original_config, df, profile, colors, content_top, page_def):
|
|
|
+ _build_fallback_analysis_page(prs, original_config, page_def, df, profile, metrics, colors, content_top)
|
|
|
+ elif page_def.page_type == 'ranking':
|
|
|
+ if not _build_ranking_page(prs, original_config, df, profile, colors, content_top, page_def):
|
|
|
+ _build_fallback_analysis_page(prs, original_config, page_def, df, profile, metrics, colors, content_top)
|
|
|
+ elif page_def.page_type == 'summary':
|
|
|
+ _build_summary_page(prs, original_config, metrics, profile, colors, content_top, page_def)
|
|
|
+ elif page_def.page_type == 'end':
|
|
|
+ _build_end_page(prs, original_config, colors)
|
|
|
+ elif page_def.page_type == 'toc':
|
|
|
+ _build_toc_page(prs, original_config, colors)
|
|
|
+
|
|
|
+ for slide in prs.slides:
|
|
|
+ _ensure_word_wrap_all(slide)
|
|
|
+ _delete_template_slides(prs)
|
|
|
+ return prs
|
|
|
+
|
|
|
+
|
|
|
+def _build_cover_page(prs, config, colors):
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[0])
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ '{report_type}': '数据报告',
|
|
|
+ '{date}': config.period_str or config.date_range[0].strftime('%Y年%m月%d日'),
|
|
|
+ '{department}': config.source_label,
|
|
|
+ '{period}': config.period_str,
|
|
|
+ '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
|
+ })
|
|
|
+ _remove_empty_cover_kpi_placeholders(slide)
|
|
|
+ _add_footer_if_missing(slide, f'数据来源:{config.source_label} | 1/{len(config.pages)}')
|
|
|
+
|
|
|
+
|
|
|
+def _build_fallback_analysis_page(prs, config, page_def, df, profile, metrics, colors, content_top):
|
|
|
+ """
|
|
|
+ Fallback page builder: generates analysis text from available data
|
|
|
+ when the primary page type cannot produce content (e.g. no time columns
|
|
|
+ for trend, no category columns for distribution).
|
|
|
+ Produces at least 4 deep analysis blocks with data citations.
|
|
|
+ """
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ page_title = page_def.title if page_def and page_def.title else f'{config.title}数据分析'
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ '{date}': config.period_str,
|
|
|
+ '{page_title}': page_title,
|
|
|
+ '{source}': config.source_label,
|
|
|
+ '{period}': '',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+
|
|
|
+ num_cols = profile.get('numeric_columns', [])
|
|
|
+ cat_cols = profile.get('category_columns', [])
|
|
|
+
|
|
|
+ insight_items = []
|
|
|
+
|
|
|
+ if num_cols:
|
|
|
+ top_metric = num_cols[0]
|
|
|
+ top_name = top_metric.get('inferred_label', top_metric['column_name'])
|
|
|
+ top_vals = df[top_metric['column_name']].dropna()
|
|
|
+ if len(top_vals) > 0:
|
|
|
+ mean_val = top_vals.mean()
|
|
|
+ max_val = top_vals.max()
|
|
|
+ min_val = top_vals.min()
|
|
|
+ median_val = top_vals.median()
|
|
|
+ total_val = top_vals.sum()
|
|
|
+ insight_items.append({
|
|
|
+ 'title': f'{top_name}整体概览',
|
|
|
+ 'content': f'报告周期内,{top_name}统计数据共包含 {len(top_vals)} 条有效记录。'
|
|
|
+ f'总和为 {total_val:,.0f},平均值为 {mean_val:,.2f},中位数为 {median_val:,.2f}。'
|
|
|
+ f'最大值为 {max_val:,.2f},最小值为 {min_val:,.2f}。'
|
|
|
+ f'{"数据波动范围较大,最大值与最小值差距显著,说明不同条目间差异明显,建议深入分析极端值成因" if min_val > 0 and max_val / max(min_val, 1) > 100 else "数据整体分布较为均衡,波动性在合理范围内"}。'
|
|
|
+ f'中位数与平均值的偏差反映了数据的{"右偏分布(少数大值拉高了均值),说明存在显著头部效应" if median_val < mean_val * 0.8 else "左偏分布" if median_val > mean_val * 1.2 else "较为对称,数据呈正态分布趋势"}。',
|
|
|
+ })
|
|
|
+
|
|
|
+ insight_items.append({
|
|
|
+ 'title': f'{top_name}分段分析',
|
|
|
+ 'content': f'对 {top_name} 进行四分段统计:上四分位数(25%数据高于此值)为 {top_vals.quantile(0.75):,.2f},'
|
|
|
+ f'下四分位数(25%数据低于此值)为 {top_vals.quantile(0.25):,.2f},'
|
|
|
+ f'四分位距(IQR)为 {top_vals.quantile(0.75) - top_vals.quantile(0.25):,.2f}。'
|
|
|
+ f'{"IQR较大,数据分布较为离散,不同类别的表现差异明显,需关注尾部类别的提升空间" if (top_vals.quantile(0.75) - top_vals.quantile(0.25)) > abs(mean_val) * 0.5 else "IQR在合理范围内,数据集中度较好"}。'
|
|
|
+ f'建议按四分位将数据分为四组,重点跟踪上四分位组的表现,识别可复制的成功因素。',
|
|
|
+ })
|
|
|
+
|
|
|
+ if cat_cols and num_cols:
|
|
|
+ cat = cat_cols[0]
|
|
|
+ cat_name = cat.get('inferred_label', cat['column_name'])
|
|
|
+ num = num_cols[0]
|
|
|
+ num_name = num.get('inferred_label', num['column_name'])
|
|
|
+ cat_unique = df[cat['column_name']].dropna().nunique()
|
|
|
+ insight_items.append({
|
|
|
+ 'title': f'{cat_name}分类覆盖分析',
|
|
|
+ 'content': f'数据共覆盖 {cat_unique} 个不同的{cat_name},在 {num_name} 维度上呈现差异化分布。'
|
|
|
+ f'不同{cat_name}对整体{num_name}的贡献度各异,建议按贡献度大小将{cat_name}进行分类管理。'
|
|
|
+ f'高贡献类别应重点维护和深度挖掘,中等贡献类别需持续培育和资源投入,'
|
|
|
+ f'低贡献类别可评估其战略价值,适当调整投入节奏。建议建立分类分级管理体系,'
|
|
|
+ f'每月跟踪各类别的变化趋势和占比波动。',
|
|
|
+ })
|
|
|
+
|
|
|
+ if len(num_cols) >= 2:
|
|
|
+ num1 = num_cols[0]
|
|
|
+ num2 = num_cols[1]
|
|
|
+ ratio = df[num1['column_name']].sum() / max(df[num2['column_name']].sum(), 1)
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '关键比率与效率指标',
|
|
|
+ 'content': f'{num1.get("inferred_label", num1["column_name"])}与{num2.get("inferred_label", num2["column_name"])}的比率为 {ratio:.2f},'
|
|
|
+ f'该比率是衡量业务效率的重要参考指标。'
|
|
|
+ f'{"比率处于较高水平,表明单位投入产出效率良好" if ratio > 1 else "比率偏低,单位投入的产出效益有限,存在效率提升空间"}。'
|
|
|
+ f'建议将此比率纳入定期监控指标,按月环比追踪变化趋势,'
|
|
|
+ f'并针对低比率项目制定专项提升计划,分析制约因素和可优化环节。',
|
|
|
+ })
|
|
|
+
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '数据质量与代表性评估',
|
|
|
+ 'content': f'本报告基于共 {len(df)} 条记录进行分析,数据覆盖范围包括上述多个维度。'
|
|
|
+ f'建议在后续周期中持续关注数据完整性和及时性,确保分析结果准确反映业务真实情况。'
|
|
|
+ f'对于数据量较小或集中度较高的维度,应结合业务判断进行解读,避免以偏概全。'
|
|
|
+ f'同时建议补充更多维度的数据(如时间序列数据、竞品对标数据等),'
|
|
|
+ f'以支撑更全面的分析视角和更精准的决策建议。',
|
|
|
+ })
|
|
|
+
|
|
|
+ if not insight_items:
|
|
|
+ insight_items = [{
|
|
|
+ 'title': '数据总览',
|
|
|
+ 'content': f'当前数据集包含 {len(df)} 条记录,{len(df.columns)} 个字段。'
|
|
|
+ f'数值字段 {len(num_cols)} 个,分类字段 {len(cat_cols)} 个。'
|
|
|
+ f'建议结合业务场景规划具体的数据分析维度,'
|
|
|
+ f'以生成更具洞察力和指导意义的数据报告。',
|
|
|
+ }]
|
|
|
+
|
|
|
+ if num_cols and len(df) > 0:
|
|
|
+ top_col = num_cols[0]
|
|
|
+ chart_zone = get_chart_left_zone(content_top, 0.4)
|
|
|
+ text_zone = get_insight_right_zone(content_top, 0.4)
|
|
|
+ sample_vals = df[top_col['column_name']].dropna().head(10).tolist()
|
|
|
+ sample_labels = [f'记录{i+1}' for i in range(len(sample_vals))]
|
|
|
+ if sample_vals:
|
|
|
+ add_bar_chart(slide, sample_labels, sample_vals,
|
|
|
+ Emu(chart_zone.x), Emu(chart_zone.y),
|
|
|
+ Emu(chart_zone.width), Emu(chart_zone.height),
|
|
|
+ series_name=top_col.get('inferred_label', top_col['column_name']),
|
|
|
+ color=colors.get('primary'))
|
|
|
+ _add_structured_insight(slide, insight_items,
|
|
|
+ Emu(text_zone.x), Emu(text_zone.y),
|
|
|
+ Emu(text_zone.width), Emu(text_zone.height))
|
|
|
+ else:
|
|
|
+ zone = get_full_width_zone(content_top)
|
|
|
+ _add_structured_insight(slide, insight_items,
|
|
|
+ Emu(zone.x), Emu(zone.y),
|
|
|
+ Emu(zone.width), Emu(zone.height))
|
|
|
+
|
|
|
+
|
|
|
+def _build_toc_page(prs, config, colors):
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ active_pages = [p for p in config.pages if p.selected and p.page_type not in ('cover', 'toc', 'end')]
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ '{date}': config.period_str,
|
|
|
+ '{page_title}': '目录',
|
|
|
+ '{source}': config.source_label,
|
|
|
+ '{period}': f'2/{len(config.pages)}',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ for i, page in enumerate(active_pages[:6], 1):
|
|
|
+ _replace_placeholder(slide, f'{{chapter{i}_title}}', page.title)
|
|
|
+ _replace_placeholder(slide, f'{{chapter{i}_desc}}', page.conclusion_title or page.title)
|
|
|
+
|
|
|
+
|
|
|
+def _build_kpi_overview_page(prs, config, metrics, colors, content_top, df=None, profile=None):
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ page_title = '核心指标概览'
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ '{date}': config.period_str,
|
|
|
+ '{page_title}': page_title,
|
|
|
+ '{source}': config.source_label,
|
|
|
+ '{period}': '',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ kpi_items = []
|
|
|
+ primary_vals = {}
|
|
|
+ all_vals = {}
|
|
|
+ for md in config.metrics:
|
|
|
+ if md.metric_type.value == 'kpi' and md.selected:
|
|
|
+ val = metrics.get(md.name, 0)
|
|
|
+ display_val = format(val, md.format_spec) if isinstance(val, (int, float)) else str(val)
|
|
|
+ kpi_items.append({
|
|
|
+ 'label': md.label,
|
|
|
+ 'value': display_val,
|
|
|
+ 'unit': md.unit,
|
|
|
+ 'change': '',
|
|
|
+ 'sub': '',
|
|
|
+ })
|
|
|
+ if md.is_primary:
|
|
|
+ primary_vals[md.label] = val
|
|
|
+ all_vals[md.label] = val
|
|
|
+
|
|
|
+ if kpi_items:
|
|
|
+ _add_kpi_cards(slide, kpi_items[:6], start_y=Emu(content_top))
|
|
|
+
|
|
|
+ insight_items = []
|
|
|
+
|
|
|
+ kpi_names = [m.label for m in config.metrics if m.selected]
|
|
|
+ kpi_str = "、".join(kpi_names[:6]) if kpi_names else "各指标"
|
|
|
+ primary_kpis = [m for m in config.metrics if m.is_primary and m.selected]
|
|
|
+ if not primary_kpis:
|
|
|
+ primary_kpis = [m for m in config.metrics if m.selected][:3]
|
|
|
+
|
|
|
+ kpi_detail_parts = []
|
|
|
+ for i, pk in enumerate(primary_kpis):
|
|
|
+ val = all_vals.get(pk.label, 0)
|
|
|
+ unit_str = pk.unit if pk.unit else ''
|
|
|
+ display_val = format(val, pk.format_spec) if isinstance(val, (int, float)) else str(val)
|
|
|
+ kpi_detail_parts.append(f'{pk.label}: {display_val}{unit_str}')
|
|
|
+
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '核心数据概览',
|
|
|
+ 'content': f'本期报告涵盖 {kpi_str} 共 {len(kpi_names)} 项核心指标。'
|
|
|
+ f'{";".join(kpi_detail_parts[:4])}。'
|
|
|
+ f'其中{"、".join(p.label for p in primary_kpis[:3])}为本次分析的重点关注指标。'
|
|
|
+ f'建议将这些指标与历史同期数据进行纵向对比,以及与行业基准进行横向对标,以全面评估当前业务健康度。'
|
|
|
+ f'对于波动较大的指标,需深入追溯其背后的业务动因,判断是否为趋势性变化还是季节性波动。',
|
|
|
+ })
|
|
|
+
|
|
|
+ cat_cols = profile.get('category_columns', []) if profile else []
|
|
|
+ num_cols = profile.get('numeric_columns', []) if profile else []
|
|
|
+ total_rows = profile.get('total_rows', 0) if profile else 0
|
|
|
+
|
|
|
+ if cat_cols:
|
|
|
+ top_cats = [c.get('inferred_label', c.get('column_name', '')) for c in cat_cols[:3]]
|
|
|
+ cat_details = []
|
|
|
+ for c in cat_cols[:3]:
|
|
|
+ uc = c.get('unique_count', 'N/A')
|
|
|
+ cat_details.append(f'{c.get("inferred_label", c.get("column_name", ""))}({uc}类)')
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '数据覆盖与维度分析',
|
|
|
+ 'content': f'数据覆盖 {total_rows:,} 条记录,包含 {", ".join(cat_details)} 等多个分析维度。'
|
|
|
+ f'丰富的维度数据支持从 {", ".join(top_cats)} 等角度进行多维度联动分析。'
|
|
|
+ f'建议关注各维度下的数据分布特征,识别高贡献或异常的分类群体,'
|
|
|
+ f'针对性地分析不同维度的表现差异,为精细化运营和数据驱动决策提供支撑。',
|
|
|
+ })
|
|
|
+
|
|
|
+ if len(config.metrics) >= 3:
|
|
|
+ compare_items = []
|
|
|
+ for a, b in zip(primary_kpis[:2], primary_kpis[1:3]):
|
|
|
+ va = all_vals.get(a.label, 0)
|
|
|
+ vb = all_vals.get(b.label, 0)
|
|
|
+ if va and vb:
|
|
|
+ ratio = round(va / vb, 2) if vb else 0
|
|
|
+ compare_items.append(f'{a.label}与{b.label}的比值为 {ratio}')
|
|
|
+ if compare_items:
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '指标间关联分析',
|
|
|
+ 'content': f'{";".join(compare_items)}。通过指标间的比值关系可以发现数据的内在规律,'
|
|
|
+ f'比值异常偏离正常区间时需重点关注。建议进一步计算各指标与核心业务目标之间的相关系数,'
|
|
|
+ f'量化不同指标对业务目标的影响力排序,将有限资源聚焦在驱动型指标上。',
|
|
|
+ })
|
|
|
+ else:
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '指标间关联分析',
|
|
|
+ 'content': f'本期核心指标包括 {", ".join(p.label for p in primary_kpis[:3])}。'
|
|
|
+ f'建议通过散点图或相关系数分析探索指标间的线性/非线性关系,识别是否存在协同或对冲效应。'
|
|
|
+ f'同时建议按时间序列分析各指标的周期性规律,为资源配置和预测提供依据。',
|
|
|
+ })
|
|
|
+
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '关键发现与行动建议',
|
|
|
+ 'content': f'综合分析 {len(kpi_names)} 项指标,建议重点关注以下方向:'
|
|
|
+ f'(1) 定期监控核心指标的趋势变化,建立异常预警机制,当指标偏离正常区间时及时触发排查流程;'
|
|
|
+ f'(2) 深化多维度交叉分析,挖掘不同群体间的结构差异,识别增长机会和风险点;'
|
|
|
+ f'(3) 结合业务经验和外部数据,验证数据指标的准确性和合理性;'
|
|
|
+ f'(4) 将分析结论转化为可执行的具体行动项,明确责任人和时间节点,建立跟踪闭环机制。',
|
|
|
+ })
|
|
|
+
|
|
|
+ kpi_rows = 2 if len(kpi_items) > 3 else 1
|
|
|
+ kpi_grid_bottom = int(content_top) + Emu(3048000)
|
|
|
+ if kpi_rows == 2:
|
|
|
+ kpi_grid_bottom += Emu(3429000)
|
|
|
+ insight_zone_y = kpi_grid_bottom + Emu(254000)
|
|
|
+ remaining_height = int(FOOTER_TOP - insight_zone_y - Emu(180000))
|
|
|
+ if remaining_height >= Emu(1400000):
|
|
|
+ compact_items = insight_items[:2] if kpi_rows == 2 else insight_items[:3]
|
|
|
+ _add_structured_insight(slide, compact_items,
|
|
|
+ Emu(CONTENT_LEFT), Emu(insight_zone_y),
|
|
|
+ Emu(SLIDE_WIDTH - 2 * CONTENT_LEFT), Emu(remaining_height),
|
|
|
+ title_size=Pt(10), body_size=Pt(9), min_body_size=Pt(8))
|
|
|
+
|
|
|
+
|
|
|
+def _build_trend_page(prs, config, df, profile, colors, content_top):
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ time_cols = profile.get('time_columns', [])
|
|
|
+ num_cols = profile.get('numeric_columns', [])
|
|
|
+ if not time_cols or not num_cols:
|
|
|
+ return False
|
|
|
+
|
|
|
+ time_col = time_cols[0]['column_name']
|
|
|
+ metric_col = num_cols[0]['column_name']
|
|
|
+ label = num_cols[0].get('inferred_label', metric_col)
|
|
|
+
|
|
|
+ page_title = f'{label}趋势'
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ '{date}': config.period_str,
|
|
|
+ '{page_title}': page_title,
|
|
|
+ '{source}': config.source_label,
|
|
|
+ '{period}': '',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+
|
|
|
+ trend_data = calc_generic_trend(df, time_col, metric_col)
|
|
|
+
|
|
|
+ if trend_data.get('dates'):
|
|
|
+ chart_zone = get_chart_left_zone(content_top, 0.6)
|
|
|
+ text_zone = get_insight_right_zone(content_top, 0.6)
|
|
|
+ add_line_chart(slide, trend_data['dates'], trend_data['values'],
|
|
|
+ Emu(chart_zone.x), Emu(chart_zone.y),
|
|
|
+ Emu(chart_zone.width), Emu(chart_zone.height),
|
|
|
+ series_name=label, color=colors.get('primary'))
|
|
|
+
|
|
|
+ dates = trend_data['dates']
|
|
|
+ vals = trend_data['values']
|
|
|
+ n = len(vals)
|
|
|
+ first_v, last_v = vals[0], vals[-1]
|
|
|
+ change = last_v - first_v
|
|
|
+ change_pct = round(change / first_v * 100, 1) if first_v else 0
|
|
|
+ max_v = max(vals) if vals else 0
|
|
|
+ min_v = min(vals) if vals else 0
|
|
|
+ max_idx = vals.index(max_v) if vals else 0
|
|
|
+ min_idx = vals.index(min_v) if vals else 0
|
|
|
+ peak_date = dates[max_idx] if max_idx < len(dates) else 'N/A'
|
|
|
+ trough_date = dates[min_idx] if min_idx < len(dates) else 'N/A'
|
|
|
+
|
|
|
+ direction_text = '上升' if change > 0 else '下降' if change < 0 else '平稳'
|
|
|
+ volatility = round((max_v - min_v) / (sum(vals) / n) * 100, 1) if sum(vals) else 0 if vals else 0
|
|
|
+ insight_items = [
|
|
|
+ {
|
|
|
+ 'title': f'{label}整体趋势概况',
|
|
|
+ 'content': f'在报告周期内共采集 {n} 个时间点的数据,{label}'
|
|
|
+ f'从 {dates[0]} 的 {first_v:,.0f} 变动至 {dates[-1]} 的 {last_v:,.0f},'
|
|
|
+ f'整体{direction_text}{abs(change_pct):.1f}%,{direction_text}趋势{"显著" if abs(change_pct) > 20 else "温和" if abs(change_pct) > 5 else "较为平缓"}。'
|
|
|
+ f'数据变化轨迹反映出{"持续向好的增长态势" if direction_text == "上升" and abs(change_pct) > 10 else "温和改善的积极信号" if direction_text == "上升" else "回调盘整的阶段性特征" if direction_text == "下降" else "平稳运行的基本状态"},'
|
|
|
+ f'建议将当前趋势与业务目标和历史同期数据进行交叉对比,评估达成全年目标的可行性。如需更详尽的趋势分析,建议增加数据采集频度和时间跨度。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': '峰值与谷值分析',
|
|
|
+ 'content': f'周期内最高值出现在 {peak_date},为 {max_v:,.0f};'
|
|
|
+ f'最低值出现在 {trough_date},为 {min_v:,.0f}。'
|
|
|
+ f'极值差距 {max_v - min_v:,.0f},波动幅度 {volatility}%,'
|
|
|
+ f'{"波动显著,需关注异常节点的驱动因素,建议排查是否受节假日、促销活动、外部政策变化等因素影响" if volatility > 30 else "波动在可控范围内,但仍需对异常波动保持警觉"}{"." if volatility > 30 else ",建立异常值的快速预警和响应机制。"}',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': '趋势阶段性特征',
|
|
|
+ 'content': f'前半程({dates[0]}至{dates[min(n//2, n-1)]})'
|
|
|
+ f'{"呈上升态势" if sum(vals[:n//2]) < sum(vals[n//2:]) else "呈下降态势" if sum(vals[:n//2]) > sum(vals[n//2:]) else "基本持平"},'
|
|
|
+ f'后半程均值为 {sum(vals[n//2:])/(n-n//2):,.0f}。建议结合业务事件节点深入分析拐点成因,'
|
|
|
+ f'重点关注是否存在季节性波动、周期性波动或外部冲击等结构性因素。'
|
|
|
+ f'若数据量较少,趋势解读应以业务经验为主,辅以数据验证。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': '业务启示',
|
|
|
+ 'content': f'综合趋势分析,当前数据反映出{"积极向好的发展态势" if direction_text == "上升" and abs(change_pct) > 10 else "温和稳定的运行动态" if abs(change_pct) <= 10 else "需重点关注的下行风险"}。'
|
|
|
+ f'建议{"加大资源投入以把握增长机遇,同时关注增速的可持续性,避免盲目扩张" if direction_text == "上升" else "排查下降原因并制定针对性应对措施,分析是短期波动还是长期趋势转折" if direction_text == "下降" else "保持当前运营节奏,同时关注潜在变化信号,适时调整策略" if direction_text == "平稳" else "继续观察数据走势"}。'
|
|
|
+ f'建议将数据与业务KPI目标进行对标分析,定期回顾趋势变化。',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ _add_structured_insight(slide, insight_items,
|
|
|
+ Emu(text_zone.x), Emu(text_zone.y),
|
|
|
+ Emu(text_zone.width), Emu(text_zone.height))
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+def _build_distribution_page(prs, config, df, profile, colors, content_top, page_def=None):
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ cat_cols = profile.get('category_columns', [])
|
|
|
+ num_cols = profile.get('numeric_columns', [])
|
|
|
+ if not cat_cols:
|
|
|
+ return False
|
|
|
+
|
|
|
+ elem = (page_def.elements or [{}])[0] if page_def else {}
|
|
|
+ cat_col = elem.get('category') or cat_cols[0]['column_name']
|
|
|
+ cat_label = elem.get('category_label') or next(
|
|
|
+ (c.get('inferred_label', cat_col) for c in cat_cols if c['column_name'] == cat_col), cat_col)
|
|
|
+ metric_col = elem.get('metric') or (num_cols[0]['column_name'] if num_cols else None)
|
|
|
+ metric_label = elem.get('metric_label') or (next(
|
|
|
+ (c.get('inferred_label', metric_col) for c in num_cols if c['column_name'] == metric_col), metric_col) if metric_col else '')
|
|
|
+
|
|
|
+ page_title = page_def.title if page_def and page_def.title else f'{cat_label}分布'
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ '{date}': config.period_str,
|
|
|
+ '{page_title}': page_title,
|
|
|
+ '{source}': config.source_label,
|
|
|
+ '{period}': '',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+
|
|
|
+ dist = calc_generic_distribution(df, cat_col, metric_col, top_n=8)
|
|
|
+
|
|
|
+ if dist.get('categories'):
|
|
|
+ chart_zone = get_chart_left_zone(content_top, 0.55)
|
|
|
+ text_zone = get_insight_right_zone(content_top, 0.55)
|
|
|
+ if len(dist['categories']) <= 8:
|
|
|
+ add_doughnut_chart(slide, dist['categories'], dist['values'],
|
|
|
+ Emu(chart_zone.x), Emu(chart_zone.y),
|
|
|
+ Emu(chart_zone.width), Emu(chart_zone.height),
|
|
|
+ colors=colors.get('series'))
|
|
|
+ else:
|
|
|
+ add_bar_chart(slide, dist['categories'], dist['values'],
|
|
|
+ Emu(chart_zone.x), Emu(chart_zone.y),
|
|
|
+ Emu(chart_zone.width), Emu(chart_zone.height),
|
|
|
+ series_name=metric_label, color=colors.get('primary'))
|
|
|
+
|
|
|
+ cats, vals, pcts = dist['categories'], dist['values'], dist['percentages']
|
|
|
+ grand_total = sum(vals)
|
|
|
+ top3_pct = sum(pcts[:3])
|
|
|
+ top1_name, top1_val, top1_pct = cats[0], vals[0], pcts[0]
|
|
|
+
|
|
|
+ metric_suffix = metric_label if metric_label else '数量'
|
|
|
+ insight_items = [
|
|
|
+ {
|
|
|
+ 'title': f'{cat_label}分布概况',
|
|
|
+ 'content': f'共有 {len(cats)} 个不同的{cat_label},覆盖范围'
|
|
|
+ f'{"广泛" if len(cats) >= 8 else "较为丰富" if len(cats) >= 5 else "相对集中"}。'
|
|
|
+ f'前3名合计占比 {top3_pct:.1f}%,集中度'
|
|
|
+ f'{"较高,呈现显著的头部集中特征" if top3_pct > 70 else "中等,呈现梯度递减分布" if top3_pct > 50 else "较低,分布较为均衡"}。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': f'排名第一: {top1_name}',
|
|
|
+ 'content': f'{top1_name}以 {top1_val:,}{metric_suffix}(占比 {top1_pct:.1f}%)位居榜首,'
|
|
|
+ f'{"是第二名" + cats[1] + "的" + f"{round(top1_val/vals[1],1)}" + "倍,优势极为显著" if len(cats) > 1 else "是该维度中最重要的类别"}。'
|
|
|
+ f'该类别贡献了超过三分之一的{metric_label},是整体业务的基本盘和核心增长极。',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ if len(vals) >= 3:
|
|
|
+ top3_sum = sum(vals[:3])
|
|
|
+ tail_sum = sum(vals[3:])
|
|
|
+ tail_pct = sum(pcts[3:])
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '长尾分布特征',
|
|
|
+ 'content': f'前三名累计 {top3_sum:,}{metric_suffix}({top3_pct:.1f}%),'
|
|
|
+ f'剩余 {len(cats)-3} 个合计 {tail_sum:,}{metric_suffix}({tail_pct:.1f}%),'
|
|
|
+ f'属于{"头部集中型分布" if top3_pct > 70 else "相对均衡分布" if top3_pct < 50 else "梯度递减型分布"}。'
|
|
|
+ f'头部贡献了绝大部分{metric_label},尾部虽数量众多但单个贡献有限。',
|
|
|
+ })
|
|
|
+ if len(vals) > 1:
|
|
|
+ avg_val = sum(vals) / len(vals)
|
|
|
+ cv = round(vals[0] / avg_val, 1) if avg_val else 0
|
|
|
+ median_idx = len(vals) // 2
|
|
|
+ median_val = vals[median_idx]
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '差异化与离散度分析',
|
|
|
+ 'content': f'排名第一的{cat_label}{top1_name}的{metric_suffix}是全部分类均值的 {cv} 倍,'
|
|
|
+ f'中位数分类(第{median_idx+1}名)为 {median_val:,}{metric_suffix},'
|
|
|
+ f'表明该维度{"差异化显著,资源集中度较高" if cv > 3 else "差异化适中,各分类间差距可控" if cv > 1.5 else "分布较为均匀"}。'
|
|
|
+ f'头部与中位数的差距反映了{cat_label}维度上的分层特征,是运营资源重点倾斜方向。',
|
|
|
+ })
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '业务启示',
|
|
|
+ 'content': f'建议重点关注 {cats[0]} 的增量拓展与存量维护,同时深入分析排名中位类别的提升空间。'
|
|
|
+ f'对于 {metric_label}贡献较小的尾部类别(如占比低于3%的分类),可评估是否优化资源配置、'
|
|
|
+ f'调整运营策略或将资源向高回报类别倾斜。结合{cat_label}维度持续跟踪分布变化,及时把握结构性机会。',
|
|
|
+ })
|
|
|
+
|
|
|
+ _add_structured_insight(slide, insight_items,
|
|
|
+ Emu(text_zone.x), Emu(text_zone.y),
|
|
|
+ Emu(text_zone.width), Emu(text_zone.height))
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+def _build_ranking_page(prs, config, df, profile, colors, content_top, page_def=None):
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ cat_cols = profile.get('category_columns', [])
|
|
|
+ num_cols = profile.get('numeric_columns', [])
|
|
|
+ if not cat_cols or not num_cols:
|
|
|
+ return False
|
|
|
+
|
|
|
+ elem = (page_def.elements or [{}])[0] if page_def else {}
|
|
|
+ rank_col = elem.get('category') or cat_cols[-1]['column_name']
|
|
|
+ rank_label = elem.get('category_label') or next(
|
|
|
+ (c.get('inferred_label', rank_col) for c in cat_cols if c['column_name'] == rank_col), rank_col)
|
|
|
+ metric_col = elem.get('metric') or num_cols[0]['column_name']
|
|
|
+ metric_label = elem.get('metric_label') or next(
|
|
|
+ (c.get('inferred_label', metric_col) for c in num_cols if c['column_name'] == metric_col), metric_col)
|
|
|
+
|
|
|
+ page_title = page_def.title if page_def and page_def.title else f'{rank_label}TOP排行'
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ '{date}': config.period_str,
|
|
|
+ '{page_title}': page_title,
|
|
|
+ '{source}': config.source_label,
|
|
|
+ '{period}': '',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+
|
|
|
+ ranking = calc_generic_ranking(df, rank_col, metric_col, top_n=15)
|
|
|
+ if ranking:
|
|
|
+ chart_zone = get_chart_left_zone(content_top, 0.6)
|
|
|
+ text_zone = get_insight_right_zone(content_top, 0.6)
|
|
|
+ names = [r['name'] for r in ranking]
|
|
|
+ vals = [r['value'] for r in ranking]
|
|
|
+ add_bar_chart(slide, names, vals,
|
|
|
+ Emu(chart_zone.x), Emu(chart_zone.y),
|
|
|
+ Emu(chart_zone.width), Emu(chart_zone.height),
|
|
|
+ series_name=metric_label, color=colors.get('primary'))
|
|
|
+
|
|
|
+ total_val = sum(vals)
|
|
|
+ top3_names = [r['name'] for r in ranking[:3]]
|
|
|
+ top3_vals = [r['value'] for r in ranking[:3]]
|
|
|
+ top3_pct = [round(v / total_val * 100, 1) for v in top3_vals] if total_val else [0, 0, 0]
|
|
|
+ top1_vs_last = round(vals[0] / vals[-1], 1) if len(vals) > 1 and vals[-1] > 0 else 'N/A'
|
|
|
+
|
|
|
+ insight_items = [
|
|
|
+ {
|
|
|
+ 'title': f'{rank_label}TOP排行概况',
|
|
|
+ 'content': f'共展示 {len(ranking)} 个排名项,前3名分别为 {top3_names[0]}、{top3_names[1]}、'
|
|
|
+ f'{top3_names[2]},累计 {sum(top3_vals):,}{metric_label}({sum(top3_pct):.1f}%)。'
|
|
|
+ f'前三名合计贡献超过总量的三分之一,表明{rank_label}维度呈现{"显著的头部集中特征" if sum(top3_pct) > 60 else "梯度递减的分布格局" if sum(top3_pct) > 40 else "相对均衡的分布态势"}。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': f'榜首分析: {top3_names[0]}',
|
|
|
+ 'content': f'{top3_names[0]}以 {top3_vals[0]:,}{metric_label}(占比 {top3_pct[0]:.1f}%)位居榜首,'
|
|
|
+ f'{"是第2名" + top3_names[1] + "的" + f"{round(top3_vals[0]/top3_vals[1],1)}倍,领先优势显著" if len(ranking) > 1 and top3_vals[1] > 0 else "优势突出"}。'
|
|
|
+ f'作为排名第一的{rank_label},其业绩表现直接影响整体业务大盘,建议重点关注其可持续增长策略。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': '头部与尾部差距分析',
|
|
|
+ 'content': f'第1名与第{len(ranking)}名差距达 {top1_vs_last} 倍,'
|
|
|
+ f'前5名平均 {round(sum(vals[:5])/5):,}{metric_label},'
|
|
|
+ f'后5名平均 {round(sum(vals[-5:])/5):,}{metric_label},'
|
|
|
+ f'前后差距约 {round((sum(vals[:5])/5)/(sum(vals[-5:])/5),1) if sum(vals[-5:]) > 0 else "N/A"} 倍。'
|
|
|
+ f'{"头部效应极为明显,需关注是否因资源分配不均导致" if isinstance(top1_vs_last, float) and top1_vs_last > 10 else "差距较为显著,存在分层优化的空间" if isinstance(top1_vs_last, float) and top1_vs_last > 5 else "梯度分布相对均衡,可针对性提升各层级表现"}。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': '累计贡献率与分层分析',
|
|
|
+ 'content': f'前5名累计贡献 {sum(vals[:5]):,}{metric_label}({round(sum(vals[:5])/total_val*100,1) if total_val else 0}%),'
|
|
|
+ f'前10名累计贡献 {sum(vals[:10]):,}{metric_label}({round(sum(vals[:10])/total_val*100,1) if total_val else 0}%),'
|
|
|
+ f'剩余 {len(ranking)-10} 名合计贡献 {sum(vals[10:]):,}{metric_label}({round(sum(vals[10:])/total_val*100,1) if total_val else 0}%)。'
|
|
|
+ f'从分层结构来看,可划分为三个梯队:第一梯队(前3名)为业绩核心贡献者,第二梯队(第4-8名)为稳定输出层,'
|
|
|
+ f'第三梯队(第9名及以后)为潜力提升层。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': '业务建议',
|
|
|
+ 'content': f'重点关注 {", ".join(top3_names)} 的发展动态,提炼其成功经验并推广至团队。'
|
|
|
+ f'对于排名靠后的{rank_label},可评估其增长潜力与资源匹配度,'
|
|
|
+ f'识别可突破的增量空间。建议建立{rank_label}的绩效考核与激励体系,'
|
|
|
+ f'通过标杆带动和梯队培养实现整体业绩提升。',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+ _add_structured_insight(slide, insight_items,
|
|
|
+ Emu(text_zone.x), Emu(text_zone.y),
|
|
|
+ Emu(text_zone.width), Emu(text_zone.height))
|
|
|
+ return True
|
|
|
+ return False
|
|
|
+
|
|
|
+
|
|
|
+def _build_summary_page(prs, config, metrics, profile, colors, content_top, page_def=None):
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ page_title = page_def.title if page_def and page_def.title else '总结与建议'
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ '{date}': config.period_str,
|
|
|
+ '{page_title}': page_title,
|
|
|
+ '{source}': config.source_label,
|
|
|
+ '{period}': '',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+
|
|
|
+ elem = (page_def.elements or [{}])[0] if page_def else {}
|
|
|
+
|
|
|
+ if elem.get('support_status') is not None:
|
|
|
+ status = elem['support_status']
|
|
|
+ dept = elem.get('support_by_dept', {})
|
|
|
+ sc = elem.get('support_count', 0)
|
|
|
+ cc = elem.get('closed_count', 0)
|
|
|
+ close_rate = round(cc / sc * 100, 1) if sc else 0
|
|
|
+ fully_closed = status.get('已闭环', 0)
|
|
|
+ partial_closed = status.get('部分闭环', 0)
|
|
|
+ not_closed = status.get('未闭环', 0)
|
|
|
+ insight_items = [{
|
|
|
+ 'title': '支持需求总览',
|
|
|
+ 'content': f'本期共产生 {sc} 项跨部门支持需求,其中已闭环 {cc} 项(含完全闭环 {fully_closed} 项、部分闭环 {partial_closed} 项),'
|
|
|
+ f'闭环率 {close_rate}%。未闭环需求 {sc - cc} 项(占比 {round((sc-cc)/sc*100,1) if sc else 0}%),'
|
|
|
+ f'闭环率{"较高,跨部门协作效率良好" if close_rate >= 60 else "处于中等水平,仍有提升空间" if close_rate >= 30 else "偏低,需重点关注闭环推动"}。'
|
|
|
+ f'跨部门支持是保障项目推进的重要环节,高效的闭环机制有助于提升客户满意度和订单转化效率。',
|
|
|
+ }]
|
|
|
+ if status:
|
|
|
+ total_status = sum(status.values())
|
|
|
+ fully_pct = round(fully_closed / total_status * 100, 1) if total_status else 0
|
|
|
+ partial_pct = round(partial_closed / total_status * 100, 1) if total_status else 0
|
|
|
+ not_pct = round(not_closed / total_status * 100, 1) if total_status else 0
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '闭环状态明细',
|
|
|
+ 'content': f'已闭环 {fully_closed} 项({fully_pct}%)、部分闭环 {partial_closed} 项({partial_pct}%)、'
|
|
|
+ f'未闭环 {not_closed} 项({not_pct}%)。'
|
|
|
+ f'其中完全闭环占比{"超过七成,闭环质量较高" if fully_pct >= 70 else "处于中等水平" if fully_pct >= 40 else "偏低,需提升闭环完整性"}。'
|
|
|
+ f'部分闭环表明需求已部分满足但未完全解决,需持续跟踪至彻底闭环。',
|
|
|
+ })
|
|
|
+ if dept:
|
|
|
+ dept_top = list(dept.items())[:5]
|
|
|
+ dept_top_sum = sum(v for _, v in dept_top)
|
|
|
+ dept_total = sum(dept.values())
|
|
|
+ dept_str = '、'.join([f'{k}({v}项)' for k, v in dept_top])
|
|
|
+ avg_dept_load = round(dept_total / len(dept), 1) if dept else 0
|
|
|
+ max_dept = dept_top[0]
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '支持部门工作量分布',
|
|
|
+ 'content': f'需求覆盖 {len(dept)} 个部门/科室,前5个部门承接 {dept_top_sum} 项({round(dept_top_sum/dept_total*100,1) if dept_total else 0}%)。'
|
|
|
+ f'Top部门:{dept_str}。其中{max_dept[0]}承接最多({max_dept[1]}项),'
|
|
|
+ f'平均每个部门承接 {avg_dept_load} 项。请关注工作量较大的部门资源分配是否充足,'
|
|
|
+ f'同时识别是否有部门长期未被分配需求(可能表明资源未充分利用)。',
|
|
|
+ })
|
|
|
+ if sc - cc > 0:
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '未闭环需求跟进建议',
|
|
|
+ 'content': f'当前仍有 {sc - cc} 项需求未完成闭环。建议按以下策略推进:第一,按紧急程度和影响范围对未闭环需求进行优先级排序,'
|
|
|
+ f'高优需求指定专人负责限期解决;第二,建立周度闭环跟踪机制,定期更新需求处理进展;'
|
|
|
+ f'第三,对于跨部门协同的复杂需求,建议指定牵头部门统筹协调推进,'
|
|
|
+ f'并建立问题升级机制(当需求超期未解决时自动升级至更高层级协调)。',
|
|
|
+ })
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '闭环效率提升建议',
|
|
|
+ 'content': f'为持续提升支持需求闭环效率,建议:一是建立标准化的需求流转流程,明确各环节责任人和响应时限;'
|
|
|
+ f'二是定期开展闭环案例复盘,提炼最佳实践并在团队内推广;'
|
|
|
+ f'三是建立闭环率考核指标,将闭环时效纳入部门协作评价体系,'
|
|
|
+ f'通过制度保障跨部门协作的效率和质量。',
|
|
|
+ })
|
|
|
+ else:
|
|
|
+ insight_items = generate_generic_insights(profile, metrics)
|
|
|
+
|
|
|
+ zone = get_full_width_zone(content_top)
|
|
|
+ _add_structured_insight(slide, insight_items,
|
|
|
+ Emu(zone.x), Emu(zone.y),
|
|
|
+ Emu(zone.width), Emu(zone.height))
|
|
|
+
|
|
|
+
|
|
|
+def _build_end_page(prs, config, colors):
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[3] if len(prs.slides) > 3 else prs.slides[0])
|
|
|
+ total = len([p for p in config.pages if p.selected])
|
|
|
+ _add_footer_if_missing(slide, f'数据来源:{config.source_label} | {total}/{total}')
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': config.title,
|
|
|
+ })
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
# DAILY REPORT
|
|
|
# ==============================================================================
|
|
|
|