|
|
@@ -85,6 +85,28 @@ def get_master_template(report_type: str) -> str:
|
|
|
raise FileNotFoundError(f"Master template not found for {report_type}")
|
|
|
|
|
|
|
|
|
+def _resolve_master_template(config: ReportConfig) -> str:
|
|
|
+ if getattr(config, 'template_path', ''):
|
|
|
+ return os.path.abspath(config.template_path)
|
|
|
+ period_type = getattr(config, 'period_type', None)
|
|
|
+ report_type = getattr(period_type, 'value', period_type) or 'daily'
|
|
|
+ return get_master_template(report_type)
|
|
|
+
|
|
|
+
|
|
|
+def _is_forecast_page_type(page_type: str) -> bool:
|
|
|
+ normalized = str(page_type or '').lower()
|
|
|
+ return normalized in {
|
|
|
+ 'forecast',
|
|
|
+ 'prediction',
|
|
|
+ 'plan',
|
|
|
+ 'monthly_forecast',
|
|
|
+ 'monthly_plan',
|
|
|
+ 'next_month_plan',
|
|
|
+ 'custom_forecast',
|
|
|
+ 'custom_prediction',
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
def _detect_content_top(slide) -> int:
|
|
|
"""Detect content start Y from a content slide template by reading {page_title} position."""
|
|
|
page_title_bottom = Emu(1422400) # daily default
|
|
|
@@ -117,12 +139,17 @@ def _duplicate_slide(prs, source_slide):
|
|
|
|
|
|
|
|
|
def _replace_placeholder(slide, placeholder, new_text):
|
|
|
+ replacement = (
|
|
|
+ _format_kpi_value_for_placeholder(new_text)
|
|
|
+ if re_module.fullmatch(r'\{kpi\d+_value\}', placeholder)
|
|
|
+ else str(new_text)
|
|
|
+ )
|
|
|
for shape in slide.shapes:
|
|
|
if not shape.has_text_frame:
|
|
|
continue
|
|
|
for para in shape.text_frame.paragraphs:
|
|
|
if placeholder in para.text:
|
|
|
- para.text = para.text.replace(placeholder, str(new_text))
|
|
|
+ para.text = para.text.replace(placeholder, replacement)
|
|
|
for run in para.runs:
|
|
|
run.font.name = '微软雅黑'
|
|
|
|
|
|
@@ -138,6 +165,13 @@ def _remove_shape(shape):
|
|
|
el.getparent().remove(el)
|
|
|
|
|
|
|
|
|
+def _safe_auto_shape_type(shape):
|
|
|
+ try:
|
|
|
+ return shape.auto_shape_type
|
|
|
+ except (AttributeError, ValueError):
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
def _remove_empty_cover_kpi_placeholders(slide):
|
|
|
"""
|
|
|
Remove template KPI cards when generic cover data does not provide values.
|
|
|
@@ -170,7 +204,7 @@ def _remove_empty_cover_kpi_placeholders(slide):
|
|
|
is_text_placeholder = shape in placeholder_shapes
|
|
|
is_empty_kpi_card = (
|
|
|
in_region and
|
|
|
- getattr(shape, 'auto_shape_type', None) == MSO_SHAPE.ROUNDED_RECTANGLE
|
|
|
+ _safe_auto_shape_type(shape) == MSO_SHAPE.ROUNDED_RECTANGLE
|
|
|
)
|
|
|
if is_text_placeholder or is_empty_kpi_card:
|
|
|
to_remove.append(shape)
|
|
|
@@ -298,6 +332,66 @@ def _add_kpi_cards(slide, kpis, start_x=Emu(762000), start_y=Emu(1651000)):
|
|
|
p.alignment = PP_ALIGN.CENTER
|
|
|
|
|
|
|
|
|
+def _add_compact_kpi_cards(slide, kpis, start_x=Emu(CONTENT_LEFT), start_y=Emu(1651000),
|
|
|
+ max_cols=3, card_h=Emu(1780000), gap_x=Emu(254000),
|
|
|
+ gap_y=Emu(254000)):
|
|
|
+ """Draw compact KPI cards so generic overview pages preserve room for insight text."""
|
|
|
+ if not kpis:
|
|
|
+ return 0
|
|
|
+
|
|
|
+ content_w = SLIDE_WIDTH - 2 * CONTENT_LEFT
|
|
|
+ cols = min(max_cols, max(1, len(kpis)))
|
|
|
+ card_w = int((content_w - (cols - 1) * int(gap_x)) / cols)
|
|
|
+ rows = (len(kpis) + cols - 1) // cols
|
|
|
+
|
|
|
+ for i, kpi in enumerate(kpis):
|
|
|
+ row = i // cols
|
|
|
+ col = i % cols
|
|
|
+ x = int(start_x) + col * (card_w + int(gap_x))
|
|
|
+ y = int(start_y) + row * (int(card_h) + int(gap_y))
|
|
|
+
|
|
|
+ card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Emu(x), Emu(y), Emu(card_w), card_h)
|
|
|
+ card.fill.solid()
|
|
|
+ card.fill.fore_color.rgb = C_CARD_BG
|
|
|
+ card.line.fill.background()
|
|
|
+
|
|
|
+ label = _truncate_text(kpi.get('label', ''), 14)
|
|
|
+ lbl = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 180000), Emu(card_w - 560000), Emu(330000))
|
|
|
+ p = lbl.text_frame.paragraphs[0]
|
|
|
+ p.text = label
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ value = _truncate_text(str(kpi.get('value', '')), 16)
|
|
|
+ val = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 570000), Emu(card_w - 1000000), Emu(560000))
|
|
|
+ p = val.text_frame.paragraphs[0]
|
|
|
+ p.text = value
|
|
|
+ p.font.size = Pt(24 if len(value) <= 10 else 20)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_PRIMARY
|
|
|
+ p.font.name = 'Arial'
|
|
|
+
|
|
|
+ unit = kpi.get('unit', '')
|
|
|
+ if unit:
|
|
|
+ ubox = slide.shapes.add_textbox(Emu(x + card_w - 820000), Emu(y + 710000), Emu(540000), Emu(330000))
|
|
|
+ p = ubox.text_frame.paragraphs[0]
|
|
|
+ p.text = _truncate_text(str(unit), 4)
|
|
|
+ p.font.size = Pt(10)
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ sub_text = kpi.get('sub') or kpi.get('change') or '核心指标'
|
|
|
+ sub = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 1230000), Emu(card_w - 560000), Emu(330000))
|
|
|
+ p = sub.text_frame.paragraphs[0]
|
|
|
+ p.text = _truncate_text(str(sub_text), 24)
|
|
|
+ p.font.size = Pt(9)
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ return int(start_y) + rows * int(card_h) + (rows - 1) * int(gap_y)
|
|
|
+
|
|
|
+
|
|
|
# ==============================================================================
|
|
|
# TEXT BLOCKS
|
|
|
# ==============================================================================
|
|
|
@@ -412,6 +506,55 @@ def _add_structured_insight(slide, items, left, top, width, height,
|
|
|
p2.space_before = Pt(1)
|
|
|
|
|
|
|
|
|
+def _ensure_min_insight_items(items, profile=None, metrics=None, min_count=2,
|
|
|
+ context_label='本页'):
|
|
|
+ """Guarantee enough long-form insight blocks for quality self-check."""
|
|
|
+ cleaned = []
|
|
|
+ for item in items or []:
|
|
|
+ title = str(item.get('title', '')).strip()
|
|
|
+ content = str(item.get('content', '')).strip()
|
|
|
+ if title or content:
|
|
|
+ cleaned.append({'title': title or '分析说明', 'content': content})
|
|
|
+
|
|
|
+ profile = profile or {}
|
|
|
+ metrics = metrics or {}
|
|
|
+ total_rows = profile.get('total_rows', 0)
|
|
|
+ numeric_count = len(profile.get('numeric_columns', []) or [])
|
|
|
+ category_count = len(profile.get('category_columns', []) or [])
|
|
|
+
|
|
|
+ fallback_pool = [
|
|
|
+ {
|
|
|
+ 'title': f'{context_label}数据基础',
|
|
|
+ 'content': f'本页基于当前数据画像进行归纳,覆盖 {total_rows or "若干"} 条记录、'
|
|
|
+ f'{numeric_count} 个数值指标和 {category_count} 个分类维度。'
|
|
|
+ f'当原始数据字段较少或业务指标尚未形成充分拆解时,报告优先呈现已经确认的核心指标,'
|
|
|
+ f'并将可验证的数据范围、维度覆盖和后续分析口径写入页面,避免出现空白页或模板占位内容。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': f'{context_label}行动建议',
|
|
|
+ 'content': f'建议围绕已确认的核心指标建立持续跟踪机制:先核对指标口径与数据字段映射,'
|
|
|
+ f'再按时间、区域、部门或客户等维度拆解异常变化,最后将发现转化为责任人、截止时间和复盘频率明确的行动项。'
|
|
|
+ f'如果后续补充历史同期或目标值数据,可进一步增加同比、环比和达成率判断。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': f'{context_label}风险提示',
|
|
|
+ 'content': f'若数据源存在缺失值、合并表头、人工备注列或统计口径变化,自动生成的结论需要结合业务确认进行复核。'
|
|
|
+ f'建议在报告发布前重点检查核心指标是否全部出现、图表数值是否与原表一致、长文本是否仍在页面安全区域内,'
|
|
|
+ f'以保证美观度和决策可信度同时达标。',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+
|
|
|
+ used_titles = {item['title'] for item in cleaned}
|
|
|
+ for fallback in fallback_pool:
|
|
|
+ if len(cleaned) >= min_count:
|
|
|
+ break
|
|
|
+ if fallback['title'] not in used_titles:
|
|
|
+ cleaned.append(fallback)
|
|
|
+ used_titles.add(fallback['title'])
|
|
|
+
|
|
|
+ return cleaned
|
|
|
+
|
|
|
+
|
|
|
# ==============================================================================
|
|
|
# ALERT / ACTION / ISSUE / GOAL CARDS
|
|
|
# ==============================================================================
|
|
|
@@ -866,6 +1009,29 @@ def _truncate_text(text, max_chars=60):
|
|
|
return text
|
|
|
|
|
|
|
|
|
+def _format_kpi_value_for_placeholder(value, max_chars=16):
|
|
|
+ """
|
|
|
+ KPI value placeholders are fixed-size number slots. If upstream passes a
|
|
|
+ category list, compact it to a count instead of letting it overflow.
|
|
|
+ """
|
|
|
+ if value is None:
|
|
|
+ return ''
|
|
|
+ text = str(value).strip()
|
|
|
+ if len(text) <= max_chars:
|
|
|
+ return text
|
|
|
+
|
|
|
+ list_text = text.strip().strip('[]()(){}')
|
|
|
+ tokens = [
|
|
|
+ token.strip().strip("'\"“”‘’")
|
|
|
+ for token in re_module.split(r'[、,,;;\n/]+', list_text)
|
|
|
+ ]
|
|
|
+ tokens = [token for token in tokens if token]
|
|
|
+ if len(tokens) >= 3:
|
|
|
+ return f'{len(tokens)}项'
|
|
|
+
|
|
|
+ return _truncate_text(text, max_chars)
|
|
|
+
|
|
|
+
|
|
|
def _sentiment_color(text):
|
|
|
"""Return a light background color based on text sentiment."""
|
|
|
if not text:
|
|
|
@@ -973,7 +1139,7 @@ def _safe_div(a, b):
|
|
|
# ==============================================================================
|
|
|
|
|
|
def build_report(data_file: str, config: ReportConfig, output_path: str) -> str:
|
|
|
- master_path = config.template_path or get_master_template('daily')
|
|
|
+ master_path = _resolve_master_template(config)
|
|
|
prs = Presentation(master_path)
|
|
|
|
|
|
df = load_generic_excel(data_file)
|
|
|
@@ -1013,8 +1179,12 @@ def build_report(data_file: str, config: ReportConfig, output_path: str) -> str:
|
|
|
_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 _is_forecast_page_type(page_def.page_type):
|
|
|
+ _build_forecast_page(prs, config, df, profile, metrics, colors, content_top, page_def)
|
|
|
elif page_def.page_type == 'end':
|
|
|
_build_end_page(prs, config, colors)
|
|
|
+ else:
|
|
|
+ raise ValueError(f'不支持的页面类型: {page_def.page_type}(页面: {page_def.title})')
|
|
|
|
|
|
for slide in prs.slides:
|
|
|
_ensure_word_wrap_all(slide)
|
|
|
@@ -1045,7 +1215,7 @@ def quality_assured_build(data_file: str, config: ReportConfig,
|
|
|
|
|
|
def _build_without_save(data_file, temp_config, original_config):
|
|
|
from pptx import Presentation as Prs
|
|
|
- prs = Prs(get_master_template('daily'))
|
|
|
+ prs = Prs(_resolve_master_template(original_config))
|
|
|
df = load_generic_excel(data_file)
|
|
|
profile = original_config.data_profiling or {}
|
|
|
colors = theme_to_rgb_colors(original_config.theme)
|
|
|
@@ -1070,10 +1240,14 @@ def _build_without_save(data_file, temp_config, original_config):
|
|
|
_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 _is_forecast_page_type(page_def.page_type):
|
|
|
+ _build_forecast_page(prs, original_config, df, profile, metrics, 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)
|
|
|
+ else:
|
|
|
+ raise ValueError(f'不支持的页面类型: {page_def.page_type}(页面: {page_def.title})')
|
|
|
|
|
|
for slide in prs.slides:
|
|
|
_ensure_word_wrap_all(slide)
|
|
|
@@ -1260,12 +1434,26 @@ def _build_kpi_overview_page(prs, config, metrics, colors, content_top, df=None,
|
|
|
all_vals[md.label] = val
|
|
|
|
|
|
if kpi_items:
|
|
|
- _add_kpi_cards(slide, kpi_items[:6], start_y=Emu(content_top))
|
|
|
+ kpi_count = len(kpi_items)
|
|
|
+ if kpi_count <= 3:
|
|
|
+ _add_kpi_cards(slide, kpi_items, start_y=Emu(content_top))
|
|
|
+ else:
|
|
|
+ shown_kpis = kpi_items[:9]
|
|
|
+ compact_card_h = Emu(1780000) if len(shown_kpis) <= 6 else Emu(1600000)
|
|
|
+ kpi_bottom = _add_compact_kpi_cards(
|
|
|
+ slide,
|
|
|
+ shown_kpis,
|
|
|
+ start_y=Emu(content_top),
|
|
|
+ card_h=compact_card_h,
|
|
|
+ gap_y=Emu(220000),
|
|
|
+ )
|
|
|
|
|
|
insight_items = []
|
|
|
|
|
|
kpi_names = [m.label for m in config.metrics if m.selected]
|
|
|
kpi_str = "、".join(kpi_names[:6]) if kpi_names else "各指标"
|
|
|
+ if len(kpi_names) > 6:
|
|
|
+ kpi_str += f'等{len(kpi_names)}项'
|
|
|
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]
|
|
|
@@ -1336,18 +1524,38 @@ def _build_kpi_overview_page(prs, config, metrics, colors, content_top, df=None,
|
|
|
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)
|
|
|
+ if kpi_count > 9:
|
|
|
+ extra_names = '、'.join(k['label'] for k in kpi_items[9:15])
|
|
|
+ insight_items.append({
|
|
|
+ 'title': '更多核心指标说明',
|
|
|
+ 'content': f'本页优先展示前 9 个核心指标,其余 {kpi_count - 9} 个指标(如 {extra_names})'
|
|
|
+ f'已纳入综合分析口径。建议在页面结构确认阶段将核心指标按“结果指标、过程指标、风险指标”分组,'
|
|
|
+ f'必要时拆分为多页 KPI 看板,以保证每个指标都有足够的解释空间。',
|
|
|
+ })
|
|
|
+
|
|
|
+ if kpi_count <= 3:
|
|
|
+ kpi_grid_bottom = int(content_top) + Emu(3048000)
|
|
|
+ else:
|
|
|
+ kpi_grid_bottom = max(kpi_bottom, int(content_top) + Emu(1780000))
|
|
|
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]
|
|
|
+ remaining_height = int(FOOTER_TOP - insight_zone_y - Emu(140000))
|
|
|
+ if remaining_height >= Emu(950000):
|
|
|
+ if kpi_count <= 3:
|
|
|
+ compact_items = insight_items[:3]
|
|
|
+ else:
|
|
|
+ compact_items = insight_items[:3] if kpi_count <= 6 else insight_items[:4]
|
|
|
_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))
|
|
|
+ elif kpi_count > 3:
|
|
|
+ fallback_top = max(insight_zone_y, int(FOOTER_TOP) - int(Emu(1250000)))
|
|
|
+ fallback_height = int(FOOTER_TOP - fallback_top - Emu(120000))
|
|
|
+ fallback_items = insight_items[:2]
|
|
|
+ _add_structured_insight(slide, fallback_items,
|
|
|
+ Emu(CONTENT_LEFT), Emu(fallback_top),
|
|
|
+ Emu(SLIDE_WIDTH - 2 * CONTENT_LEFT), Emu(max(fallback_height, Emu(850000))),
|
|
|
+ title_size=Pt(9), body_size=Pt(8), min_body_size=Pt(7))
|
|
|
|
|
|
|
|
|
def _build_trend_page(prs, config, df, profile, colors, content_top):
|
|
|
@@ -1693,6 +1901,14 @@ def _build_summary_page(prs, config, metrics, profile, colors, content_top, page
|
|
|
else:
|
|
|
insight_items = generate_generic_insights(profile, metrics)
|
|
|
|
|
|
+ insight_items = _ensure_min_insight_items(
|
|
|
+ insight_items,
|
|
|
+ profile=profile,
|
|
|
+ metrics=metrics,
|
|
|
+ min_count=2,
|
|
|
+ context_label='总结页',
|
|
|
+ )
|
|
|
+
|
|
|
zone = get_full_width_zone(content_top)
|
|
|
_add_structured_insight(slide, insight_items,
|
|
|
Emu(zone.x), Emu(zone.y),
|
|
|
@@ -1708,6 +1924,141 @@ def _build_end_page(prs, config, colors):
|
|
|
})
|
|
|
|
|
|
|
|
|
+def _find_metric_def_by_column(config, column):
|
|
|
+ for metric in getattr(config, 'metrics', []) or []:
|
|
|
+ if getattr(metric, 'column', None) == column:
|
|
|
+ return metric
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+def _forecast_items_from_page_def(page_def, df, profile, metrics, config):
|
|
|
+ elem = (page_def.elements or [{}])[0] if page_def else {}
|
|
|
+ items = []
|
|
|
+
|
|
|
+ explicit_items = elem.get('forecast_items') or elem.get('goals')
|
|
|
+ if explicit_items:
|
|
|
+ for idx, item in enumerate(explicit_items[:6], 1):
|
|
|
+ title = item.get('title') or item.get('label') or f'预测项{idx}'
|
|
|
+ value = item.get('value') or item.get('number') or item.get('target') or 0
|
|
|
+ items.append({'title': str(title), 'number': value})
|
|
|
+ return items
|
|
|
+
|
|
|
+ metric_names = elem.get('metrics') or elem.get('metric_names') or []
|
|
|
+ for metric_name in metric_names[:6]:
|
|
|
+ if metric_name in metrics:
|
|
|
+ metric_def = next((m for m in getattr(config, 'metrics', []) if m.name == metric_name), None)
|
|
|
+ label = metric_def.label if metric_def else str(metric_name)
|
|
|
+ items.append({'title': label, 'number': metrics.get(metric_name, 0)})
|
|
|
+ if items:
|
|
|
+ return items
|
|
|
+
|
|
|
+ num_cols = profile.get('numeric_columns', []) if profile else []
|
|
|
+ keyword_cols = []
|
|
|
+ keywords = ('预测', 'forecast', '目标', '计划', 'target', 'plan')
|
|
|
+ for col in num_cols:
|
|
|
+ col_name = col.get('column_name', '')
|
|
|
+ label = col.get('inferred_label', col_name)
|
|
|
+ if any(k in str(col_name).lower() or k in str(label).lower() for k in keywords):
|
|
|
+ keyword_cols.append(col)
|
|
|
+
|
|
|
+ for col in keyword_cols[:6]:
|
|
|
+ col_name = col.get('column_name')
|
|
|
+ metric_def = _find_metric_def_by_column(config, col_name)
|
|
|
+ label = metric_def.label if metric_def else col.get('inferred_label', col_name)
|
|
|
+ if metric_def and metric_def.name in metrics:
|
|
|
+ value = metrics.get(metric_def.name, 0)
|
|
|
+ elif col_name in df.columns:
|
|
|
+ series = df[col_name].dropna()
|
|
|
+ value = int(series.sum()) if not series.empty else 0
|
|
|
+ else:
|
|
|
+ value = 0
|
|
|
+ items.append({'title': label, 'number': value})
|
|
|
+
|
|
|
+ return items
|
|
|
+
|
|
|
+
|
|
|
+def _generic_forecast_insights(page_def, forecast_items, profile, metrics):
|
|
|
+ title = page_def.title if page_def else '预测与行动计划'
|
|
|
+ total = sum(float(item.get('number') or 0) for item in forecast_items)
|
|
|
+ item_desc = '、'.join(f"{item['title']} {item.get('number', 0):,.0f}" for item in forecast_items[:5])
|
|
|
+ if forecast_items:
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ 'title': f'{title}目标概览',
|
|
|
+ 'content': f'本页围绕已确认的预测/计划指标展开,当前纳入 {len(forecast_items)} 个量化项,'
|
|
|
+ f'合计规模约 {total:,.0f}。主要项目包括:{item_desc}。'
|
|
|
+ f'这些指标应与本期实际结果、历史同期和资源约束一起判断,避免只看单点预测值。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': '达成路径与风险控制',
|
|
|
+ 'content': f'建议将预测目标拆解为“责任人、关键动作、时间节点、风险预案”四类信息。'
|
|
|
+ f'如果目标值明显高于本期实际表现,应同步确认新增订单、库存、产能、交付或预算等支撑条件;'
|
|
|
+ f'如果目标值低于当前趋势,则需要说明保守假设,防止业务团队误判资源投入强度。',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+
|
|
|
+ total_rows = profile.get('total_rows', 0) if profile else 0
|
|
|
+ return [
|
|
|
+ {
|
|
|
+ 'title': f'{title}口径说明',
|
|
|
+ 'content': f'当前页面未检测到明确的预测或目标数值字段,因此以数据画像和核心指标进行预测口径说明。'
|
|
|
+ f'本期数据覆盖 {total_rows or "若干"} 条记录,建议在六项确认阶段明确预测指标、目标字段和统计口径,'
|
|
|
+ f'例如下月交付、销售目标、库存消化、需求闭环或风险事件数量。',
|
|
|
+ },
|
|
|
+ {
|
|
|
+ 'title': '补充数据建议',
|
|
|
+ 'content': f'为了生成更可靠的预测页,建议在源数据中补充至少一个预测/目标字段,并提供历史实际值用于校准。'
|
|
|
+ f'报告生成后应检查预测值是否与图表一致,文字洞察是否说明关键假设、达成路径和偏差处理机制。',
|
|
|
+ },
|
|
|
+ ]
|
|
|
+
|
|
|
+
|
|
|
+def _build_forecast_page(prs, config, df, profile, metrics, 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}': '',
|
|
|
+ })
|
|
|
+
|
|
|
+ forecast_items = _forecast_items_from_page_def(page_def, df, profile, metrics, config)
|
|
|
+ if not forecast_items and metrics.get('next_month_goals'):
|
|
|
+ forecast_items = [
|
|
|
+ {'title': g['title'].split(':')[0], 'number': g.get('number', 0)}
|
|
|
+ for g in metrics.get('next_month_goals', [])[:6]
|
|
|
+ ]
|
|
|
+
|
|
|
+ chart_zone = get_chart_left_zone(content_top, 0.58)
|
|
|
+ text_zone = get_insight_right_zone(content_top, 0.58)
|
|
|
+ if forecast_items:
|
|
|
+ names = [item['title'] for item in forecast_items[:6]]
|
|
|
+ values = [float(item.get('number') or 0) for item in forecast_items[:6]]
|
|
|
+ add_column_chart(slide, names, values,
|
|
|
+ Emu(chart_zone.x), Emu(chart_zone.y),
|
|
|
+ Emu(chart_zone.width), Emu(min(chart_zone.height, Emu(5100000))),
|
|
|
+ series_name='预测/目标值', color=colors.get('accent', C_ACCENT),
|
|
|
+ category_axis_title='预测项', value_axis_title='数值')
|
|
|
+
|
|
|
+ is_monthly = (
|
|
|
+ getattr(config, 'period_type', None) == PeriodType.MONTHLY or
|
|
|
+ str(getattr(config, 'period_type', '')).lower() == 'monthly'
|
|
|
+ )
|
|
|
+ has_order_monthly_plan = bool(metrics.get('next_month_goals') or metrics.get('forecast_next'))
|
|
|
+ if is_monthly and has_order_monthly_plan:
|
|
|
+ insight_items = generate_deep_insights('monthly', 'monthly_plan', metrics)
|
|
|
+ else:
|
|
|
+ insight_items = []
|
|
|
+ insight_items = _generic_forecast_insights(page_def, forecast_items, profile, metrics) if not insight_items else insight_items
|
|
|
+ insight_items = _ensure_min_insight_items(insight_items, profile, metrics, context_label='预测页')
|
|
|
+ _add_structured_insight(slide, insight_items,
|
|
|
+ Emu(text_zone.x), Emu(text_zone.y),
|
|
|
+ Emu(text_zone.width), Emu(text_zone.height))
|
|
|
+
|
|
|
+
|
|
|
# ==============================================================================
|
|
|
# DAILY REPORT
|
|
|
# ==============================================================================
|