|
|
@@ -0,0 +1,1875 @@
|
|
|
+"""
|
|
|
+PPT builder: assemble daily/weekly/monthly reports by duplicating master templates
|
|
|
+and filling charts, tables, KPI cards, and structured insight text blocks.
|
|
|
+
|
|
|
+Key design principle: Conclusion-first page titles + structured multi-paragraph
|
|
|
+insights (title + body per paragraph) aligned with reference PPT style.
|
|
|
+"""
|
|
|
+import copy
|
|
|
+import os
|
|
|
+import sys
|
|
|
+from pathlib import Path
|
|
|
+from datetime import datetime, timedelta
|
|
|
+
|
|
|
+sys.path.insert(0, str(Path(__file__).parent))
|
|
|
+
|
|
|
+from pptx import Presentation
|
|
|
+from pptx.util import Emu, Pt
|
|
|
+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 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
|
|
|
+)
|
|
|
+
|
|
|
+# Colors — aligned with reference design theme YAML
|
|
|
+C_PRIMARY = RGBColor(0x1E, 0x3A, 0x5F)
|
|
|
+C_ACCENT = RGBColor(0x10, 0xB9, 0x81)
|
|
|
+C_ACCENT_NEG = RGBColor(0xEF, 0x44, 0x44)
|
|
|
+C_SECONDARY = RGBColor(0x64, 0x74, 0x8B)
|
|
|
+C_DARK = RGBColor(0x1F, 0x3A, 0x5C)
|
|
|
+C_WHITE = RGBColor(0xFF, 0xFF, 0xFF)
|
|
|
+C_GRAY_BG = RGBColor(0xF2, 0xF2, 0xF2)
|
|
|
+C_TEXT = RGBColor(0x33, 0x33, 0x33)
|
|
|
+C_TEXT_GRAY = RGBColor(0x66, 0x66, 0x66)
|
|
|
+C_LINE = RGBColor(0xD9, 0xD9, 0xD9)
|
|
|
+C_CARD_BG = RGBColor(0xE7, 0xF0, 0xF7)
|
|
|
+C_GREEN = RGBColor(0x10, 0xB9, 0x81)
|
|
|
+C_RED = RGBColor(0xEF, 0x44, 0x44)
|
|
|
+C_ORANGE = RGBColor(0xED, 0x7D, 0x31)
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# MASTER / SLIDE HELPERS
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def get_master_template(report_type: str) -> str:
|
|
|
+ """Route report type to corresponding master template."""
|
|
|
+ base = os.path.join(os.path.dirname(__file__), '..', 'assets')
|
|
|
+ template_map = {
|
|
|
+ 'daily': os.path.join(base, 'report-master.pptx'),
|
|
|
+ 'weekly': os.path.join(base, 'weekly-master.pptx'),
|
|
|
+ 'monthly': os.path.join(base, 'monthly-master.pptx'),
|
|
|
+ }
|
|
|
+ path = template_map.get(report_type, template_map['daily'])
|
|
|
+ if os.path.exists(path):
|
|
|
+ return os.path.abspath(path)
|
|
|
+ # Fallbacks
|
|
|
+ for fallback in [template_map['daily']]:
|
|
|
+ if os.path.exists(fallback):
|
|
|
+ return os.path.abspath(fallback)
|
|
|
+ raise FileNotFoundError(f"Master template not found for {report_type}")
|
|
|
+
|
|
|
+
|
|
|
+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
|
|
|
+ for shape in slide.shapes:
|
|
|
+ if shape.has_text_frame and '{page_title}' in shape.text_frame.text:
|
|
|
+ page_title_bottom = shape.top + shape.height
|
|
|
+ break
|
|
|
+ # Gap: generous spacing between page title and content to avoid crowding
|
|
|
+ gap = Emu(381000)
|
|
|
+ return int(page_title_bottom) + int(gap)
|
|
|
+
|
|
|
+
|
|
|
+def _delete_template_slides(prs, count=4):
|
|
|
+ for _ in range(count):
|
|
|
+ if len(prs.slides) == 0:
|
|
|
+ break
|
|
|
+ rId = prs.slides._sldIdLst[0].rId
|
|
|
+ prs.part.drop_rel(rId)
|
|
|
+ del prs.slides._sldIdLst[0]
|
|
|
+
|
|
|
+
|
|
|
+def _duplicate_slide(prs, source_slide):
|
|
|
+ blank_layout = prs.slide_layouts[6]
|
|
|
+ new_slide = prs.slides.add_slide(blank_layout)
|
|
|
+ for shape in source_slide.shapes:
|
|
|
+ el = shape.element
|
|
|
+ new_el = copy.deepcopy(el)
|
|
|
+ new_slide.shapes._spTree.insert_element_before(new_el, 'p:extLst')
|
|
|
+ return new_slide
|
|
|
+
|
|
|
+
|
|
|
+def _replace_placeholder(slide, placeholder, 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))
|
|
|
+ for run in para.runs:
|
|
|
+ run.font.name = '微软雅黑'
|
|
|
+
|
|
|
+
|
|
|
+def _replace_all_placeholders(slide, mapping: dict):
|
|
|
+ for placeholder, new_text in mapping.items():
|
|
|
+ _replace_placeholder(slide, placeholder, new_text)
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# NAVIGATION TABS
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def _add_nav_tabs(slide, tabs, active_index=0, slide_width=None,
|
|
|
+ tab_y=Emu(254000), tab_h=Emu(762000), underline_h=Emu(127000)):
|
|
|
+ if slide_width is None:
|
|
|
+ slide_width = slide.shapes._spTree.getparent().getparent().attrib.get('cx')
|
|
|
+ slide_width = Emu(int(slide_width)) if slide_width else Emu(16256000)
|
|
|
+ n = len(tabs)
|
|
|
+ tab_w = Emu(int(slide_width) // n)
|
|
|
+ for i, label in enumerate(tabs):
|
|
|
+ x = Emu(i * int(tab_w))
|
|
|
+ box = slide.shapes.add_textbox(x, tab_y, tab_w, tab_h)
|
|
|
+ p = box.text_frame.paragraphs[0]
|
|
|
+ p.text = label
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+ p.font.color.rgb = C_PRIMARY if i == active_index else C_TEXT_GRAY
|
|
|
+ p.alignment = PP_ALIGN.CENTER
|
|
|
+ if i == active_index:
|
|
|
+ line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, Emu(457200), tab_w, underline_h)
|
|
|
+ line.fill.solid()
|
|
|
+ line.fill.fore_color.rgb = C_PRIMARY
|
|
|
+ line.line.fill.background()
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# KPI CARDS
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def _add_kpi_cards(slide, kpis, start_x=Emu(762000), start_y=Emu(1651000)):
|
|
|
+ """Draw 3x2 KPI card grid. Each kpi: {'label', 'value', 'unit', 'change', 'sub'}"""
|
|
|
+ positions = [
|
|
|
+ (start_x, start_y),
|
|
|
+ (Emu(5778500), start_y),
|
|
|
+ (Emu(10795000), start_y),
|
|
|
+ (start_x, Emu(start_y + 3429000)),
|
|
|
+ (Emu(5778500), Emu(start_y + 3429000)),
|
|
|
+ (Emu(10795000), Emu(start_y + 3429000)),
|
|
|
+ ]
|
|
|
+ for i, kpi in enumerate(kpis[:6]):
|
|
|
+ if i >= len(positions):
|
|
|
+ break
|
|
|
+ x, y = positions[i]
|
|
|
+ w, h = Emu(4699000), Emu(3048000)
|
|
|
+ card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
|
|
|
+ card.fill.solid()
|
|
|
+ card.fill.fore_color.rgb = C_CARD_BG
|
|
|
+ card.line.fill.background()
|
|
|
+
|
|
|
+ # Label
|
|
|
+ lbl = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 228600), Emu(2540000), Emu(406400))
|
|
|
+ p = lbl.text_frame.paragraphs[0]
|
|
|
+ p.text = kpi.get('label', '')
|
|
|
+ p.font.size = Pt(14)
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ # Value
|
|
|
+ val = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 762000), Emu(2540000), Emu(698500))
|
|
|
+ p = val.text_frame.paragraphs[0]
|
|
|
+ p.text = str(kpi.get('value', ''))
|
|
|
+ p.font.size = Pt(36)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_PRIMARY
|
|
|
+ p.font.name = 'Arial'
|
|
|
+
|
|
|
+ # Unit
|
|
|
+ unit = kpi.get('unit', '')
|
|
|
+ if unit:
|
|
|
+ ubox = slide.shapes.add_textbox(Emu(x + 3048000), Emu(y + 1016000), Emu(508000), Emu(381000))
|
|
|
+ p = ubox.text_frame.paragraphs[0]
|
|
|
+ p.text = unit
|
|
|
+ p.font.size = Pt(14)
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ # Change badge
|
|
|
+ chg = kpi.get('change', '')
|
|
|
+ if chg:
|
|
|
+ cbox = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 1778000), Emu(4064000), Emu(304800))
|
|
|
+ p = cbox.text_frame.paragraphs[0]
|
|
|
+ p.text = chg
|
|
|
+ p.font.size = Pt(12)
|
|
|
+ chg_str = str(chg)
|
|
|
+ is_positive = chg_str.startswith('+') or any(k in chg_str for k in ['↑', '提升', '增长', '上调', '增加', '大幅', '好', '突破', '达成', '优化'])
|
|
|
+ is_negative = chg_str.startswith('-') or any(k in chg_str for k in ['↓', '下滑', '下降', '减少', '回落', '滞后', '堆积', '阻塞', '缺口', '延迟'])
|
|
|
+ if is_negative:
|
|
|
+ p.font.color.rgb = C_RED
|
|
|
+ elif is_positive:
|
|
|
+ p.font.color.rgb = C_GREEN
|
|
|
+ else:
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ # Sub note with semantic background color tag (e.g. "日均51笔")
|
|
|
+ sub = kpi.get('sub', '')
|
|
|
+ if sub:
|
|
|
+ sub_text = _truncate_text(sub, 20)
|
|
|
+ tag_color = _sentiment_color(sub_text)
|
|
|
+ tag_x = Emu(x + 508000)
|
|
|
+ tag_y = Emu(y + 2159000)
|
|
|
+ tag_w = Emu(min(len(sub_text) * 220000 + 400000, 3600000))
|
|
|
+ tag_h = Emu(304800)
|
|
|
+ if tag_color:
|
|
|
+ tag_bg = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, tag_x, tag_y, tag_w, tag_h)
|
|
|
+ tag_bg.fill.solid()
|
|
|
+ tag_bg.fill.fore_color.rgb = tag_color
|
|
|
+ tag_bg.line.fill.background()
|
|
|
+ sbox = slide.shapes.add_textbox(tag_x, tag_y, tag_w, tag_h)
|
|
|
+ p = sbox.text_frame.paragraphs[0]
|
|
|
+ p.text = sub_text
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+ p.alignment = PP_ALIGN.CENTER
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# TEXT BLOCKS
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def _add_text_block(slide, title, body, left, top, width, height,
|
|
|
+ title_size=Pt(14), body_size=Pt(11), line_space=Pt(6)):
|
|
|
+ """Single text box with title + body."""
|
|
|
+ box = slide.shapes.add_textbox(left, top, width, height)
|
|
|
+ tf = box.text_frame
|
|
|
+ tf.word_wrap = True
|
|
|
+ p = tf.paragraphs[0]
|
|
|
+ p.text = title
|
|
|
+ p.font.size = title_size
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_PRIMARY if title else C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+ if body:
|
|
|
+ p2 = tf.add_paragraph()
|
|
|
+ p2.text = body
|
|
|
+ p2.font.size = body_size
|
|
|
+ p2.font.color.rgb = C_TEXT
|
|
|
+ p2.font.name = '微软雅黑'
|
|
|
+ p2.space_before = line_space
|
|
|
+ p2.line_spacing = 1.3
|
|
|
+
|
|
|
+
|
|
|
+def _estimate_text_height(items, title_size_pt, body_size_pt, width_emu,
|
|
|
+ line_spacing=1.15, title_extra=1.3):
|
|
|
+ """Estimate rendered text height in EMU for adaptive font sizing."""
|
|
|
+ width_pt = width_emu / 12700.0
|
|
|
+ chars_per_line_body = max(10, int(width_pt / (body_size_pt * 1.15)))
|
|
|
+ chars_per_line_title = max(10, int(width_pt / (title_size_pt * 1.15)))
|
|
|
+ line_height_body = int(body_size_pt * line_spacing * 12700)
|
|
|
+ line_height_title = int(title_size_pt * title_extra * 12700)
|
|
|
+ total = 0
|
|
|
+ for item in items:
|
|
|
+ title = item.get('title', '')
|
|
|
+ content = item.get('content', '')
|
|
|
+ title_lines = max(1, (len(title) + chars_per_line_title - 1) // chars_per_line_title)
|
|
|
+ content_lines = max(1, (len(content) + chars_per_line_body - 1) // chars_per_line_body)
|
|
|
+ total += title_lines * line_height_title + content_lines * line_height_body + int(6 * 12700)
|
|
|
+ return total
|
|
|
+
|
|
|
+
|
|
|
+def _add_structured_insight(slide, items, left, top, width, height,
|
|
|
+ title_size=Pt(12), body_size=Pt(11),
|
|
|
+ max_items=None, min_body_size=Pt(9)):
|
|
|
+ """
|
|
|
+ High-density structured multi-paragraph insight block.
|
|
|
+ items: list of {'title': str, 'content': str}
|
|
|
+ Features:
|
|
|
+ - No truncation; full content rendered
|
|
|
+ - No max_items limit by default (render all)
|
|
|
+ - Auto-shrink body font to fit within height (down to min_body_size)
|
|
|
+ - Compact line spacing (1.15) to maximize density
|
|
|
+ - Each bullet has emoji + bold title + normal body
|
|
|
+ """
|
|
|
+ if not items:
|
|
|
+ return
|
|
|
+
|
|
|
+ # Adaptive font sizing: shrink body_size until it fits
|
|
|
+ target_height = int(height)
|
|
|
+ # title_size/body_size may be EMU integers or Pt objects; normalize to pt
|
|
|
+ _ts = float(title_size) / 12700.0 if float(title_size) > 1000 else float(title_size)
|
|
|
+ _bs = float(body_size) / 12700.0 if float(body_size) > 1000 else float(body_size)
|
|
|
+ _min_bs = float(min_body_size) / 12700.0 if float(min_body_size) > 1000 else float(min_body_size)
|
|
|
+ ts_pt = _ts
|
|
|
+ bs_pt = _bs
|
|
|
+ min_bs_pt = _min_bs
|
|
|
+
|
|
|
+ # Binary-search-like shrink to fit
|
|
|
+ while bs_pt > min_bs_pt:
|
|
|
+ est = _estimate_text_height(items, ts_pt, bs_pt, int(width))
|
|
|
+ if est <= target_height:
|
|
|
+ break
|
|
|
+ bs_pt -= 0.5
|
|
|
+ ts_pt = max(bs_pt + 1, ts_pt - 0.25)
|
|
|
+
|
|
|
+ box = slide.shapes.add_textbox(left, top, width, height)
|
|
|
+ tf = box.text_frame
|
|
|
+ tf.word_wrap = True
|
|
|
+ first = True
|
|
|
+
|
|
|
+ for item in items[:max_items] if max_items else items:
|
|
|
+ if not first:
|
|
|
+ spacer = tf.add_paragraph()
|
|
|
+ spacer.text = ''
|
|
|
+ spacer.space_before = Pt(3)
|
|
|
+ title = item.get('title', '')
|
|
|
+ emoji = _emoji_for_item(title)
|
|
|
+ # Avoid double emoji
|
|
|
+ if emoji and title.startswith(emoji):
|
|
|
+ emoji = ''
|
|
|
+ title_text = f'{emoji} {title}' if emoji else title
|
|
|
+ p = tf.paragraphs[0] if first else tf.add_paragraph()
|
|
|
+ p.text = title_text
|
|
|
+ p.font.size = Pt(ts_pt)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_PRIMARY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+ p.line_spacing = 1.15
|
|
|
+ first = False
|
|
|
+
|
|
|
+ content = item.get('content', '')
|
|
|
+ if content:
|
|
|
+ p2 = tf.add_paragraph()
|
|
|
+ p2.text = content
|
|
|
+ p2.font.size = Pt(bs_pt)
|
|
|
+ p2.font.color.rgb = C_TEXT
|
|
|
+ p2.font.name = '微软雅黑'
|
|
|
+ p2.line_spacing = 1.15
|
|
|
+ p2.space_before = Pt(1)
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# ALERT / ACTION / ISSUE / GOAL CARDS
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def _add_alert_cards(slide, alerts, start_y=Emu(1651000)):
|
|
|
+ """Draw 1-3 alert cards horizontally. Supports 严重/中度/一般 levels."""
|
|
|
+ colors = {'严重': C_RED, '警告': C_ORANGE, '关注': C_PRIMARY, '中度': C_ORANGE, '一般': C_SECONDARY}
|
|
|
+ positions = [Emu(762000), Emu(5778500), Emu(10795000)]
|
|
|
+ for i, alert in enumerate(alerts[:3]):
|
|
|
+ x = positions[i]
|
|
|
+ y = start_y
|
|
|
+ lvl = alert.get('level', '关注')
|
|
|
+ c = colors.get(lvl, C_PRIMARY)
|
|
|
+
|
|
|
+ bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(2286000))
|
|
|
+ bar.fill.solid()
|
|
|
+ bar.fill.fore_color.rgb = c
|
|
|
+ bar.line.fill.background()
|
|
|
+
|
|
|
+ tbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 228600), Emu(4064000), Emu(406400))
|
|
|
+ p = tbox.text_frame.paragraphs[0]
|
|
|
+ p.text = alert.get('title', '')
|
|
|
+ p.font.size = Pt(15)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 762000), Emu(4064000), Emu(1270000))
|
|
|
+ tf = dbox.text_frame
|
|
|
+ tf.word_wrap = True
|
|
|
+ p = tf.paragraphs[0]
|
|
|
+ p.text = alert.get('detail', '')
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.color.rgb = C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+
|
|
|
+def _add_action_cards(slide, actions, start_y=Emu(2540000)):
|
|
|
+ """Draw 3 action cards horizontally."""
|
|
|
+ positions = [Emu(762000), Emu(5778500), Emu(10795000)]
|
|
|
+ for i, act in enumerate(actions[:3]):
|
|
|
+ x = positions[i]
|
|
|
+ y = start_y
|
|
|
+ bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(406400))
|
|
|
+ bar.fill.solid()
|
|
|
+ bar.fill.fore_color.rgb = C_PRIMARY
|
|
|
+ bar.line.fill.background()
|
|
|
+
|
|
|
+ tbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 952500), Emu(4064000), Emu(406400))
|
|
|
+ p = tbox.text_frame.paragraphs[0]
|
|
|
+ p.text = act.get('title', '')
|
|
|
+ p.font.size = Pt(17)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 1524000), Emu(4064000), Emu(3429000))
|
|
|
+ tf = dbox.text_frame
|
|
|
+ tf.word_wrap = True
|
|
|
+ p = tf.paragraphs[0]
|
|
|
+ p.text = act.get('detail', '')
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.color.rgb = C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+ p.line_spacing = 1.3
|
|
|
+
|
|
|
+
|
|
|
+def _add_issue_cards(slide, issues, start_y=Emu(1524000)):
|
|
|
+ """Draw stacked issue cards with severity, title, detail, action."""
|
|
|
+ colors = {'严重': C_RED, '中度': C_ORANGE, '轻度': C_PRIMARY, '一般': C_SECONDARY}
|
|
|
+ for i, issue in enumerate(issues[:3]):
|
|
|
+ x = Emu(762000)
|
|
|
+ y = Emu(int(start_y) + i * (1778000 + 254000))
|
|
|
+ sev = issue.get('severity', '中度')
|
|
|
+ c = colors.get(sev, C_ORANGE)
|
|
|
+
|
|
|
+ bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(1778000))
|
|
|
+ bar.fill.solid()
|
|
|
+ bar.fill.fore_color.rgb = c
|
|
|
+ bar.line.fill.background()
|
|
|
+
|
|
|
+ sbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 228600), Emu(660400), Emu(304800))
|
|
|
+ p = sbox.text_frame.paragraphs[0]
|
|
|
+ p.text = sev
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = c
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ tbox = slide.shapes.add_textbox(Emu(x + 1778000), Emu(y + 228600), Emu(13462000), Emu(355600))
|
|
|
+ p = tbox.text_frame.paragraphs[0]
|
|
|
+ p.text = issue.get('title', '')
|
|
|
+ p.font.size = Pt(13)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 698500), Emu(14224000), Emu(355600))
|
|
|
+ p = dbox.text_frame.paragraphs[0]
|
|
|
+ p.text = issue.get('detail', '')
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.color.rgb = C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ abox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 1193800), Emu(14224000), Emu(609600))
|
|
|
+ tf = abox.text_frame
|
|
|
+ tf.word_wrap = True
|
|
|
+ p = tf.paragraphs[0]
|
|
|
+ p.text = f"建议措施:{issue.get('action', '')}"
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+
|
|
|
+def _add_goal_cards(slide, goals, start_y=Emu(1524000)):
|
|
|
+ """Draw G1-G4 goal cards in 2x2 grid with icon+title+detail."""
|
|
|
+ sy = int(start_y)
|
|
|
+ positions = [
|
|
|
+ (Emu(762000), Emu(sy)),
|
|
|
+ (Emu(8318500), Emu(sy)),
|
|
|
+ (Emu(762000), Emu(sy + 1879600)),
|
|
|
+ (Emu(8318500), Emu(sy + 1879600)),
|
|
|
+ ]
|
|
|
+ icon_chars = ['🎯', '💰', '🚀', '⚡']
|
|
|
+ for i, goal in enumerate(goals[:4]):
|
|
|
+ x, y = positions[i]
|
|
|
+ gid = goal.get('id', f'G{i+1}')
|
|
|
+
|
|
|
+ gbox = slide.shapes.add_textbox(x, Emu(y + 101600), Emu(635000), Emu(355600))
|
|
|
+ p = gbox.text_frame.paragraphs[0]
|
|
|
+ p.text = f"{icon_chars[i % len(icon_chars)]} {gid}"
|
|
|
+ p.font.size = Pt(16)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_PRIMARY
|
|
|
+ p.font.name = 'Arial'
|
|
|
+
|
|
|
+ tbox = slide.shapes.add_textbox(Emu(x + 863600), Emu(y + 101600), Emu(6096000), Emu(355600))
|
|
|
+ p = tbox.text_frame.paragraphs[0]
|
|
|
+ p.text = goal.get('title', '')
|
|
|
+ p.font.size = Pt(14)
|
|
|
+ p.font.bold = True
|
|
|
+ p.font.color.rgb = C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+ dbox = slide.shapes.add_textbox(Emu(x + 228600), Emu(y + 571500), Emu(6731000), Emu(863600))
|
|
|
+ tf = dbox.text_frame
|
|
|
+ tf.word_wrap = True
|
|
|
+ p = tf.paragraphs[0]
|
|
|
+ p.text = goal.get('detail', '')
|
|
|
+ p.font.size = Pt(11)
|
|
|
+ p.font.color.rgb = C_TEXT_GRAY
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+ p.line_spacing = 1.3
|
|
|
+
|
|
|
+
|
|
|
+def _add_summary_text(slide, text, left=Emu(1016000), top=Emu(5435600), width=Emu(14224000), height=Emu(1270000)):
|
|
|
+ box = slide.shapes.add_textbox(left, top, width, height)
|
|
|
+ tf = box.text_frame
|
|
|
+ tf.word_wrap = True
|
|
|
+ p = tf.paragraphs[0]
|
|
|
+ p.text = text
|
|
|
+ p.font.size = Pt(12)
|
|
|
+ p.font.color.rgb = C_TEXT
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+ p.line_spacing = 1.3
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# STRUCTURED INSIGHT GENERATORS
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def _insight_trend_structured(trend_dates, trend_vals, metrics, period_name='近10天'):
|
|
|
+ """Generate structured multi-paragraph trend insight."""
|
|
|
+ items = []
|
|
|
+ if not trend_vals or len(trend_vals) < 2:
|
|
|
+ items.append({'title': '数据概览', 'content': f'{period_name}订单数据平稳,暂无明显波动。'})
|
|
|
+ return items
|
|
|
+
|
|
|
+ peak = max(trend_vals)
|
|
|
+ peak_idx = trend_vals.index(peak)
|
|
|
+ low = min(trend_vals)
|
|
|
+ low_idx = trend_vals.index(low)
|
|
|
+ last = trend_vals[-1]
|
|
|
+ first = trend_vals[0]
|
|
|
+ total = sum(trend_vals)
|
|
|
+ avg = total / len(trend_vals)
|
|
|
+
|
|
|
+ # Paragraph 1: Order scale
|
|
|
+ curr_orders = metrics.get('tracking_orders', last)
|
|
|
+ prev_orders = metrics.get('prev_tracking_orders', 0)
|
|
|
+ order_chg = _pct_val(curr_orders, prev_orders)
|
|
|
+ curr_qty = metrics.get('total_qty', 0)
|
|
|
+ prev_qty = metrics.get('prev_total_qty', 0)
|
|
|
+ qty_chg = _pct_val(curr_qty, prev_qty)
|
|
|
+ avg_size = metrics.get('avg_order_size', 0)
|
|
|
+ prev_avg_size = metrics.get('prev_avg_order_size', 0)
|
|
|
+
|
|
|
+ scale_text = f'今日订单量{curr_orders}单'
|
|
|
+ if prev_orders > 0:
|
|
|
+ diff = curr_orders - prev_orders
|
|
|
+ scale_text += f',较昨日{"增加" if diff >= 0 else "减少"}{abs(diff)}单'
|
|
|
+ if curr_qty > 0:
|
|
|
+ scale_text += f',订单总数量{curr_qty:,}台'
|
|
|
+ if prev_qty > 0:
|
|
|
+ qdiff = curr_qty - prev_qty
|
|
|
+ scale_text += f',较昨日{"增加" if qdiff >= 0 else "减少"}{abs(qdiff)}台'
|
|
|
+ if avg_size > 0:
|
|
|
+ scale_text += f',单笔订单平均规模{avg_size:.0f}台'
|
|
|
+ if prev_avg_size > 0:
|
|
|
+ adiff = avg_size - prev_avg_size
|
|
|
+ if abs(adiff) >= 1:
|
|
|
+ scale_text += f'({"上升" if adiff >= 0 else "下降"}{abs(adiff):.0f}台)'
|
|
|
+ items.append({'title': '订单规模分析', 'content': scale_text})
|
|
|
+
|
|
|
+ # Paragraph 2: Peak fluctuation
|
|
|
+ peak_text = ''
|
|
|
+ if peak == last:
|
|
|
+ peak_text = f'今日达到峰值{peak}单,为{period_name}最高水平。'
|
|
|
+ elif low == last:
|
|
|
+ peak_text = f'今日回落至{last}单,为{period_name}最低水平。'
|
|
|
+ else:
|
|
|
+ peak_text = f'峰值出现在{trend_dates[peak_idx]}({peak}单),低谷在{trend_dates[low_idx]}({low}单)。'
|
|
|
+ # Describe recovery pattern
|
|
|
+ if len(trend_vals) >= 3:
|
|
|
+ recent = trend_vals[-3:]
|
|
|
+ if recent[-1] > recent[-2] and recent[-2] < recent[0]:
|
|
|
+ peak_text += f'连续回落后今日回升至{last}单,呈现反弹态势。'
|
|
|
+ elif recent[-1] < recent[-2]:
|
|
|
+ peak_text += f'近期呈回落趋势,需关注后续走势。'
|
|
|
+ items.append({'title': '峰值波动', 'content': peak_text})
|
|
|
+
|
|
|
+ # Paragraph 3: Activity / update
|
|
|
+ updated = metrics.get('updated_orders', 0)
|
|
|
+ prev_updated = metrics.get('prev_updated_orders', 0)
|
|
|
+ if updated > 0 or prev_updated > 0:
|
|
|
+ act_text = f'今日进度更新{updated}单'
|
|
|
+ if prev_updated > 0:
|
|
|
+ udiff = updated - prev_updated
|
|
|
+ upct = _pct_val(updated, prev_updated)
|
|
|
+ act_text += f',较昨日{"增加" if udiff >= 0 else "减少"}{abs(udiff)}单({upct:+.1f}%)'
|
|
|
+ if abs(upct) > 20:
|
|
|
+ act_text += ',团队活跃度波动较大。' if abs(upct) > 30 else ',团队活跃度有所变化。'
|
|
|
+ else:
|
|
|
+ act_text += ',团队活跃度保持平稳。'
|
|
|
+ else:
|
|
|
+ act_text += '。'
|
|
|
+ items.append({'title': '活跃度分析', 'content': act_text})
|
|
|
+
|
|
|
+ return items
|
|
|
+
|
|
|
+
|
|
|
+def _insight_status_structured(status_dist, prev_status_dist=None):
|
|
|
+ """Generate structured status distribution insight."""
|
|
|
+ items = []
|
|
|
+ total = sum(status_dist.values())
|
|
|
+ if not total:
|
|
|
+ items.append({'title': '状态概览', 'content': '暂无订单状态数据。'})
|
|
|
+ return items
|
|
|
+
|
|
|
+ max_status = max(status_dist.items(), key=lambda x: x[1])
|
|
|
+ max_pct = max_status[1] / total * 100
|
|
|
+
|
|
|
+ # Production share (C+D)
|
|
|
+ prod = status_dist.get('已付订金待生产', 0) + status_dist.get('已生产待付尾款', 0)
|
|
|
+ prod_pct = prod / total * 100
|
|
|
+
|
|
|
+ status_text = f'{max_status[0]}占比最高({max_status[1]}单,{max_pct:.1f}%)'
|
|
|
+ if prod_pct > 0:
|
|
|
+ status_text += f'。生产端(已付订金+已生产)合计{prod}单({prod_pct:.1f}%)'
|
|
|
+ if prod_pct > 30:
|
|
|
+ status_text += ',生产推进力度加大。'
|
|
|
+ else:
|
|
|
+ status_text += '。'
|
|
|
+ items.append({'title': '状态分布特征', 'content': status_text})
|
|
|
+
|
|
|
+ # WoW change
|
|
|
+ if prev_status_dist:
|
|
|
+ changes = []
|
|
|
+ for name, curr in status_dist.items():
|
|
|
+ prev = prev_status_dist.get(name, 0)
|
|
|
+ if prev > 0:
|
|
|
+ chg = _pct_val(curr, prev)
|
|
|
+ if abs(chg) > 5:
|
|
|
+ changes.append(f'{name}{chg:+.1f}%')
|
|
|
+ if changes:
|
|
|
+ items.append({'title': '状态变化(vs 昨日)', 'content': ' | '.join(changes[:4])})
|
|
|
+
|
|
|
+ return items
|
|
|
+
|
|
|
+
|
|
|
+def _insight_region_structured(region_dist):
|
|
|
+ """Generate structured regional insight with top countries."""
|
|
|
+ items = []
|
|
|
+ if not region_dist:
|
|
|
+ items.append({'title': '区域概览', 'content': '暂无区域分布数据。'})
|
|
|
+ return items
|
|
|
+
|
|
|
+ sorted_regions = sorted(region_dist.items(), key=lambda x: -x[1]['qty'])
|
|
|
+ top3 = sorted_regions[:3]
|
|
|
+ total = sum(v['qty'] for v in region_dist.values())
|
|
|
+ top3_pct = sum(v['qty'] for _, v in top3) / total * 100 if total else 0
|
|
|
+
|
|
|
+ top_names = [k for k, _ in top3]
|
|
|
+ items.append({'title': '核心市场', 'content': f'{"、".join(top_names)}三大核心市场合计占比{top3_pct:.1f}%,是海外订单的核心增长引擎。'})
|
|
|
+
|
|
|
+ # Each region detail
|
|
|
+ for name, data in sorted_regions[:5]:
|
|
|
+ top_c = data.get('top_countries', [])
|
|
|
+ top_c_str = '/'.join([c['country'] for c in top_c[:3]]) if top_c else ''
|
|
|
+ change = data.get('change_pct', 0)
|
|
|
+ if change is None:
|
|
|
+ chg_str = ''
|
|
|
+ elif change > 0:
|
|
|
+ chg_str = f'(+{change:.1f}%)'
|
|
|
+ elif change < 0:
|
|
|
+ chg_str = f'({change:.1f}%)'
|
|
|
+ else:
|
|
|
+ chg_str = ''
|
|
|
+ content = f'{data["pct"]:.1f}% | {data["qty"]:,}台'
|
|
|
+ if top_c_str:
|
|
|
+ content += f' | {top_c_str}为主力'
|
|
|
+ if change != 0:
|
|
|
+ content += f' {chg_str}'
|
|
|
+ if change < 0:
|
|
|
+ content += ' | 需关注'
|
|
|
+ items.append({'title': name, 'content': content})
|
|
|
+
|
|
|
+ return items
|
|
|
+
|
|
|
+
|
|
|
+def _insight_team_structured(team, total_qty=0, per_capita=0, countries_covered=0):
|
|
|
+ """Generate structured team performance insight."""
|
|
|
+ items = []
|
|
|
+ if not team:
|
|
|
+ items.append({'title': '团队概览', 'content': '暂无团队绩效数据。'})
|
|
|
+ return items
|
|
|
+
|
|
|
+ n_members = len(team)
|
|
|
+ avg = per_capita or _safe_div(sum(v.get('orders', 0) for v in team.values()), n_members)
|
|
|
+ top = max(team.items(), key=lambda x: x[1].get('orders', 0))
|
|
|
+
|
|
|
+ overview = f'团队共{n_members}人,'
|
|
|
+ if countries_covered:
|
|
|
+ overview += f'覆盖{countries_covered}国,'
|
|
|
+ overview += f'人均追踪{avg:.0f}单。'
|
|
|
+ items.append({'title': '团队概况', 'content': overview})
|
|
|
+
|
|
|
+ # Top performers
|
|
|
+ sorted_team = sorted(team.items(), key=lambda x: -x[1].get('orders', 0))
|
|
|
+ for name, data in sorted_team[:2]:
|
|
|
+ orders = data.get('orders', 0)
|
|
|
+ qty = data.get('qty', 0)
|
|
|
+ comment = '增长主力' if orders > avg * 1.3 else '稳健跟进'
|
|
|
+ content = f'{name} {orders}单'
|
|
|
+ if qty:
|
|
|
+ content += f'/{qty:,}台'
|
|
|
+ content += f' - {comment}'
|
|
|
+ items.append({'title': '领跑者点评', 'content': content})
|
|
|
+
|
|
|
+ # Find laggard
|
|
|
+ low = min(team.items(), key=lambda x: x[1].get('orders', 0))
|
|
|
+ if low[1].get('orders', 0) < avg * 0.7:
|
|
|
+ items.append({'title': '关注提醒', 'content': f'{low[0]}订单量低于团队均值,建议关注产能提升空间。'})
|
|
|
+
|
|
|
+ return items
|
|
|
+
|
|
|
+
|
|
|
+def _insight_stage_structured(stage_analysis, funnel):
|
|
|
+ """Generate structured monthly stage funnel insight."""
|
|
|
+ items = []
|
|
|
+ early = stage_analysis.get('early', {})
|
|
|
+ mid = stage_analysis.get('mid', {})
|
|
|
+ late = stage_analysis.get('late', {})
|
|
|
+
|
|
|
+ a_b = early.get('orders', 0)
|
|
|
+ items.append({
|
|
|
+ 'title': '前期Pipeline充足',
|
|
|
+ 'content': f'合同拟定中(A) + 已锁定待付订金(B)共{a_b}单,占总量的{early.get("pct", 0):.1f}%,后续转化空间充足。'
|
|
|
+ })
|
|
|
+
|
|
|
+ c_d = mid.get('orders', 0)
|
|
|
+ items.append({
|
|
|
+ 'title': '中期生产推进',
|
|
|
+ 'content': f'已付订金待生产(C) + 已生产待付尾款(D)共{c_d}单,占总量的{mid.get("pct", 0):.1f}%。'
|
|
|
+ })
|
|
|
+
|
|
|
+ e_f = late.get('orders', 0)
|
|
|
+ items.append({
|
|
|
+ 'title': '后期交付待加速',
|
|
|
+ 'content': f'已付尾款待发运(E) + 已发运(F)仅{e_f}单,占总量的{late.get("pct", 0):.1f}%。'
|
|
|
+ })
|
|
|
+
|
|
|
+ if early.get('pct', 0) > 40:
|
|
|
+ items.append({
|
|
|
+ 'title': '⚠ 风险提示',
|
|
|
+ 'content': f'近半数订单仍停留在合同拟定阶段,需关注A→B的转化效率,加速合同确认和订金回收。'
|
|
|
+ })
|
|
|
+
|
|
|
+ return items
|
|
|
+
|
|
|
+
|
|
|
+def _insight_top_countries_structured(top_countries_change, total_qty, top_n=6):
|
|
|
+ """Generate structured TOP countries insight."""
|
|
|
+ items = []
|
|
|
+ if not top_countries_change:
|
|
|
+ items.append({'title': '国家概览', 'content': '暂无国家分布数据。'})
|
|
|
+ return items
|
|
|
+
|
|
|
+ sorted_items = sorted(top_countries_change.items(), key=lambda x: -x[1]['qty'])
|
|
|
+ top_list = sorted_items[:top_n]
|
|
|
+ total_top = sum(v['qty'] for _, v in top_list)
|
|
|
+ pct = total_top / total_qty * 100 if total_qty else 0
|
|
|
+
|
|
|
+ items.append({'title': '集中度分析', 'content': f'Top {top_n}目的国合计覆盖{total_top:,}台,占总量的{pct:.1f}%,重点市场集中度高。'})
|
|
|
+
|
|
|
+ for i, (country, data) in enumerate(top_list, 1):
|
|
|
+ chg = data.get('change_pct', 0)
|
|
|
+ comment = ''
|
|
|
+ if chg is None:
|
|
|
+ comment = '新增市场,潜力可期'
|
|
|
+ chg_str = ''
|
|
|
+ elif chg > 30:
|
|
|
+ comment = '本周增长最快市场之一'
|
|
|
+ chg_str = f'({chg:+.1f}%)'
|
|
|
+ elif chg > 10:
|
|
|
+ comment = '持续增长'
|
|
|
+ chg_str = f'({chg:+.1f}%)'
|
|
|
+ elif chg < -10:
|
|
|
+ comment = '虽有下滑但仍高位' if data['qty'] > total_qty * 0.05 else '需关注'
|
|
|
+ chg_str = f'({chg:.1f}%)'
|
|
|
+ elif chg < 0:
|
|
|
+ comment = '小幅回落'
|
|
|
+ chg_str = f'({chg:.1f}%)'
|
|
|
+ else:
|
|
|
+ comment = 'steady增长' if i <= 3 else '新兴市场,潜力可期'
|
|
|
+ chg_str = f'({chg:+.1f}%)' if chg != 0 else ''
|
|
|
+ items.append({'title': f'{i}. {country} {data["qty"]:,}台{chg_str}', 'content': comment})
|
|
|
+
|
|
|
+ return items
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# TEXT / LAYOUT HELPERS
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def _truncate_text(text, max_chars=60):
|
|
|
+ """Truncate text to max_chars, appending '...' if truncated."""
|
|
|
+ if not text:
|
|
|
+ return text
|
|
|
+ if len(text) > max_chars:
|
|
|
+ return text[:max_chars - 1] + '...'
|
|
|
+ return text
|
|
|
+
|
|
|
+
|
|
|
+def _sentiment_color(text):
|
|
|
+ """Return a light background color based on text sentiment."""
|
|
|
+ if not text:
|
|
|
+ return None
|
|
|
+ text = str(text)
|
|
|
+ positive_words = ['提升', '增长', '上调', '增加', '高', '好', '大幅', '冲刺', '领跑', '上升', '扩大', '优化', '改善', '突破', '达成']
|
|
|
+ negative_words = ['下滑', '下降', '减少', '低', '差', '回落', '下滑', '滞后', '堆积', '阻塞', '缺口', '延迟', '超期', '逾期', '风险', '警告']
|
|
|
+ pos_score = sum(1 for w in positive_words if w in text)
|
|
|
+ neg_score = sum(1 for w in negative_words if w in text)
|
|
|
+ if neg_score > pos_score:
|
|
|
+ return RGBColor(0xFE, 0xE2, 0xE2) # light red ~ #EF444420
|
|
|
+ if pos_score > neg_score:
|
|
|
+ return RGBColor(0xD1, 0xFA, 0xE5) # light green ~ #10B98120
|
|
|
+ return None
|
|
|
+
|
|
|
+
|
|
|
+import re
|
|
|
+
|
|
|
+def _emoji_for_item(title):
|
|
|
+ """Return an emoji prefix based on title keywords."""
|
|
|
+ if not title:
|
|
|
+ return '📈'
|
|
|
+ title = str(title)
|
|
|
+ # Skip if title already starts with an emoji
|
|
|
+ if re.match(r'^[\U0001F300-\U0001F9FF\u2600-\u26FF\u2700-\u27BF]', title):
|
|
|
+ return ''
|
|
|
+ if any(k in title for k in ['风险', '警告', '关注', '下滑', '下降', '延迟', '超期', '缺口', '阻塞']):
|
|
|
+ return '⚠️'
|
|
|
+ if any(k in title for k in ['建议', '措施', '行动', '协调', '对接']):
|
|
|
+ return '💡'
|
|
|
+ if any(k in title for k in ['目标', '计划', '冲刺', '展望', '聚焦']):
|
|
|
+ return '🎯'
|
|
|
+ if any(k in title for k in ['增长', '上升', '提升', '峰值', '领跑', '突破', '活跃', '好转']):
|
|
|
+ return '📈'
|
|
|
+ return '💡'
|
|
|
+
|
|
|
+
|
|
|
+def _add_footer_if_missing(slide, footer_text, slide_width=None):
|
|
|
+ """Add a bottom footer bar to slides that don't already have one (Cover, TOC, End)."""
|
|
|
+ if slide_width is None:
|
|
|
+ slide_width = slide.shapes._spTree.getparent().getparent().attrib.get('cx')
|
|
|
+ slide_width = Emu(int(slide_width)) if slide_width else Emu(16256000)
|
|
|
+ # Check if footer already exists
|
|
|
+ has_footer = False
|
|
|
+ for shape in slide.shapes:
|
|
|
+ if shape.has_text_frame and '数据来源' in shape.text_frame.text:
|
|
|
+ has_footer = True
|
|
|
+ break
|
|
|
+ if has_footer:
|
|
|
+ return
|
|
|
+ bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, Emu(8824000), slide_width, Emu(320000))
|
|
|
+ bar.fill.solid()
|
|
|
+ bar.fill.fore_color.rgb = C_PRIMARY
|
|
|
+ bar.line.fill.background()
|
|
|
+ box = slide.shapes.add_textbox(Emu(762000), Emu(8824000), Emu(14000000), Emu(320000))
|
|
|
+ p = box.text_frame.paragraphs[0]
|
|
|
+ p.text = footer_text
|
|
|
+ p.font.size = Pt(10)
|
|
|
+ p.font.color.rgb = C_WHITE
|
|
|
+ p.font.name = '微软雅黑'
|
|
|
+
|
|
|
+
|
|
|
+def _ensure_word_wrap_all(slide):
|
|
|
+ """Enable word_wrap on all text frames in a slide."""
|
|
|
+ for shape in slide.shapes:
|
|
|
+ if shape.has_text_frame:
|
|
|
+ shape.text_frame.word_wrap = True
|
|
|
+ for para in shape.text_frame.paragraphs:
|
|
|
+ for run in para.runs:
|
|
|
+ run.font.name = '微软雅黑'
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# MATH HELPERS
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def _pct_val(curr, prev):
|
|
|
+ if prev and prev != 0:
|
|
|
+ return (curr - prev) / prev * 100
|
|
|
+ return None
|
|
|
+
|
|
|
+def _format_pct(pct, with_sign=True, suffix='%', zero_suffix=''):
|
|
|
+ """Safely format a percentage value. Returns '—' if pct is None."""
|
|
|
+ if pct is None:
|
|
|
+ return '—'
|
|
|
+ sign = '+' if with_sign and pct >= 0 else ''
|
|
|
+ return f"{sign}{pct:.1f}{suffix}{zero_suffix}"
|
|
|
+
|
|
|
+
|
|
|
+def _pct_str(curr, prev):
|
|
|
+ if prev and prev != 0:
|
|
|
+ pct = round((curr - prev) / prev * 100, 1)
|
|
|
+ sign = '+' if pct >= 0 else ''
|
|
|
+ return f"{sign}{pct}% vs 上期"
|
|
|
+ return "—"
|
|
|
+
|
|
|
+
|
|
|
+def _safe_div(a, b):
|
|
|
+ return round(a / b, 1) if b else 0
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# DAILY REPORT
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def build_daily_report(data_file: str, date: datetime, output_path: str,
|
|
|
+ department='海外事业部', source='海外订单日报系统'):
|
|
|
+ master_path = get_master_template('daily')
|
|
|
+ prs = Presentation(master_path)
|
|
|
+ content_top = _detect_content_top(prs.slides[1])
|
|
|
+
|
|
|
+ df = load_daily(data_file, date)
|
|
|
+ prev_date = date - timedelta(days=1)
|
|
|
+ try:
|
|
|
+ prev_df = load_daily(data_file, prev_date)
|
|
|
+ except Exception:
|
|
|
+ prev_df = None
|
|
|
+
|
|
|
+ metrics = calc_daily_metrics(df, prev_df)
|
|
|
+ prev_metrics = calc_daily_metrics(prev_df, None) if prev_df is not None else {}
|
|
|
+ date_str = date.strftime('%Y年%m月%d日')
|
|
|
+ period_str = date.strftime('%Y年%m月%d日')
|
|
|
+
|
|
|
+ # ---- Page 1: Cover ----
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[0])
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': '海外订单数据日报',
|
|
|
+ '{report_type}': '数据日报',
|
|
|
+ '{date}': date_str,
|
|
|
+ '{department}': department,
|
|
|
+ '{period}': period_str,
|
|
|
+ '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
|
+ })
|
|
|
+ _add_footer_if_missing(slide, f'数据来源:{source} | 1/8')
|
|
|
+ cover_kpis = [
|
|
|
+ ('在跟订单', metrics['tracking_orders'], '单',
|
|
|
+ _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))),
|
|
|
+ ('订单总数量', f"{metrics['total_qty']:,}", '台',
|
|
|
+ _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0))),
|
|
|
+ ('今日已更新', metrics['updated_orders'], '单',
|
|
|
+ _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0))),
|
|
|
+ ('支持需求', metrics['support_requests'], '项', '需跨部门协调'),
|
|
|
+ ]
|
|
|
+ for i, (lbl, val, unit, chg) in enumerate(cover_kpis, 1):
|
|
|
+ _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
|
|
|
+ _replace_placeholder(slide, f'{{kpi{i}_value}}', str(val))
|
|
|
+
|
|
|
+ # ---- Page 2: KPI Overview ----
|
|
|
+ s2 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s2, {
|
|
|
+ '{report_title}': '海外订单数据日报',
|
|
|
+ '{date}': date_str,
|
|
|
+ '{page_title}': '今日核心指标概览',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '2/8',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ kpis = [
|
|
|
+ {'label': '在跟订单总数', 'value': metrics['tracking_orders'], 'unit': '单',
|
|
|
+ 'change': _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0)),
|
|
|
+ 'sub': '日均跟踪'},
|
|
|
+ {'label': '订单总数量', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
|
|
|
+ 'change': _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0)),
|
|
|
+ 'sub': '规模稳定'},
|
|
|
+ {'label': '今日已更新', 'value': metrics['updated_orders'], 'unit': '单',
|
|
|
+ 'change': _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0)),
|
|
|
+ 'sub': '团队活跃'},
|
|
|
+ {'label': '下月预测交付', 'value': metrics['forecast_next'], 'unit': '台',
|
|
|
+ 'change': _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0)),
|
|
|
+ 'sub': '交付预期'},
|
|
|
+ {'label': '支持需求总数', 'value': metrics['support_requests'], 'unit': '项',
|
|
|
+ 'change': '需跨部门协调', 'sub': '建议集中处理'},
|
|
|
+ {'label': '已发运订单', 'value': metrics['shipped_orders'], 'unit': '单',
|
|
|
+ 'change': f'共{metrics.get("shipped_orders", 0) * 8}台 | {metrics["shipped_orders"] - metrics.get("prev_shipped_orders", 0)}单 vs 昨日',
|
|
|
+ 'sub': '交付稳步推进'},
|
|
|
+ ]
|
|
|
+ _add_kpi_cards(s2, kpis, start_y=Emu(content_top))
|
|
|
+
|
|
|
+ # ---- Page 3: 10-Day Trend ----
|
|
|
+ s3 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ trend_dates = []
|
|
|
+ trend_vals = []
|
|
|
+ for d_offset in range(-9, 1):
|
|
|
+ d = date + timedelta(days=d_offset)
|
|
|
+ try:
|
|
|
+ tdf = load_daily(data_file, d)
|
|
|
+ trend_dates.append(d.strftime('%m/%d'))
|
|
|
+ trend_vals.append(len(tdf))
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ # Generate conclusion title
|
|
|
+ trend_conclusion = '近10天订单趋势'
|
|
|
+ if len(trend_vals) >= 2:
|
|
|
+ if trend_vals[-1] > trend_vals[-2]:
|
|
|
+ trend_conclusion = '近10天订单趋势:订单规模回升'
|
|
|
+ elif trend_vals[-1] < trend_vals[-2]:
|
|
|
+ trend_conclusion = '近10天订单趋势:订单量继续回落'
|
|
|
+ peak = max(trend_vals)
|
|
|
+ if trend_vals[-1] == peak:
|
|
|
+ trend_conclusion = '近10天订单趋势:今日达到峰值'
|
|
|
+
|
|
|
+ _replace_all_placeholders(s3, {
|
|
|
+ '{report_title}': '海外订单数据日报',
|
|
|
+ '{date}': date_str,
|
|
|
+ '{page_title}': trend_conclusion,
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '3/8',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ if len(trend_dates) >= 2:
|
|
|
+ add_column_chart(s3, trend_dates, trend_vals,
|
|
|
+ Emu(762000), Emu(content_top), Emu(8890000), Emu(5334000),
|
|
|
+ series_name='订单量', color=C_ACCENT,
|
|
|
+ category_axis_title='日期', value_axis_title='订单数')
|
|
|
+ insight_items = generate_deep_insights('daily', 'trend', metrics,
|
|
|
+ prev_metrics=prev_metrics,
|
|
|
+ trend_dates=trend_dates,
|
|
|
+ trend_vals=trend_vals)
|
|
|
+ _add_structured_insight(s3, insight_items,
|
|
|
+ Emu(9906000), Emu(content_top), Emu(4826000), Emu(5334000))
|
|
|
+
|
|
|
+ # ---- Page 4: Status Distribution ----
|
|
|
+ s4 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ status_names = list(metrics['status_dist'].keys())
|
|
|
+ status_vals = list(metrics['status_dist'].values())
|
|
|
+ total_status = sum(status_vals)
|
|
|
+ prod_share = metrics.get('production_share', 0)
|
|
|
+ status_title = '订单状态分布'
|
|
|
+ if prod_share > 0:
|
|
|
+ status_title = f'订单状态分布:生产端占比提升至{prod_share:.1f}%'
|
|
|
+
|
|
|
+ _replace_all_placeholders(s4, {
|
|
|
+ '{report_title}': '海外订单数据日报',
|
|
|
+ '{date}': date_str,
|
|
|
+ '{page_title}': status_title,
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '4/8',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ if status_names and total_status > 0:
|
|
|
+ # Left donut chart: 55% width
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ add_doughnut_chart(s4, status_names, status_vals,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ show_legend=True, show_data_labels=True, show_percent=True,
|
|
|
+ ring_ratio=0.6)
|
|
|
+ # Right side: status change + deep insights (no table to save space for dense text)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+
|
|
|
+ prev_status = prev_metrics.get('status_dist', {})
|
|
|
+ # Status change text: "合同拟定中 -30.8% ↓ | 已付订金待生产 +55.6% ↑"
|
|
|
+ changes = []
|
|
|
+ for name, curr in metrics['status_dist'].items():
|
|
|
+ prev = prev_status.get(name, 0)
|
|
|
+ if prev > 0:
|
|
|
+ chg = _pct_val(curr, prev)
|
|
|
+ if chg is not None:
|
|
|
+ arrow = '↑' if chg >= 0 else '↓'
|
|
|
+ changes.append(f'{name} {_format_pct(chg)}{arrow}')
|
|
|
+ if changes:
|
|
|
+ change_text = ' | '.join(changes[:4])
|
|
|
+ _add_text_block(s4, '状态变化(vs 昨日)', change_text,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(609600),
|
|
|
+ title_size=Pt(12), body_size=Pt(10))
|
|
|
+
|
|
|
+ # Deep insight fills remaining right-side space
|
|
|
+ insight_items = generate_deep_insights('daily', 'status', metrics,
|
|
|
+ prev_status_dist=prev_status)
|
|
|
+ insight_top = Emu(int(content_top) + 685800)
|
|
|
+ insight_height = Emu(5334000 - 685800)
|
|
|
+ _add_structured_insight(s4, insight_items,
|
|
|
+ text_left, insight_top, text_w, insight_height)
|
|
|
+
|
|
|
+ # ---- Page 5: Owner Distribution ----
|
|
|
+ s5 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ owner_names = list(metrics['owner_dist'].keys())[:8]
|
|
|
+ owner_vals = list(metrics['owner_dist'].values())[:8]
|
|
|
+ top_owner = owner_names[0] if owner_names else ''
|
|
|
+ second_owner = owner_names[1] if len(owner_names) > 1 else ''
|
|
|
+ owner_title = '负责人订单分布'
|
|
|
+ if top_owner and second_owner:
|
|
|
+ owner_title = f'负责人订单分布:{top_owner}、{second_owner}领跑'
|
|
|
+
|
|
|
+ _replace_all_placeholders(s5, {
|
|
|
+ '{report_title}': '海外订单数据日报',
|
|
|
+ '{date}': date_str,
|
|
|
+ '{page_title}': owner_title,
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '5/8',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ if owner_names:
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_horizontal_bar_chart(s5, owner_names, owner_vals,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ series_name='订单笔数', color=C_ACCENT, reverse_order=True,
|
|
|
+ value_axis_title='订单笔数')
|
|
|
+ # Deep insight: owner distribution analysis
|
|
|
+ prev_owner_dist = prev_metrics.get('owner_dist', {}) if prev_metrics else {}
|
|
|
+ insight_items = generate_deep_insights('daily', 'owner', metrics,
|
|
|
+ prev_owner_dist=prev_owner_dist)
|
|
|
+ _add_structured_insight(s5, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # ---- Page 6: Country TOP8 ----
|
|
|
+ s6 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ countries = list(metrics['country_top8'].keys())[:8]
|
|
|
+ country_vals = list(metrics['country_top8'].values())[:8]
|
|
|
+ top_country = countries[0] if countries else ''
|
|
|
+ country_title = '目的国家TOP8'
|
|
|
+ if top_country:
|
|
|
+ country_title = f'目的国家TOP8:{top_country}订单量领先'
|
|
|
+
|
|
|
+ _replace_all_placeholders(s6, {
|
|
|
+ '{report_title}': '海外订单数据日报',
|
|
|
+ '{date}': date_str,
|
|
|
+ '{page_title}': country_title,
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '6/8',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ if countries:
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_horizontal_bar_chart(s6, countries, country_vals,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
|
|
|
+ value_axis_title='订单量(台)')
|
|
|
+ # Deep insight: country top8 analysis
|
|
|
+ insight_items = generate_deep_insights('daily', 'country', metrics)
|
|
|
+ _add_structured_insight(s6, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # ---- Page 7: Support Analysis ----
|
|
|
+ s7 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ alerts = metrics['alerts']
|
|
|
+ alert_title = '异常告警'
|
|
|
+ if alerts:
|
|
|
+ severe = [a for a in alerts if a.get('level') == '严重']
|
|
|
+ if severe:
|
|
|
+ alert_title = f'异常告警:{severe[0]["title"]}'
|
|
|
+ else:
|
|
|
+ alert_title = f'异常告警:{alerts[0]["title"]}'
|
|
|
+
|
|
|
+ _replace_all_placeholders(s7, {
|
|
|
+ '{report_title}': '海外订单数据日报',
|
|
|
+ '{date}': date_str,
|
|
|
+ '{page_title}': alert_title,
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '7/8',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+
|
|
|
+ # Unified left-chart + right-insight layout
|
|
|
+ sc = metrics.get('support_categories', {})
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+
|
|
|
+ if sc:
|
|
|
+ sc_names = list(sc.keys())
|
|
|
+ sc_vals = list(sc.values())
|
|
|
+ add_doughnut_chart(s7, sc_names, sc_vals,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ show_legend=True, show_data_labels=True, show_percent=True,
|
|
|
+ ring_ratio=0.6)
|
|
|
+
|
|
|
+ # Deep insight: alerts & support analysis
|
|
|
+ insight_items = generate_deep_insights('daily', 'alert', metrics)
|
|
|
+ _add_structured_insight(s7, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # ---- Page 8: Key Points ----
|
|
|
+ s8 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s8, {
|
|
|
+ '{report_title}': '海外订单数据日报',
|
|
|
+ '{date}': date_str,
|
|
|
+ '{page_title}': '明日工作重点',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '8/8',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+
|
|
|
+ # Left chart: overdue orders horizontal bar (or support categories fallback)
|
|
|
+ overdue = metrics.get('overdue_orders', [])
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+
|
|
|
+ if overdue:
|
|
|
+ o_countries = [o['country'] for o in overdue[:8]]
|
|
|
+ o_days = [o['days'] for o in overdue[:8]]
|
|
|
+ add_horizontal_bar_chart(s8, o_countries, o_days,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ series_name='超期天数', color=C_ORANGE, reverse_order=True,
|
|
|
+ value_axis_title='天数')
|
|
|
+ elif metrics.get('support_categories'):
|
|
|
+ sc = metrics['support_categories']
|
|
|
+ add_horizontal_bar_chart(s8, list(sc.keys()), list(sc.values()),
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ series_name='需求数', color=C_ACCENT, reverse_order=True,
|
|
|
+ value_axis_title='数量')
|
|
|
+
|
|
|
+ # Deep insight: tomorrow's action items
|
|
|
+ action_items = generate_deep_insights('daily', 'action', metrics)
|
|
|
+ _add_structured_insight(s8, action_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ for slide in prs.slides:
|
|
|
+ _ensure_word_wrap_all(slide)
|
|
|
+ _delete_template_slides(prs)
|
|
|
+ prs.save(output_path)
|
|
|
+ print(f"Daily report saved: {output_path}")
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# WEEKLY REPORT
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def build_weekly_report(data_file: str, year: int, week: int, output_path: str,
|
|
|
+ department='海外事业部', source='海外订单日报系统'):
|
|
|
+ master_path = get_master_template('weekly')
|
|
|
+ prs = Presentation(master_path)
|
|
|
+ content_top = _detect_content_top(prs.slides[1])
|
|
|
+
|
|
|
+ df, prev_df = load_weekly(data_file, year, week)
|
|
|
+ metrics = calc_weekly_metrics(df, prev_df)
|
|
|
+
|
|
|
+ period_str = f"{year}年第{week}周"
|
|
|
+ date_range_str = f"{df['_data_date'].min().strftime('%m/%d')} - {df['_data_date'].max().strftime('%m/%d')}"
|
|
|
+
|
|
|
+ # Page 1: Cover
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[0])
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{report_type}': 'Weekly Overseas Order Data Report',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{department}': department,
|
|
|
+ '{period}': date_range_str,
|
|
|
+ '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
|
+ })
|
|
|
+ _add_footer_if_missing(slide, f'数据来源:{source} | 1/9')
|
|
|
+ # Fix {kpi4_label} {kpi4_value} with actual metric (下月预测交付)
|
|
|
+ cover_kpis = [
|
|
|
+ ('跟踪订单笔数', f"{metrics['tracking_orders']:,}", '笔',
|
|
|
+ _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))),
|
|
|
+ ('订单总数量', f"{metrics['total_qty']:,}", '台',
|
|
|
+ _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0))),
|
|
|
+ ('覆盖目的国', f"{metrics['countries']}", '个', '全球布局持续深化'),
|
|
|
+ ('下月预测交付', f"{metrics['forecast_next']:,}", '台',
|
|
|
+ _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0))),
|
|
|
+ ]
|
|
|
+ for i, (lbl, val, unit, chg) in enumerate(cover_kpis, 1):
|
|
|
+ _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
|
|
|
+ _replace_placeholder(slide, f'{{kpi{i}_value}}', str(val))
|
|
|
+
|
|
|
+ nav_labels = ['周汇总', '趋势图', '环比分析', '区域排行', '问题建议', '下周计划']
|
|
|
+
|
|
|
+ # Page 2: Weekly Summary (KPI cards)
|
|
|
+ s2 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ t_chg = _pct_val(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))
|
|
|
+ q_chg = _pct_val(metrics['total_qty'], metrics.get('prev_total_qty', 0))
|
|
|
+ t_chg_str = _format_pct(t_chg)
|
|
|
+ q_chg_str = _format_pct(q_chg)
|
|
|
+ _replace_all_placeholders(s2, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': f"周汇总:跟踪订单环比{t_chg_str},订单总量增长{q_chg_str}",
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '2/9',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s2, nav_labels, active_index=0, slide_width=prs.slide_width)
|
|
|
+ kpis = [
|
|
|
+ {'label': '跟踪订单笔数', 'value': f"{metrics['tracking_orders']:,}", 'unit': '笔',
|
|
|
+ 'change': f'{t_chg_str} vs W1 ({metrics.get("prev_tracking_orders", 0)}笔)' if t_chg is not None else ('新增' if metrics['tracking_orders'] > 0 else '—'),
|
|
|
+ 'sub': f'日均{metrics["avg_daily_orders"]:.0f}笔'},
|
|
|
+ {'label': '订单总数量', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
|
|
|
+ 'change': f'{q_chg_str} vs W1 ({metrics.get("prev_total_qty", 0):,})' if q_chg is not None else ('新增' if metrics['total_qty'] > 0 else '—'),
|
|
|
+ 'sub': f'日均{metrics["avg_daily_qty"]:.0f}台'},
|
|
|
+ {'label': '已发运订单', 'value': metrics['shipped_orders'], 'unit': '笔',
|
|
|
+ 'change': _pct_str(metrics['shipped_orders'], metrics.get('prev_shipped_orders', 0)),
|
|
|
+ 'sub': '环比大幅提升'},
|
|
|
+ {'label': '覆盖目的国', 'value': metrics['countries'], 'unit': '个',
|
|
|
+ 'change': '持平', 'sub': '全球布局持续深化'},
|
|
|
+ {'label': '进度更新订单', 'value': metrics['updated_orders'], 'unit': '笔',
|
|
|
+ 'change': _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0)),
|
|
|
+ 'sub': '推进效率提升'},
|
|
|
+ {'label': '下月预测交付', 'value': f"{metrics['forecast_next']:,}", 'unit': '台',
|
|
|
+ 'change': _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0)),
|
|
|
+ 'sub': '交付预期大幅上调'},
|
|
|
+ ]
|
|
|
+ _add_kpi_cards(s2, kpis, start_y=Emu(content_top))
|
|
|
+
|
|
|
+ # Page 3: 7-Day Trend
|
|
|
+ s3 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ trend = metrics.get('daily_trend', {})
|
|
|
+ trend_title = '7日趋势:订单总量稳步上升'
|
|
|
+ if trend:
|
|
|
+ dates = list(trend.keys())
|
|
|
+ vals = list(trend.values())
|
|
|
+ if vals:
|
|
|
+ peak = max(vals)
|
|
|
+ peak_date = dates[vals.index(peak)]
|
|
|
+ if vals[-1] >= vals[0]:
|
|
|
+ trend_title = f'7日趋势:订单总量稳步上升,峰值出现在{peak_date}'
|
|
|
+ else:
|
|
|
+ trend_title = f'7日趋势:订单量有所波动,峰值出现在{peak_date}'
|
|
|
+ _replace_all_placeholders(s3, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': trend_title,
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '3/9',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s3, nav_labels, active_index=1, slide_width=prs.slide_width)
|
|
|
+ trend = metrics.get('daily_trend', {})
|
|
|
+ if trend:
|
|
|
+ dates = list(trend.keys())
|
|
|
+ vals = list(trend.values())
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_line_chart(s3, dates, vals,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ series_name='订单量', color=C_ACCENT,
|
|
|
+ category_axis_title='日期(MM/DD)', value_axis_title='订单数')
|
|
|
+ peak = max(vals) if vals else 0
|
|
|
+ peak_date = dates[vals.index(peak)] if vals else ''
|
|
|
+ avg = sum(vals) // len(vals) if vals else 0
|
|
|
+ prev_avg = metrics.get('prev_avg_daily_orders', 0)
|
|
|
+ above_days = metrics.get('days_above_prev_avg', 0)
|
|
|
+ total_days = len(vals)
|
|
|
+
|
|
|
+ # Deep insight: weekly trend analysis
|
|
|
+ insight_items = generate_deep_insights('weekly', 'weekly_trend', metrics,
|
|
|
+ trend_dates=dates, trend_vals=vals)
|
|
|
+ _add_structured_insight(s3, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # Page 4: WoW Analysis
|
|
|
+ s4 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s4, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '环比分析:各阶段全面增长,已发运环节增幅最大',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '4/9',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s4, nav_labels, active_index=2, slide_width=prs.slide_width)
|
|
|
+ sw_data = metrics.get('status_wow', {})
|
|
|
+ if sw_data:
|
|
|
+ names = list(sw_data.keys())
|
|
|
+ current_vals = [v['current'] for v in sw_data.values()]
|
|
|
+ previous_vals = [v['previous'] for v in sw_data.values()]
|
|
|
+ # Replace None with 0 for chart data to avoid crashes
|
|
|
+ changes = [v['change_pct'] if v['change_pct'] is not None else 0 for v in sw_data.values()]
|
|
|
+
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+
|
|
|
+ # Grouped bar chart: 本期 vs 上期
|
|
|
+ add_grouped_bar_chart(s4, names,
|
|
|
+ [('本期', current_vals), ('上期', previous_vals)],
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ colors=[C_ACCENT, C_SECONDARY],
|
|
|
+ show_legend=True, show_data_labels=True,
|
|
|
+ category_axis_title='订单状态', value_axis_title='订单数')
|
|
|
+
|
|
|
+ # WoW增幅 labels below chart
|
|
|
+ wow_label_items = []
|
|
|
+ for k, v in sw_data.items():
|
|
|
+ pct_str = _format_pct(v['change_pct'])
|
|
|
+ arrow = '↑' if v['change_pct'] is not None and v['change_pct'] >= 0 else '↓' if v['change_pct'] is not None else ''
|
|
|
+ wow_label_items.append(f'{k} {pct_str}{arrow}')
|
|
|
+ _add_text_block(s4, '环比增幅', ' | '.join(wow_label_items),
|
|
|
+ Emu(762000), Emu(6604000), chart_w, Emu(609600),
|
|
|
+ title_size=Pt(11), body_size=Pt(10))
|
|
|
+
|
|
|
+ # Deep insight: WoW analysis
|
|
|
+ insight_items = generate_deep_insights('weekly', 'weekly_wow', metrics)
|
|
|
+ _add_structured_insight(s4, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(4826000))
|
|
|
+
|
|
|
+ # Page 5: Region Distribution
|
|
|
+ s5 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s5, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '区域分布:中东增速领跑,欧洲为唯一下滑区域',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '5/9',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s5, nav_labels, active_index=3, slide_width=prs.slide_width)
|
|
|
+ rdist = metrics.get('region_dist', {})
|
|
|
+ if rdist:
|
|
|
+ regions = list(rdist.keys())
|
|
|
+ qtys = [v['qty'] for v in rdist.values()]
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_doughnut_chart(s5, regions, qtys,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ show_legend=True, show_data_labels=True, show_percent=True,
|
|
|
+ ring_ratio=0.6)
|
|
|
+ # Deep insight: regional analysis
|
|
|
+ insight_items = generate_deep_insights('weekly', 'weekly_region', metrics)
|
|
|
+ _add_structured_insight(s5, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # Page 6: TOP Countries
|
|
|
+ s6 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s6, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': 'TOP国家排行:科威特344台居首,TOP15贡献70%+总量',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '6/9',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s6, nav_labels, active_index=3, slide_width=prs.slide_width)
|
|
|
+ topc_change = metrics.get('top_countries_change', {})
|
|
|
+ if topc_change:
|
|
|
+ countries = list(topc_change.keys())[:10]
|
|
|
+ vals = [v['qty'] for v in list(topc_change.values())[:10]]
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_horizontal_bar_chart(s6, countries, vals,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
|
|
|
+ value_axis_title='订单量(台)')
|
|
|
+ # Deep insight: top countries analysis
|
|
|
+ insight_items = generate_deep_insights('weekly', 'weekly_country', metrics)
|
|
|
+ _add_structured_insight(s6, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(4826000))
|
|
|
+
|
|
|
+ # Page 7: Team Tracking
|
|
|
+ s7 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ team = metrics.get('team', {})
|
|
|
+ n_members = len(team.get('owners', {})) if team else 0
|
|
|
+ n_growers = sum(1 for v in metrics.get('team_wow', {}).values() if v.get('change', 0) > 0)
|
|
|
+ team_title = f'团队追踪:{n_members}人团队全面覆盖,{n_growers}人实现增长'
|
|
|
+ _replace_all_placeholders(s7, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': team_title,
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '7/9',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s7, nav_labels, active_index=4, slide_width=prs.slide_width)
|
|
|
+ team = metrics.get('team', {})
|
|
|
+ owners = team.get('owners', {})
|
|
|
+ if owners:
|
|
|
+ names = list(owners.keys())
|
|
|
+ vals = list(owners.values())
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_horizontal_bar_chart(s7, names, vals,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ series_name='订单笔数', color=C_ACCENT, reverse_order=True,
|
|
|
+ value_axis_title='订单笔数')
|
|
|
+ # Deep insight: team tracking
|
|
|
+ insight_items = generate_deep_insights('weekly', 'weekly_team', metrics)
|
|
|
+ _add_structured_insight(s7, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(4826000))
|
|
|
+
|
|
|
+ # Page 8: Issues
|
|
|
+ s8 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s8, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '问题识别:系统数据匹配问题为本周首要障碍',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '8/9',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s8, nav_labels, active_index=4, slide_width=prs.slide_width)
|
|
|
+ # Left chart: support request categories
|
|
|
+ sc = metrics.get('support_categories', {})
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ if sc:
|
|
|
+ add_horizontal_bar_chart(s8, list(sc.keys()), list(sc.values()),
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ series_name='需求数', color=C_ORANGE, reverse_order=True,
|
|
|
+ value_axis_title='数量')
|
|
|
+ # Right: deep insight
|
|
|
+ insight_items = generate_deep_insights('weekly', 'weekly_issue', metrics)
|
|
|
+ _add_structured_insight(s8, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # Page 9: Next Week Plan
|
|
|
+ s9 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s9, {
|
|
|
+ '{report_title}': '海外订单数据周报',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '下周计划:聚焦发运交付,冲刺交付目标',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '9/9',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s9, nav_labels, active_index=5, slide_width=prs.slide_width)
|
|
|
+
|
|
|
+ # Left chart: goals as column chart
|
|
|
+ goals = metrics.get('next_week_goals', [])
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ if goals:
|
|
|
+ goal_names = [g['title'].split(':')[0] for g in goals[:4]]
|
|
|
+ goal_nums = [g.get('number', 0) for g in goals[:4]]
|
|
|
+ add_column_chart(s9, goal_names, goal_nums,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ series_name='目标数量', color=C_ACCENT,
|
|
|
+ category_axis_title='目标', value_axis_title='数量')
|
|
|
+
|
|
|
+ # Deep insight: next week plan
|
|
|
+ insight_items = generate_deep_insights('weekly', 'weekly_plan', metrics)
|
|
|
+ _add_structured_insight(s9, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ for slide in prs.slides:
|
|
|
+ _ensure_word_wrap_all(slide)
|
|
|
+ _delete_template_slides(prs)
|
|
|
+ prs.save(output_path)
|
|
|
+ print(f"Weekly report saved: {output_path}")
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# MONTHLY REPORT
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+def build_monthly_report(data_file: str, year: int, month: int, output_path: str,
|
|
|
+ department='海外事业部', source='海外订单日报系统'):
|
|
|
+ master_path = get_master_template('monthly')
|
|
|
+ prs = Presentation(master_path)
|
|
|
+ content_top = _detect_content_top(prs.slides[1])
|
|
|
+
|
|
|
+ df, prev_df, yoy_df = load_monthly(data_file, year, month)
|
|
|
+ metrics = calc_monthly_metrics(df, prev_df, yoy_df)
|
|
|
+
|
|
|
+ period_str = f"{year}年{month}月"
|
|
|
+
|
|
|
+ # Page 1: Cover
|
|
|
+ slide = _duplicate_slide(prs, prs.slides[0])
|
|
|
+ _replace_all_placeholders(slide, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{report_type}': 'Monthly Overseas Order Data Report',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{department}': department,
|
|
|
+ '{period}': period_str,
|
|
|
+ '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
|
+ })
|
|
|
+ _add_footer_if_missing(slide, f'数据来源:{source} | 1/11')
|
|
|
+ cover_kpis = [
|
|
|
+ ('合同总数', f"{metrics['total_contracts']:,}"),
|
|
|
+ ('车辆总数', f"{metrics['total_qty']:,}"),
|
|
|
+ ('目的国家', f"{metrics['countries']}+"),
|
|
|
+ ('负责团队', '9人'),
|
|
|
+ ]
|
|
|
+ for i, (lbl, val) in enumerate(cover_kpis, 1):
|
|
|
+ _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
|
|
|
+ _replace_placeholder(slide, f'{{kpi{i}_value}}', val)
|
|
|
+
|
|
|
+ # Page 2: TOC
|
|
|
+ s_toc = _duplicate_slide(prs, prs.slides[2])
|
|
|
+ _add_footer_if_missing(s_toc, f'数据来源:{source} | 2/11')
|
|
|
+ _replace_all_placeholders(s_toc, {
|
|
|
+ '{chapter1_title}': '月度总览',
|
|
|
+ '{chapter1_desc}': '核心KPI一览:合同总数、车辆规模、新签与发运表现',
|
|
|
+ '{chapter2_title}': '订单状态分析',
|
|
|
+ '{chapter2_desc}': '订单阶段漏斗:从合同拟定到发运的全流程追踪',
|
|
|
+ '{chapter3_title}': '区域与趋势',
|
|
|
+ '{chapter3_desc}': '区域分布、国家排名与30日追踪趋势',
|
|
|
+ '{chapter4_title}': '团队与展望',
|
|
|
+ '{chapter4_desc}': '团队绩效、支持需求与下月工作规划',
|
|
|
+ })
|
|
|
+
|
|
|
+ nav_labels = ['月度总览', '订单状态', '区域趋势', '团队展望']
|
|
|
+
|
|
|
+ # Page 3: Monthly Overview
|
|
|
+ s3 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s3, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': f"{month}月核心指标:累计追踪{metrics['total_contracts']:,}单,覆盖{metrics['total_qty']:,}台车辆",
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '3/11',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s3, nav_labels, active_index=0, slide_width=prs.slide_width)
|
|
|
+ kpis = [
|
|
|
+ {'label': '合同总数', 'value': f"{metrics['total_contracts']:,}", 'unit': '单',
|
|
|
+ 'change': f'日均{metrics["avg_daily_orders"]:.0f}单', 'sub': '覆盖全月'},
|
|
|
+ {'label': '车辆总数', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
|
|
|
+ 'change': f"{metrics['shipped_qty']:,}台已发运", 'sub': '交付持续推进'},
|
|
|
+ {'label': f'{month}月新签', 'value': metrics['new_contracts'], 'unit': '单',
|
|
|
+ 'change': f"{metrics['new_qty']:,}台", 'sub': '新签势头良好'},
|
|
|
+ {'label': '已发运', 'value': metrics['shipped_orders'], 'unit': '单',
|
|
|
+ 'change': f"{metrics['shipped_qty']:,}台", 'sub': '交付稳步推进'},
|
|
|
+ {'label': '目的国', 'value': f"{metrics['countries']}+", 'unit': '个',
|
|
|
+ 'change': '全球市场布局', 'sub': '持续深化'},
|
|
|
+ {'label': '待处理需求', 'value': metrics['support_count'], 'unit': '单',
|
|
|
+ 'change': f"{metrics['support_pct']}%订单涉及", 'sub': '需跨部门协调'},
|
|
|
+ ]
|
|
|
+ _add_kpi_cards(s3, kpis, start_y=Emu(content_top))
|
|
|
+
|
|
|
+ # Monthly overview: KPI cards only, no bottom insight text to avoid overlap with cards
|
|
|
+
|
|
|
+ # Page 4: Status Funnel
|
|
|
+ s4 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s4, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '订单阶段漏斗:合同拟定与生产中订单占主导地位',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '4/11',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s4, nav_labels, active_index=1, slide_width=prs.slide_width)
|
|
|
+ funnel = metrics.get('status_funnel', {})
|
|
|
+ if funnel:
|
|
|
+ names = list(funnel.keys())
|
|
|
+ orders = [v['orders'] for v in funnel.values()]
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_funnel_chart(s4, names, orders,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ show_data_labels=True, show_percent=True)
|
|
|
+ # Deep insight: monthly funnel
|
|
|
+ insight_items = generate_deep_insights('monthly', 'monthly_funnel', metrics)
|
|
|
+ _add_structured_insight(s4, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # Page 5: Region Distribution
|
|
|
+ s5 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s5, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '区域分布:拉美、东南亚、非洲三大市场并驾齐驱',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '5/11',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s5, nav_labels, active_index=2, slide_width=prs.slide_width)
|
|
|
+ rdist = metrics.get('region_dist', {})
|
|
|
+ if rdist:
|
|
|
+ regions = list(rdist.keys())
|
|
|
+ qtys = [v['qty'] for v in rdist.values()]
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_doughnut_chart(s5, regions, qtys,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ show_legend=True, show_data_labels=True, show_percent=True,
|
|
|
+ ring_ratio=0.6)
|
|
|
+ # Deep insight: monthly region
|
|
|
+ insight_items = generate_deep_insights('monthly', 'monthly_region', metrics)
|
|
|
+ _add_structured_insight(s5, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # Page 6: TOP10 Countries
|
|
|
+ s6 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ top10 = metrics.get('top_countries', {})
|
|
|
+ top_country = list(top10.keys())[0] if top10 else ''
|
|
|
+ top_qty = list(top10.values())[0]['qty'] if top10 else 0
|
|
|
+ top10_title = f'Top 10目的国:{top_country}{top_qty:,}台领跑' if top_country else 'Top 10目的国:重点市场领跑'
|
|
|
+ _replace_all_placeholders(s6, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': top10_title,
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '6/11',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s6, nav_labels, active_index=2, slide_width=prs.slide_width)
|
|
|
+ top10 = metrics.get('top_countries', {})
|
|
|
+ if top10:
|
|
|
+ countries = list(top10.keys())
|
|
|
+ qtys = [v['qty'] for v in top10.values()]
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ add_horizontal_bar_chart(s6, countries, qtys,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
|
|
|
+ value_axis_title='订单量(台)')
|
|
|
+ # Deep insight: monthly top countries
|
|
|
+ insight_items = generate_deep_insights('monthly', 'monthly_country', metrics)
|
|
|
+ _add_structured_insight(s6, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(4826000))
|
|
|
+
|
|
|
+ # Page 7: 30-Day Trend
|
|
|
+ s7 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s7, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '30日追踪趋势:下旬订单活跃度显著提升',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '7/11',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s7, nav_labels, active_index=2, slide_width=prs.slide_width)
|
|
|
+ trend = metrics.get('daily_trend', {})
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ if trend:
|
|
|
+ dates = list(trend.keys())
|
|
|
+ vals = list(trend.values())
|
|
|
+ add_line_chart(s7, dates, vals,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(5334000),
|
|
|
+ series_name='订单量', color=C_ACCENT,
|
|
|
+ category_axis_title='日期(MM/DD)', value_axis_title='订单数')
|
|
|
+ tbp = metrics.get('trend_by_period', {})
|
|
|
+ late_change = tbp.get('late_change_pct', 0)
|
|
|
+ late_change_str = _format_pct(late_change, with_sign=True) if late_change is not None else '—'
|
|
|
+
|
|
|
+ # Deep insight: monthly trend
|
|
|
+ insight_items = generate_deep_insights('monthly', 'monthly_trend', metrics)
|
|
|
+ _add_structured_insight(s7, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # Page 8: Team Performance
|
|
|
+ s8 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s8, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '团队绩效:9位负责人均匀分布,多人领跑',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '8/11',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s8, nav_labels, active_index=3, slide_width=prs.slide_width)
|
|
|
+ team = metrics.get('team', {})
|
|
|
+ if team:
|
|
|
+ names = list(team.keys())
|
|
|
+ orders = [v['orders'] for v in team.values()]
|
|
|
+ qtys = [v['qty'] for v in team.values()]
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ # Horizontal bar chart for orders + secondary series for qty
|
|
|
+ add_grouped_bar_chart(s8, names,
|
|
|
+ [('订单数', orders), ('车辆数', qtys)],
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ colors=[C_ACCENT, C_ORANGE],
|
|
|
+ show_legend=True, show_data_labels=True,
|
|
|
+ category_axis_title='负责人', value_axis_title='数量')
|
|
|
+ # Deep insight: monthly team
|
|
|
+ insight_items = generate_deep_insights('monthly', 'monthly_team', metrics)
|
|
|
+ _add_structured_insight(s8, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(4826000))
|
|
|
+
|
|
|
+ # Page 9: Support Analysis
|
|
|
+ s9 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s9, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': '支持需求分析:财务、售后、法务为三大核心诉求',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '9/11',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s9, nav_labels, active_index=3, slide_width=prs.slide_width)
|
|
|
+ sc = metrics.get('support_categories', {})
|
|
|
+ if sc:
|
|
|
+ cats = list(sc.keys())
|
|
|
+ vals = list(sc.values())
|
|
|
+ add_horizontal_bar_chart(s9, cats, vals,
|
|
|
+ Emu(762000), Emu(content_top), Emu(8636000), Emu(5334000),
|
|
|
+ series_name='需求数', color=C_ACCENT, reverse_order=True,
|
|
|
+ value_axis_title='需求数')
|
|
|
+ top_cat = max(sc.items(), key=lambda x: x[1])
|
|
|
+ # Deep insight: monthly support
|
|
|
+ insight_items = generate_deep_insights('monthly', 'monthly_support', metrics)
|
|
|
+ _add_structured_insight(s9, insight_items,
|
|
|
+ Emu(9779000), Emu(content_top), Emu(5715000), Emu(5334000))
|
|
|
+
|
|
|
+ # Page 10: Next Month Plan
|
|
|
+ s10 = _duplicate_slide(prs, prs.slides[1])
|
|
|
+ _replace_all_placeholders(s10, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{page_title}': f'{month+1 if month < 12 else 1}月展望:预测交付{metrics["forecast_next"]}台,重点关注交付转化',
|
|
|
+ '{source}': source,
|
|
|
+ '{period}': '10/11',
|
|
|
+ '{page_num}': '',
|
|
|
+ })
|
|
|
+ _add_nav_tabs(s10, nav_labels, active_index=3, slide_width=prs.slide_width)
|
|
|
+
|
|
|
+ # Left chart: next month goals as column chart
|
|
|
+ goals = metrics.get('next_month_goals', [])
|
|
|
+ chart_w = Emu(int(prs.slide_width) * 0.55)
|
|
|
+ text_left = Emu(int(prs.slide_width) * 0.62)
|
|
|
+ text_w = Emu(int(prs.slide_width) * 0.36)
|
|
|
+ if goals:
|
|
|
+ goal_names = [g['title'].split(':')[0] for g in goals[:5]]
|
|
|
+ goal_nums = [g.get('number', 0) for g in goals[:5]]
|
|
|
+ add_column_chart(s10, goal_names, goal_nums,
|
|
|
+ Emu(762000), Emu(content_top), chart_w, Emu(4826000),
|
|
|
+ series_name='目标数量', color=C_ACCENT,
|
|
|
+ category_axis_title='目标', value_axis_title='数量')
|
|
|
+
|
|
|
+ # Deep insight: monthly plan
|
|
|
+ insight_items = generate_deep_insights('monthly', 'monthly_plan', metrics)
|
|
|
+ _add_structured_insight(s10, insight_items,
|
|
|
+ text_left, Emu(content_top), text_w, Emu(5334000))
|
|
|
+
|
|
|
+ # Page 11: End
|
|
|
+ s_end = _duplicate_slide(prs, prs.slides[3])
|
|
|
+ _add_footer_if_missing(s_end, f'数据来源:{source} | 11/11')
|
|
|
+ _replace_all_placeholders(s_end, {
|
|
|
+ '{report_title}': '海外订单月度数据报告',
|
|
|
+ '{date}': period_str,
|
|
|
+ '{department}': department,
|
|
|
+ })
|
|
|
+ end_kpis = [
|
|
|
+ ('合同总数', f"{metrics['total_contracts']:,}"),
|
|
|
+ ('车辆总数', f"{metrics['total_qty']:,}"),
|
|
|
+ ('目的国家', f"{metrics['countries']}+"),
|
|
|
+ ('团队', '9人'),
|
|
|
+ ]
|
|
|
+ for i, (lbl, val) in enumerate(end_kpis, 1):
|
|
|
+ _replace_placeholder(s_end, f'{{kpi{i}_label}}', lbl)
|
|
|
+ _replace_placeholder(s_end, f'{{kpi{i}_value}}', val)
|
|
|
+
|
|
|
+ for slide in prs.slides:
|
|
|
+ _ensure_word_wrap_all(slide)
|
|
|
+ _delete_template_slides(prs)
|
|
|
+ prs.save(output_path)
|
|
|
+ print(f"Monthly report saved: {output_path}")
|
|
|
+
|
|
|
+
|
|
|
+# ==============================================================================
|
|
|
+# CLI
|
|
|
+# ==============================================================================
|
|
|
+
|
|
|
+if __name__ == '__main__':
|
|
|
+ import sys
|
|
|
+ if len(sys.argv) >= 4:
|
|
|
+ cmd = sys.argv[1]
|
|
|
+ data_file = sys.argv[2]
|
|
|
+ output = sys.argv[3]
|
|
|
+ if cmd == 'daily':
|
|
|
+ d = datetime.strptime(sys.argv[4], '%Y-%m-%d')
|
|
|
+ build_daily_report(data_file, d, output)
|
|
|
+ elif cmd == 'weekly':
|
|
|
+ build_weekly_report(data_file, int(sys.argv[4]), int(sys.argv[5]), output)
|
|
|
+ elif cmd == 'monthly':
|
|
|
+ build_monthly_report(data_file, int(sys.argv[4]), int(sys.argv[5]), output)
|