ppt_builder.py 94 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999
  1. """
  2. PPT builder: assemble daily/weekly/monthly reports by duplicating master templates
  3. and filling charts, tables, KPI cards, and structured insight text blocks.
  4. Key design principle: Conclusion-first page titles + structured multi-paragraph
  5. insights (title + body per paragraph) aligned with reference PPT style.
  6. """
  7. import copy
  8. import os
  9. import sys
  10. import re as re_module
  11. from pathlib import Path
  12. from datetime import datetime, timedelta
  13. sys.path.insert(0, str(Path(__file__).parent))
  14. from pptx import Presentation
  15. from pptx.util import Emu, Pt
  16. from pptx.dml.color import RGBColor
  17. from pptx.enum.text import PP_ALIGN
  18. from pptx.enum.shapes import MSO_SHAPE
  19. from data_loader import load_generic_excel
  20. from metrics_calculator import (
  21. calc_generic_metrics, calc_generic_trend, calc_generic_distribution,
  22. calc_generic_ranking, generate_generic_insights,
  23. )
  24. from chart_factory import (
  25. add_column_chart, add_bar_chart, add_line_chart, add_doughnut_chart,
  26. add_pie_chart, add_funnel_chart, add_horizontal_bar_chart,
  27. add_grouped_bar_chart, add_table
  28. )
  29. from page_layouts import (
  30. get_kpi_grid, get_chart_left_zone, get_insight_right_zone,
  31. get_full_width_zone, get_two_column_zones, LayoutContext,
  32. )
  33. from template_parser import (
  34. parse_template, get_builtin_template_profile,
  35. PLACEHOLDER_ALIASES, _matches_any_placeholder,
  36. )
  37. from quality_inspector import QualityInspector
  38. from theme_manager import theme_to_rgb_colors, get_theme
  39. from report_config import (
  40. ReportConfig, PageDef, MetricDef, PeriodType, ChartType,
  41. validate_six_confirmations,
  42. )
  43. from quality_rules import SLIDE_WIDTH, SLIDE_HEIGHT, CONTENT_LEFT, CONTENT_TOP_BASE, FOOTER_TOP
  44. # Colors — aligned with reference design theme YAML
  45. C_PRIMARY = RGBColor(0x1E, 0x3A, 0x5F)
  46. C_ACCENT = RGBColor(0x10, 0xB9, 0x81)
  47. C_ACCENT_NEG = RGBColor(0xEF, 0x44, 0x44)
  48. C_SECONDARY = RGBColor(0x64, 0x74, 0x8B)
  49. C_DARK = RGBColor(0x1F, 0x3A, 0x5C)
  50. C_WHITE = RGBColor(0xFF, 0xFF, 0xFF)
  51. C_GRAY_BG = RGBColor(0xF2, 0xF2, 0xF2)
  52. C_TEXT = RGBColor(0x33, 0x33, 0x33)
  53. C_TEXT_GRAY = RGBColor(0x66, 0x66, 0x66)
  54. C_LINE = RGBColor(0xD9, 0xD9, 0xD9)
  55. C_CARD_BG = RGBColor(0xE7, 0xF0, 0xF7)
  56. C_GREEN = RGBColor(0x10, 0xB9, 0x81)
  57. C_RED = RGBColor(0xEF, 0x44, 0x44)
  58. C_ORANGE = RGBColor(0xED, 0x7D, 0x31)
  59. # ==============================================================================
  60. # MASTER / SLIDE HELPERS
  61. # ==============================================================================
  62. def get_master_template(report_type: str) -> str:
  63. """Route report type to corresponding master template."""
  64. base = os.path.join(os.path.dirname(__file__), '..', 'assets')
  65. template_map = {
  66. 'daily': os.path.join(base, 'report-master.pptx'),
  67. 'weekly': os.path.join(base, 'weekly-master.pptx'),
  68. 'monthly': os.path.join(base, 'monthly-master.pptx'),
  69. }
  70. path = template_map.get(report_type, template_map['daily'])
  71. if os.path.exists(path):
  72. return os.path.abspath(path)
  73. # Fallbacks
  74. for fallback in [template_map['daily']]:
  75. if os.path.exists(fallback):
  76. return os.path.abspath(fallback)
  77. raise FileNotFoundError(f"Master template not found for {report_type}")
  78. def _resolve_master_template(config: ReportConfig) -> str:
  79. if getattr(config, 'template_path', ''):
  80. return os.path.abspath(config.template_path)
  81. period_type = getattr(config, 'period_type', None)
  82. report_type = getattr(period_type, 'value', period_type) or 'daily'
  83. return get_master_template(report_type)
  84. def _resolve_template_profile(config: ReportConfig):
  85. """Resolve TemplateProfile from config (cached or parse on demand)."""
  86. if getattr(config, 'template_profile', None):
  87. return config.template_profile
  88. if getattr(config, 'template_path', ''):
  89. return parse_template(config.template_path)
  90. period_type = getattr(config, 'period_type', None)
  91. report_type = getattr(period_type, 'value', period_type) or 'daily'
  92. return get_builtin_template_profile(report_type)
  93. def _resolve_colors(config: ReportConfig, profile) -> dict:
  94. """Three-tier color resolution: user theme > template theme > defaults."""
  95. # If user explicitly configured a theme and opted out of template theme
  96. if config.theme and not getattr(config, 'use_template_theme', True):
  97. return theme_to_rgb_colors(config.theme)
  98. # Try template-extracted theme
  99. from theme_manager import extract_theme_from_template, ThemeConfig
  100. template_theme = extract_theme_from_template(profile)
  101. if template_theme:
  102. return theme_to_rgb_colors(template_theme)
  103. # Fallback to user theme or default
  104. if config.theme:
  105. return theme_to_rgb_colors(config.theme)
  106. # Ultimate fallback: hard-coded defaults packaged as a theme
  107. return theme_to_rgb_colors(ThemeConfig())
  108. def _resolve_fonts(config: ReportConfig, profile) -> dict:
  109. """Three-tier font resolution: user config > template fonts > defaults."""
  110. result = {
  111. 'title_font': '微软雅黑',
  112. 'body_font': '微软雅黑',
  113. 'number_font': 'Arial',
  114. }
  115. # Template fonts
  116. detected = getattr(profile, 'detected_fonts', {})
  117. if detected.get('title_font'):
  118. result['title_font'] = detected['title_font']
  119. if detected.get('body_font'):
  120. result['body_font'] = detected['body_font']
  121. if detected.get('number_font'):
  122. result['number_font'] = detected['number_font']
  123. # User override via theme config
  124. if config.theme:
  125. if getattr(config.theme, 'title_font', ''):
  126. result['title_font'] = config.theme.title_font
  127. if getattr(config.theme, 'body_font', ''):
  128. result['body_font'] = config.theme.body_font
  129. if getattr(config.theme, 'number_font', ''):
  130. result['number_font'] = config.theme.number_font
  131. return result
  132. def _duplicate_master_slide(prs, profile, page_type: str):
  133. """Duplicate the appropriate master slide for the given page_type."""
  134. idx = profile.get_master_index_for(page_type)
  135. if 0 <= idx < len(prs.slides):
  136. source = prs.slides[idx]
  137. else:
  138. source = prs.slides[0]
  139. return _duplicate_slide(prs, source)
  140. def _is_forecast_page_type(page_type: str) -> bool:
  141. normalized = str(page_type or '').lower()
  142. return normalized in {
  143. 'forecast',
  144. 'prediction',
  145. 'plan',
  146. 'monthly_forecast',
  147. 'monthly_plan',
  148. 'next_month_plan',
  149. 'custom_forecast',
  150. 'custom_prediction',
  151. }
  152. def _detect_content_top(slide) -> int:
  153. """Detect content start Y from a content slide template by reading {page_title} position."""
  154. page_title_bottom = Emu(1422400) # daily default
  155. for shape in slide.shapes:
  156. if shape.has_text_frame and '{page_title}' in shape.text_frame.text:
  157. page_title_bottom = shape.top + shape.height
  158. break
  159. # Gap: generous spacing between page title and content to avoid crowding
  160. gap = Emu(381000)
  161. return int(page_title_bottom) + int(gap)
  162. def _delete_template_slides(prs, count=None):
  163. """Delete original template slides from the presentation.
  164. count: number of original template slides to remove from the beginning.
  165. If None, auto-detect using a heuristic that looks for unreplaced placeholders.
  166. """
  167. if count is None:
  168. # Auto-detect: count leading slides that contain unreplaced placeholders
  169. # or have only template-specific content patterns.
  170. count = 0
  171. for slide in prs.slides:
  172. has_unreplaced_placeholder = False
  173. has_real_content = False
  174. for shape in slide.shapes:
  175. if shape.has_text_frame:
  176. text = shape.text_frame.text.strip()
  177. if text:
  178. if '{' in text and '}' in text:
  179. has_unreplaced_placeholder = True
  180. else:
  181. # Text like copyright, footer, etc. on template slides
  182. # is not "real content" in the report sense
  183. pass
  184. # If slide has unreplaced placeholders, it's an original template slide
  185. if has_unreplaced_placeholder:
  186. count += 1
  187. else:
  188. # Also check if slide is completely empty (some template slides
  189. # may have no placeholders at all)
  190. if len(slide.shapes) == 0:
  191. count += 1
  192. else:
  193. break
  194. # Ensure we don't delete all slides
  195. actual_count = min(count, len(prs.slides) - 1) if len(prs.slides) > 1 else 0
  196. for _ in range(actual_count):
  197. if len(prs.slides) == 0:
  198. break
  199. rId = prs.slides._sldIdLst[0].rId
  200. prs.part.drop_rel(rId)
  201. del prs.slides._sldIdLst[0]
  202. def _duplicate_slide(prs, source_slide):
  203. # Use last available layout (typically blank) to avoid index errors on custom templates
  204. layout_idx = min(6, len(prs.slide_layouts) - 1)
  205. blank_layout = prs.slide_layouts[layout_idx]
  206. new_slide = prs.slides.add_slide(blank_layout)
  207. # Copy slide background (solid, gradient, image) from source
  208. try:
  209. src_cSld = source_slide._element.cSld
  210. new_cSld = new_slide._element.cSld
  211. if src_cSld.bg is not None:
  212. new_bg = copy.deepcopy(src_cSld.bg)
  213. if new_cSld.bg is not None:
  214. new_cSld.remove(new_cSld.bg)
  215. new_cSld.insert(0, new_bg)
  216. except Exception:
  217. pass
  218. for shape in source_slide.shapes:
  219. el = shape.element
  220. new_el = copy.deepcopy(el)
  221. new_slide.shapes._spTree.insert_element_before(new_el, 'p:extLst')
  222. return new_slide
  223. def _replace_placeholder(slide, placeholder, new_text, fonts: dict = None):
  224. fonts = fonts or {}
  225. body_font = fonts.get('body_font', '微软雅黑')
  226. replacement = (
  227. _format_kpi_value_for_placeholder(new_text)
  228. if re_module.fullmatch(r'\{kpi\d+_value\}', placeholder)
  229. else str(new_text)
  230. )
  231. # Gather aliases for this placeholder
  232. aliases = PLACEHOLDER_ALIASES.get(placeholder, [])
  233. targets = [placeholder] + [a for a in aliases if a != placeholder]
  234. for shape in slide.shapes:
  235. if not shape.has_text_frame:
  236. continue
  237. for para in shape.text_frame.paragraphs:
  238. for target in targets:
  239. if target in para.text:
  240. para.text = para.text.replace(target, replacement)
  241. for run in para.runs:
  242. run.font.name = body_font
  243. break # only replace once per paragraph
  244. def _replace_all_placeholders(slide, mapping: dict, fonts: dict = None):
  245. for placeholder, new_text in mapping.items():
  246. _replace_placeholder(slide, placeholder, new_text, fonts)
  247. def _remove_shape(shape):
  248. """Remove a python-pptx shape from its parent tree."""
  249. el = shape.element
  250. el.getparent().remove(el)
  251. def _remove_slide(prs, slide):
  252. """Remove a slide from a presentation by its rId."""
  253. try:
  254. for i, s in enumerate(prs.slides):
  255. if s == slide:
  256. rId = prs.slides._sldIdLst[i].rId
  257. prs.part.drop_rel(rId)
  258. del prs.slides._sldIdLst[i]
  259. return True
  260. except Exception:
  261. pass
  262. return False
  263. def _safe_auto_shape_type(shape):
  264. try:
  265. return shape.auto_shape_type
  266. except (AttributeError, ValueError):
  267. return None
  268. def _remove_empty_cover_kpi_placeholders(slide):
  269. """
  270. Remove template KPI cards when generic cover data does not provide values.
  271. This prevents empty rounded rectangles from staying on the cover.
  272. """
  273. kpi_pattern = re_module.compile(r'\{kpi\d+_(label|value)\}')
  274. placeholder_shapes = [
  275. shape for shape in slide.shapes
  276. if shape.has_text_frame and kpi_pattern.search(shape.text_frame.text or '')
  277. ]
  278. if not placeholder_shapes:
  279. return
  280. x_min = min(int(shape.left) for shape in placeholder_shapes)
  281. x_max = max(int(shape.left) + int(shape.width) for shape in placeholder_shapes)
  282. y_min = min(int(shape.top) for shape in placeholder_shapes)
  283. y_max = max(int(shape.top) + int(shape.height) for shape in placeholder_shapes)
  284. pad = Emu(220000)
  285. to_remove = []
  286. for shape in slide.shapes:
  287. sx = int(shape.left)
  288. sy = int(shape.top)
  289. sw = int(shape.width)
  290. sh = int(shape.height)
  291. in_region = (
  292. sx >= x_min - pad and sx + sw <= x_max + pad and
  293. sy >= y_min - pad and sy + sh <= y_max + pad
  294. )
  295. is_text_placeholder = shape in placeholder_shapes
  296. is_empty_kpi_card = (
  297. in_region and
  298. _safe_auto_shape_type(shape) == MSO_SHAPE.ROUNDED_RECTANGLE
  299. )
  300. if is_text_placeholder or is_empty_kpi_card:
  301. to_remove.append(shape)
  302. for shape in to_remove:
  303. _remove_shape(shape)
  304. # ==============================================================================
  305. # NAVIGATION TABS
  306. # ==============================================================================
  307. def _add_nav_tabs(slide, tabs, active_index=0, slide_width=None,
  308. fonts=None, colors=None,
  309. tab_y=Emu(254000), tab_h=Emu(762000), underline_h=Emu(127000)):
  310. colors = colors or {}
  311. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  312. C_TEXT_GRAY = colors.get('text_gray', RGBColor(0x66, 0x66, 0x66))
  313. if slide_width is None:
  314. slide_width = slide.shapes._spTree.getparent().getparent().attrib.get('cx')
  315. slide_width = Emu(int(slide_width)) if slide_width else Emu(16256000)
  316. n = len(tabs)
  317. tab_w = Emu(int(slide_width) // n)
  318. for i, label in enumerate(tabs):
  319. x = Emu(i * int(tab_w))
  320. box = slide.shapes.add_textbox(x, tab_y, tab_w, tab_h)
  321. p = box.text_frame.paragraphs[0]
  322. p.text = label
  323. p.font.size = Pt(11)
  324. p.font.name = '微软雅黑'
  325. p.font.color.rgb = C_PRIMARY if i == active_index else C_TEXT_GRAY
  326. p.alignment = PP_ALIGN.CENTER
  327. if i == active_index:
  328. line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, Emu(457200), tab_w, underline_h)
  329. line.fill.solid()
  330. line.fill.fore_color.rgb = C_PRIMARY
  331. line.line.fill.background()
  332. # ==============================================================================
  333. # KPI CARDS
  334. # ==============================================================================
  335. def _add_kpi_cards(slide, kpis, start_x=Emu(762000), start_y=Emu(1651000), fonts=None, colors=None):
  336. fonts = fonts or {}
  337. body_font = fonts.get("body_font", "微软雅黑")
  338. number_font = fonts.get("number_font", "Arial")
  339. colors = colors or {}
  340. C_CARD_BG = colors.get('card_bg', RGBColor(0xE7, 0xF0, 0xF7))
  341. C_TEXT_GRAY = colors.get('text_gray', RGBColor(0x66, 0x66, 0x66))
  342. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  343. positions = [
  344. (start_x, start_y),
  345. (Emu(5778500), start_y),
  346. (Emu(10795000), start_y),
  347. (start_x, Emu(start_y + 3429000)),
  348. (Emu(5778500), Emu(start_y + 3429000)),
  349. (Emu(10795000), Emu(start_y + 3429000)),
  350. ]
  351. for i, kpi in enumerate(kpis[:6]):
  352. if i >= len(positions):
  353. break
  354. x, y = positions[i]
  355. w, h = Emu(4699000), Emu(3048000)
  356. card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
  357. card.fill.solid()
  358. card.fill.fore_color.rgb = C_CARD_BG
  359. card.line.fill.background()
  360. # Label
  361. lbl = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 228600), Emu(2540000), Emu(406400))
  362. p = lbl.text_frame.paragraphs[0]
  363. p.text = kpi.get('label', '')
  364. p.font.size = Pt(14)
  365. p.font.color.rgb = C_TEXT_GRAY
  366. p.font.name = '微软雅黑'
  367. # Value
  368. val = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 762000), Emu(2540000), Emu(698500))
  369. p = val.text_frame.paragraphs[0]
  370. p.text = str(kpi.get('value', ''))
  371. p.font.size = Pt(36)
  372. p.font.bold = True
  373. p.font.color.rgb = C_PRIMARY
  374. p.font.name = 'Arial'
  375. # Unit
  376. unit = kpi.get('unit', '')
  377. if unit:
  378. ubox = slide.shapes.add_textbox(Emu(x + 3048000), Emu(y + 1016000), Emu(508000), Emu(381000))
  379. p = ubox.text_frame.paragraphs[0]
  380. p.text = unit
  381. p.font.size = Pt(14)
  382. p.font.color.rgb = C_TEXT_GRAY
  383. p.font.name = '微软雅黑'
  384. # Change badge
  385. chg = kpi.get('change', '')
  386. if chg:
  387. cbox = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 1778000), Emu(4064000), Emu(304800))
  388. p = cbox.text_frame.paragraphs[0]
  389. p.text = chg
  390. p.font.size = Pt(12)
  391. chg_str = str(chg)
  392. is_positive = chg_str.startswith('+') or any(k in chg_str for k in ['↑', '提升', '增长', '上调', '增加', '大幅', '好', '突破', '达成', '优化'])
  393. is_negative = chg_str.startswith('-') or any(k in chg_str for k in ['↓', '下滑', '下降', '减少', '回落', '滞后', '堆积', '阻塞', '缺口', '延迟'])
  394. if is_negative:
  395. p.font.color.rgb = C_RED
  396. elif is_positive:
  397. p.font.color.rgb = C_GREEN
  398. else:
  399. p.font.color.rgb = C_TEXT_GRAY
  400. p.font.name = '微软雅黑'
  401. # Sub note with semantic background color tag (e.g. "日均51笔")
  402. sub = kpi.get('sub', '')
  403. if sub:
  404. sub_text = _truncate_text(sub, 20)
  405. tag_color = _sentiment_color(sub_text)
  406. tag_x = Emu(x + 508000)
  407. tag_y = Emu(y + 2159000)
  408. tag_w = Emu(min(len(sub_text) * 220000 + 400000, 3600000))
  409. tag_h = Emu(304800)
  410. if tag_color:
  411. tag_bg = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, tag_x, tag_y, tag_w, tag_h)
  412. tag_bg.fill.solid()
  413. tag_bg.fill.fore_color.rgb = tag_color
  414. tag_bg.line.fill.background()
  415. sbox = slide.shapes.add_textbox(tag_x, tag_y, tag_w, tag_h)
  416. p = sbox.text_frame.paragraphs[0]
  417. p.text = sub_text
  418. p.font.size = Pt(11)
  419. p.font.color.rgb = C_TEXT_GRAY
  420. p.font.name = '微软雅黑'
  421. p.alignment = PP_ALIGN.CENTER
  422. def _add_compact_kpi_cards(slide, kpis, start_x=Emu(CONTENT_LEFT), start_y=Emu(1651000),
  423. fonts=None, colors=None,
  424. max_cols=3, card_h=Emu(1780000), gap_x=Emu(254000),
  425. gap_y=Emu(254000)):
  426. colors = colors or {}
  427. C_CARD_BG = colors.get('card_bg', RGBColor(0xE7, 0xF0, 0xF7))
  428. C_TEXT_GRAY = colors.get('text_gray', RGBColor(0x66, 0x66, 0x66))
  429. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  430. """Draw compact KPI cards so generic overview pages preserve room for insight text."""
  431. if not kpis:
  432. return 0
  433. content_w = SLIDE_WIDTH - 2 * CONTENT_LEFT
  434. cols = min(max_cols, max(1, len(kpis)))
  435. card_w = int((content_w - (cols - 1) * int(gap_x)) / cols)
  436. rows = (len(kpis) + cols - 1) // cols
  437. for i, kpi in enumerate(kpis):
  438. row = i // cols
  439. col = i % cols
  440. x = int(start_x) + col * (card_w + int(gap_x))
  441. y = int(start_y) + row * (int(card_h) + int(gap_y))
  442. card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Emu(x), Emu(y), Emu(card_w), card_h)
  443. card.fill.solid()
  444. card.fill.fore_color.rgb = C_CARD_BG
  445. card.line.fill.background()
  446. label = _truncate_text(kpi.get('label', ''), 14)
  447. lbl = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 180000), Emu(card_w - 560000), Emu(330000))
  448. p = lbl.text_frame.paragraphs[0]
  449. p.text = label
  450. p.font.size = Pt(11)
  451. p.font.color.rgb = C_TEXT_GRAY
  452. p.font.name = '微软雅黑'
  453. value = _truncate_text(str(kpi.get('value', '')), 16)
  454. val = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 570000), Emu(card_w - 1000000), Emu(560000))
  455. p = val.text_frame.paragraphs[0]
  456. p.text = value
  457. p.font.size = Pt(24 if len(value) <= 10 else 20)
  458. p.font.bold = True
  459. p.font.color.rgb = C_PRIMARY
  460. p.font.name = 'Arial'
  461. unit = kpi.get('unit', '')
  462. if unit:
  463. ubox = slide.shapes.add_textbox(Emu(x + card_w - 820000), Emu(y + 710000), Emu(540000), Emu(330000))
  464. p = ubox.text_frame.paragraphs[0]
  465. p.text = _truncate_text(str(unit), 4)
  466. p.font.size = Pt(10)
  467. p.font.color.rgb = C_TEXT_GRAY
  468. p.font.name = '微软雅黑'
  469. sub_text = kpi.get('sub') or kpi.get('change') or '核心指标'
  470. sub = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 1230000), Emu(card_w - 560000), Emu(330000))
  471. p = sub.text_frame.paragraphs[0]
  472. p.text = _truncate_text(str(sub_text), 24)
  473. p.font.size = Pt(9)
  474. p.font.color.rgb = C_TEXT_GRAY
  475. p.font.name = '微软雅黑'
  476. return int(start_y) + rows * int(card_h) + (rows - 1) * int(gap_y)
  477. # ==============================================================================
  478. # TEXT BLOCKS
  479. # ==============================================================================
  480. def _add_text_block(slide, title, body, left, top, width, height,
  481. fonts=None, colors=None,
  482. title_size=Pt(14), body_size=Pt(11), line_space=Pt(6)):
  483. colors = colors or {}
  484. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  485. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  486. """Single text box with title + body."""
  487. box = slide.shapes.add_textbox(left, top, width, height)
  488. tf = box.text_frame
  489. tf.word_wrap = True
  490. p = tf.paragraphs[0]
  491. p.text = title
  492. p.font.size = title_size
  493. p.font.bold = True
  494. p.font.color.rgb = C_PRIMARY if title else C_TEXT
  495. p.font.name = '微软雅黑'
  496. if body:
  497. p2 = tf.add_paragraph()
  498. p2.text = body
  499. p2.font.size = body_size
  500. p2.font.color.rgb = C_TEXT
  501. p2.font.name = '微软雅黑'
  502. p2.space_before = line_space
  503. p2.line_spacing = 1.3
  504. def _estimate_text_height(items, title_size_pt, body_size_pt, width_emu,
  505. line_spacing=1.15, title_extra=1.3):
  506. """Estimate rendered text height in EMU for adaptive font sizing."""
  507. width_pt = width_emu / 12700.0
  508. chars_per_line_body = max(10, int(width_pt / (body_size_pt * 1.15)))
  509. chars_per_line_title = max(10, int(width_pt / (title_size_pt * 1.15)))
  510. line_height_body = int(body_size_pt * line_spacing * 12700)
  511. line_height_title = int(title_size_pt * title_extra * 12700)
  512. total = 0
  513. for item in items:
  514. title = item.get('title', '')
  515. content = item.get('content', '')
  516. title_lines = max(1, (len(title) + chars_per_line_title - 1) // chars_per_line_title)
  517. content_lines = max(1, (len(content) + chars_per_line_body - 1) // chars_per_line_body)
  518. total += title_lines * line_height_title + content_lines * line_height_body + int(6 * 12700)
  519. return total
  520. def _add_structured_insight(slide, items, left, top, width, height,
  521. fonts=None, colors=None,
  522. title_size=Pt(12), body_size=Pt(11),
  523. max_items=None, min_body_size=Pt(9)):
  524. colors = colors or {}
  525. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  526. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  527. """
  528. High-density structured multi-paragraph insight block.
  529. items: list of {'title': str, 'content': str}
  530. Features:
  531. - No truncation; full content rendered
  532. - No max_items limit by default (render all)
  533. - Auto-shrink body font to fit within height (down to min_body_size)
  534. - Compact line spacing (1.15) to maximize density
  535. - Each bullet has emoji + bold title + normal body
  536. """
  537. if not items:
  538. return
  539. # Adaptive font sizing: shrink body_size until it fits
  540. target_height = int(height)
  541. # title_size/body_size may be EMU integers or Pt objects; normalize to pt
  542. _ts = float(title_size) / 12700.0 if float(title_size) > 1000 else float(title_size)
  543. _bs = float(body_size) / 12700.0 if float(body_size) > 1000 else float(body_size)
  544. _min_bs = float(min_body_size) / 12700.0 if float(min_body_size) > 1000 else float(min_body_size)
  545. ts_pt = _ts
  546. bs_pt = _bs
  547. min_bs_pt = _min_bs
  548. # Binary-search-like shrink to fit
  549. while bs_pt > min_bs_pt:
  550. est = _estimate_text_height(items, ts_pt, bs_pt, int(width))
  551. if est <= target_height:
  552. break
  553. bs_pt -= 0.5
  554. ts_pt = max(bs_pt + 1, ts_pt - 0.25)
  555. box = slide.shapes.add_textbox(left, top, width, height)
  556. tf = box.text_frame
  557. tf.word_wrap = True
  558. first = True
  559. for item in items[:max_items] if max_items else items:
  560. if not first:
  561. spacer = tf.add_paragraph()
  562. spacer.text = ''
  563. spacer.space_before = Pt(3)
  564. title = item.get('title', '')
  565. emoji = _emoji_for_item(title)
  566. # Avoid double emoji
  567. if emoji and title.startswith(emoji):
  568. emoji = ''
  569. title_text = f'{emoji} {title}' if emoji else title
  570. p = tf.paragraphs[0] if first else tf.add_paragraph()
  571. p.text = title_text
  572. p.font.size = Pt(ts_pt)
  573. p.font.bold = True
  574. p.font.color.rgb = C_PRIMARY
  575. p.font.name = '微软雅黑'
  576. p.line_spacing = 1.15
  577. first = False
  578. content = item.get('content', '')
  579. if content:
  580. p2 = tf.add_paragraph()
  581. p2.text = content
  582. p2.font.size = Pt(bs_pt)
  583. p2.font.color.rgb = C_TEXT
  584. p2.font.name = '微软雅黑'
  585. p2.line_spacing = 1.15
  586. p2.space_before = Pt(1)
  587. def _ensure_min_insight_items(items, profile=None, metrics=None, min_count=2,
  588. context_label='本页'):
  589. """Guarantee enough long-form insight blocks for quality self-check."""
  590. cleaned = []
  591. for item in items or []:
  592. title = str(item.get('title', '')).strip()
  593. content = str(item.get('content', '')).strip()
  594. if title or content:
  595. cleaned.append({'title': title or '分析说明', 'content': content})
  596. profile = profile or {}
  597. metrics = metrics or {}
  598. total_rows = profile.get('total_rows', 0)
  599. numeric_count = len(profile.get('numeric_columns', []) or [])
  600. category_count = len(profile.get('category_columns', []) or [])
  601. fallback_pool = [
  602. {
  603. 'title': f'{context_label}数据基础',
  604. 'content': f'本页基于当前数据画像进行归纳,覆盖 {total_rows or "若干"} 条记录、'
  605. f'{numeric_count} 个数值指标和 {category_count} 个分类维度。'
  606. f'当原始数据字段较少或业务指标尚未形成充分拆解时,报告优先呈现已经确认的核心指标,'
  607. f'并将可验证的数据范围、维度覆盖和后续分析口径写入页面,避免出现空白页或模板占位内容。',
  608. },
  609. {
  610. 'title': f'{context_label}行动建议',
  611. 'content': f'建议围绕已确认的核心指标建立持续跟踪机制:先核对指标口径与数据字段映射,'
  612. f'再按时间、区域、部门或客户等维度拆解异常变化,最后将发现转化为责任人、截止时间和复盘频率明确的行动项。'
  613. f'如果后续补充历史同期或目标值数据,可进一步增加同比、环比和达成率判断。',
  614. },
  615. {
  616. 'title': f'{context_label}风险提示',
  617. 'content': f'若数据源存在缺失值、合并表头、人工备注列或统计口径变化,自动生成的结论需要结合业务确认进行复核。'
  618. f'建议在报告发布前重点检查核心指标是否全部出现、图表数值是否与原表一致、长文本是否仍在页面安全区域内,'
  619. f'以保证美观度和决策可信度同时达标。',
  620. },
  621. ]
  622. used_titles = {item['title'] for item in cleaned}
  623. for fallback in fallback_pool:
  624. if len(cleaned) >= min_count:
  625. break
  626. if fallback['title'] not in used_titles:
  627. cleaned.append(fallback)
  628. used_titles.add(fallback['title'])
  629. return cleaned
  630. # ==============================================================================
  631. # ALERT / ACTION / ISSUE / GOAL CARDS
  632. # ==============================================================================
  633. def _add_alert_cards(slide, alerts, start_y=Emu(1651000), fonts=None, colors=None):
  634. colors = colors or {}
  635. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  636. C_RED = colors.get('red', RGBColor(0xEF, 0x44, 0x44))
  637. C_ORANGE = colors.get('orange', RGBColor(0xED, 0x7D, 0x31))
  638. C_SECONDARY = colors.get('secondary', RGBColor(0x64, 0x74, 0x8B))
  639. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  640. colors = {'严重': C_RED, '警告': C_ORANGE, '关注': C_PRIMARY, '中度': C_ORANGE, '一般': C_SECONDARY}
  641. positions = [Emu(762000), Emu(5778500), Emu(10795000)]
  642. for i, alert in enumerate(alerts[:3]):
  643. x = positions[i]
  644. y = start_y
  645. lvl = alert.get('level', '关注')
  646. c = colors.get(lvl, C_PRIMARY)
  647. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(2286000))
  648. bar.fill.solid()
  649. bar.fill.fore_color.rgb = c
  650. bar.line.fill.background()
  651. tbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 228600), Emu(4064000), Emu(406400))
  652. p = tbox.text_frame.paragraphs[0]
  653. p.text = alert.get('title', '')
  654. p.font.size = Pt(15)
  655. p.font.bold = True
  656. p.font.color.rgb = C_TEXT
  657. p.font.name = '微软雅黑'
  658. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 762000), Emu(4064000), Emu(1270000))
  659. tf = dbox.text_frame
  660. tf.word_wrap = True
  661. p = tf.paragraphs[0]
  662. p.text = alert.get('detail', '')
  663. p.font.size = Pt(11)
  664. p.font.color.rgb = C_TEXT
  665. p.font.name = '微软雅黑'
  666. def _add_action_cards(slide, actions, start_y=Emu(2540000), fonts=None, colors=None):
  667. colors = colors or {}
  668. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  669. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  670. positions = [Emu(762000), Emu(5778500), Emu(10795000)]
  671. for i, act in enumerate(actions[:3]):
  672. x = positions[i]
  673. y = start_y
  674. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(406400))
  675. bar.fill.solid()
  676. bar.fill.fore_color.rgb = C_PRIMARY
  677. bar.line.fill.background()
  678. tbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 952500), Emu(4064000), Emu(406400))
  679. p = tbox.text_frame.paragraphs[0]
  680. p.text = act.get('title', '')
  681. p.font.size = Pt(17)
  682. p.font.bold = True
  683. p.font.color.rgb = C_TEXT
  684. p.font.name = '微软雅黑'
  685. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 1524000), Emu(4064000), Emu(3429000))
  686. tf = dbox.text_frame
  687. tf.word_wrap = True
  688. p = tf.paragraphs[0]
  689. p.text = act.get('detail', '')
  690. p.font.size = Pt(11)
  691. p.font.color.rgb = C_TEXT
  692. p.font.name = '微软雅黑'
  693. p.line_spacing = 1.3
  694. def _add_issue_cards(slide, issues, start_y=Emu(1524000), fonts=None, colors=None):
  695. colors = colors or {}
  696. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  697. C_RED = colors.get('red', RGBColor(0xEF, 0x44, 0x44))
  698. C_ORANGE = colors.get('orange', RGBColor(0xED, 0x7D, 0x31))
  699. C_SECONDARY = colors.get('secondary', RGBColor(0x64, 0x74, 0x8B))
  700. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  701. colors = {'严重': C_RED, '中度': C_ORANGE, '轻度': C_PRIMARY, '一般': C_SECONDARY}
  702. for i, issue in enumerate(issues[:3]):
  703. x = Emu(762000)
  704. y = Emu(int(start_y) + i * (1778000 + 254000))
  705. sev = issue.get('severity', '中度')
  706. c = colors.get(sev, C_ORANGE)
  707. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(1778000))
  708. bar.fill.solid()
  709. bar.fill.fore_color.rgb = c
  710. bar.line.fill.background()
  711. sbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 228600), Emu(660400), Emu(304800))
  712. p = sbox.text_frame.paragraphs[0]
  713. p.text = sev
  714. p.font.size = Pt(11)
  715. p.font.bold = True
  716. p.font.color.rgb = c
  717. p.font.name = '微软雅黑'
  718. tbox = slide.shapes.add_textbox(Emu(x + 1778000), Emu(y + 228600), Emu(13462000), Emu(355600))
  719. p = tbox.text_frame.paragraphs[0]
  720. p.text = issue.get('title', '')
  721. p.font.size = Pt(13)
  722. p.font.bold = True
  723. p.font.color.rgb = C_TEXT
  724. p.font.name = '微软雅黑'
  725. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 698500), Emu(14224000), Emu(355600))
  726. p = dbox.text_frame.paragraphs[0]
  727. p.text = issue.get('detail', '')
  728. p.font.size = Pt(11)
  729. p.font.color.rgb = C_TEXT
  730. p.font.name = '微软雅黑'
  731. abox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 1193800), Emu(14224000), Emu(609600))
  732. tf = abox.text_frame
  733. tf.word_wrap = True
  734. p = tf.paragraphs[0]
  735. p.text = f"建议措施:{issue.get('action', '')}"
  736. p.font.size = Pt(11)
  737. p.font.color.rgb = C_TEXT_GRAY
  738. p.font.name = '微软雅黑'
  739. def _add_goal_cards(slide, goals, start_y=Emu(1524000), fonts=None, colors=None):
  740. colors = colors or {}
  741. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  742. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  743. C_TEXT_GRAY = colors.get('text_gray', RGBColor(0x66, 0x66, 0x66))
  744. sy = int(start_y)
  745. positions = [
  746. (Emu(762000), Emu(sy)),
  747. (Emu(8318500), Emu(sy)),
  748. (Emu(762000), Emu(sy + 1879600)),
  749. (Emu(8318500), Emu(sy + 1879600)),
  750. ]
  751. icon_chars = ['🎯', '💰', '🚀', '⚡']
  752. for i, goal in enumerate(goals[:4]):
  753. x, y = positions[i]
  754. gid = goal.get('id', f'G{i+1}')
  755. gbox = slide.shapes.add_textbox(x, Emu(y + 101600), Emu(635000), Emu(355600))
  756. p = gbox.text_frame.paragraphs[0]
  757. p.text = f"{icon_chars[i % len(icon_chars)]} {gid}"
  758. p.font.size = Pt(16)
  759. p.font.bold = True
  760. p.font.color.rgb = C_PRIMARY
  761. p.font.name = 'Arial'
  762. tbox = slide.shapes.add_textbox(Emu(x + 863600), Emu(y + 101600), Emu(6096000), Emu(355600))
  763. p = tbox.text_frame.paragraphs[0]
  764. p.text = goal.get('title', '')
  765. p.font.size = Pt(14)
  766. p.font.bold = True
  767. p.font.color.rgb = C_TEXT
  768. p.font.name = '微软雅黑'
  769. dbox = slide.shapes.add_textbox(Emu(x + 228600), Emu(y + 571500), Emu(6731000), Emu(863600))
  770. tf = dbox.text_frame
  771. tf.word_wrap = True
  772. p = tf.paragraphs[0]
  773. p.text = goal.get('detail', '')
  774. p.font.size = Pt(11)
  775. p.font.color.rgb = C_TEXT_GRAY
  776. p.font.name = '微软雅黑'
  777. p.line_spacing = 1.3
  778. def _add_summary_text(slide, text, left=Emu(1016000), top=Emu(5435600), width=Emu(14224000), height=Emu(1270000), fonts=None, colors=None):
  779. colors = colors or {}
  780. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  781. box = slide.shapes.add_textbox(left, top, width, height)
  782. tf = box.text_frame
  783. tf.word_wrap = True
  784. p = tf.paragraphs[0]
  785. p.text = text
  786. p.font.size = Pt(12)
  787. p.font.color.rgb = C_TEXT
  788. p.font.name = '微软雅黑'
  789. p.line_spacing = 1.3
  790. # ==============================================================================
  791. # TEXT / LAYOUT HELPERS
  792. # ==============================================================================
  793. def _truncate_text(text, max_chars=60):
  794. """Truncate text to max_chars, appending '...' if truncated."""
  795. if not text:
  796. return text
  797. if len(text) > max_chars:
  798. return text[:max_chars - 1] + '...'
  799. return text
  800. def _format_kpi_value_for_placeholder(value, max_chars=16):
  801. """
  802. KPI value placeholders are fixed-size number slots. If upstream passes a
  803. category list, compact it to a count instead of letting it overflow.
  804. """
  805. if value is None:
  806. return ''
  807. text = str(value).strip()
  808. if len(text) <= max_chars:
  809. return text
  810. list_text = text.strip().strip('[]()(){}')
  811. tokens = [
  812. token.strip().strip("'\"“”‘’")
  813. for token in re_module.split(r'[、,,;;\n/]+', list_text)
  814. ]
  815. tokens = [token for token in tokens if token]
  816. if len(tokens) >= 3:
  817. return f'{len(tokens)}项'
  818. return _truncate_text(text, max_chars)
  819. def _sentiment_color(text):
  820. """Return a light background color based on text sentiment."""
  821. if not text:
  822. return None
  823. text = str(text)
  824. positive_words = ['提升', '增长', '上调', '增加', '高', '好', '大幅', '冲刺', '领跑', '上升', '扩大', '优化', '改善', '突破', '达成']
  825. negative_words = ['下滑', '下降', '减少', '低', '差', '回落', '下滑', '滞后', '堆积', '阻塞', '缺口', '延迟', '超期', '逾期', '风险', '警告']
  826. pos_score = sum(1 for w in positive_words if w in text)
  827. neg_score = sum(1 for w in negative_words if w in text)
  828. if neg_score > pos_score:
  829. return RGBColor(0xFE, 0xE2, 0xE2) # light red ~ #EF444420
  830. if pos_score > neg_score:
  831. return RGBColor(0xD1, 0xFA, 0xE5) # light green ~ #10B98120
  832. return None
  833. import re
  834. def _emoji_for_item(title):
  835. """Return an emoji prefix based on title keywords."""
  836. if not title:
  837. return '📈'
  838. title = str(title)
  839. # Skip if title already starts with an emoji
  840. if re.match(r'^[\U0001F300-\U0001F9FF\u2600-\u26FF\u2700-\u27BF]', title):
  841. return ''
  842. if any(k in title for k in ['风险', '警告', '关注', '下滑', '下降', '延迟', '超期', '缺口', '阻塞']):
  843. return '⚠️'
  844. if any(k in title for k in ['建议', '措施', '行动', '协调', '对接']):
  845. return '💡'
  846. if any(k in title for k in ['目标', '计划', '冲刺', '展望', '聚焦']):
  847. return '🎯'
  848. if any(k in title for k in ['增长', '上升', '提升', '峰值', '领跑', '突破', '活跃', '好转']):
  849. return '📈'
  850. return '💡'
  851. def _add_footer_if_missing(slide, footer_text, slide_width=None, fonts=None, colors=None):
  852. colors = colors or {}
  853. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  854. C_WHITE = colors.get('white', RGBColor(0xFF, 0xFF, 0xFF))
  855. if slide_width is None:
  856. slide_width = slide.shapes._spTree.getparent().getparent().attrib.get('cx')
  857. slide_width = Emu(int(slide_width)) if slide_width else Emu(16256000)
  858. # Check if footer already exists
  859. has_footer = False
  860. for shape in slide.shapes:
  861. if shape.has_text_frame and '数据来源' in shape.text_frame.text:
  862. has_footer = True
  863. break
  864. if has_footer:
  865. return
  866. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, Emu(8824000), slide_width, Emu(320000))
  867. bar.fill.solid()
  868. bar.fill.fore_color.rgb = C_PRIMARY
  869. bar.line.fill.background()
  870. box = slide.shapes.add_textbox(Emu(762000), Emu(8824000), Emu(14000000), Emu(320000))
  871. p = box.text_frame.paragraphs[0]
  872. p.text = footer_text
  873. p.font.size = Pt(10)
  874. p.font.color.rgb = C_WHITE
  875. p.font.name = '微软雅黑'
  876. def _ensure_word_wrap_all(slide, fonts: dict = None):
  877. """Enable word_wrap on all text frames in a slide."""
  878. fonts = fonts or {}
  879. body_font = fonts.get('body_font', '微软雅黑')
  880. for shape in slide.shapes:
  881. if shape.has_text_frame:
  882. shape.text_frame.word_wrap = True
  883. for para in shape.text_frame.paragraphs:
  884. for run in para.runs:
  885. run.font.name = body_font
  886. # ==============================================================================
  887. # MATH HELPERS
  888. # ==============================================================================
  889. def _pct_val(curr, prev):
  890. if prev and prev != 0:
  891. return (curr - prev) / prev * 100
  892. return None
  893. def _format_pct(pct, with_sign=True, suffix='%', zero_suffix=''):
  894. """Safely format a percentage value. Returns '—' if pct is None."""
  895. if pct is None:
  896. return '—'
  897. sign = '+' if with_sign and pct >= 0 else ''
  898. return f"{sign}{pct:.1f}{suffix}{zero_suffix}"
  899. def _pct_str(curr, prev):
  900. if prev and prev != 0:
  901. pct = round((curr - prev) / prev * 100, 1)
  902. sign = '+' if pct >= 0 else ''
  903. return f"{sign}{pct}% vs 上期"
  904. return "—"
  905. def _safe_div(a, b):
  906. return round(a / b, 1) if b else 0
  907. # ==============================================================================
  908. # DYNAMIC / UNIVERSAL REPORT BUILDER
  909. # ==============================================================================
  910. def build_report(data_file: str, config: ReportConfig, output_path: str) -> str:
  911. master_path = _resolve_master_template(config)
  912. prs = Presentation(master_path)
  913. original_slide_count = len(prs.slides)
  914. df = load_generic_excel(data_file)
  915. if config.require_six_confirmations:
  916. confirmation_issues = validate_six_confirmations(config, list(df.columns))
  917. if confirmation_issues:
  918. raise ValueError('生成前六项确认未通过:\n- ' + '\n- '.join(confirmation_issues))
  919. data_profile = config.data_profiling or {}
  920. # Resolve template profile and dynamic layout context
  921. template_profile = _resolve_template_profile(config)
  922. ctx = LayoutContext.from_template_profile(template_profile)
  923. colors = _resolve_colors(config, template_profile)
  924. fonts = _resolve_fonts(config, template_profile)
  925. metrics = calc_generic_metrics(df, config)
  926. content_top = template_profile.get_content_top('content')
  927. total_pages = len([p for p in config.pages if p.selected])
  928. if total_pages == 0:
  929. total_pages = len(config.pages)
  930. for page_idx, page_def in enumerate(config.pages):
  931. if not page_def.selected:
  932. continue
  933. page_num = page_idx + 1
  934. if page_def.page_type == 'cover':
  935. _build_cover_page(prs, config, colors, fonts, template_profile)
  936. elif page_def.page_type == 'toc':
  937. _build_toc_page(prs, config, colors, fonts, template_profile)
  938. elif page_def.page_type == 'kpi_overview':
  939. _build_kpi_overview_page(prs, config, metrics, colors, fonts, content_top, df, data_profile, ctx)
  940. elif page_def.page_type == 'trend':
  941. if not _build_trend_page(prs, config, df, data_profile, colors, fonts, content_top, ctx):
  942. _build_fallback_analysis_page(prs, config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  943. elif page_def.page_type == 'distribution':
  944. if not _build_distribution_page(prs, config, df, data_profile, colors, fonts, content_top, page_def, ctx):
  945. _build_fallback_analysis_page(prs, config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  946. elif page_def.page_type == 'ranking':
  947. if not _build_ranking_page(prs, config, df, data_profile, colors, fonts, content_top, page_def, ctx):
  948. _build_fallback_analysis_page(prs, config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  949. elif page_def.page_type == 'summary':
  950. _build_summary_page(prs, config, metrics, data_profile, colors, fonts, content_top, page_def, ctx)
  951. elif _is_forecast_page_type(page_def.page_type):
  952. _build_forecast_page(prs, config, df, data_profile, metrics, colors, fonts, content_top, page_def, ctx)
  953. elif page_def.page_type == 'end':
  954. _build_end_page(prs, config, colors, fonts, template_profile)
  955. else:
  956. raise ValueError(f'不支持的页面类型: {page_def.page_type}(页面: {page_def.title})')
  957. for slide in prs.slides:
  958. _ensure_word_wrap_all(slide, fonts)
  959. _delete_template_slides(prs, original_slide_count)
  960. prs.save(output_path)
  961. print(f"Report saved: {output_path}")
  962. return output_path
  963. def quality_assured_build(data_file: str, config: ReportConfig,
  964. output_path: str) -> tuple:
  965. if config.require_six_confirmations:
  966. df = load_generic_excel(data_file)
  967. confirmation_issues = validate_six_confirmations(config, list(df.columns))
  968. if confirmation_issues:
  969. raise ValueError('生成前六项确认未通过:\n- ' + '\n- '.join(confirmation_issues))
  970. template_profile = _resolve_template_profile(config)
  971. ctx = LayoutContext.from_template_profile(template_profile)
  972. colors = _resolve_colors(config, template_profile)
  973. inspector = QualityInspector(colors, ctx)
  974. return inspector.quality_assured_build(
  975. build_fn=lambda d, c: _build_without_save(d, c, config),
  976. data=data_file,
  977. config=config,
  978. output_path=output_path,
  979. )
  980. def _build_without_save(data_file, temp_config, original_config):
  981. from pptx import Presentation as Prs
  982. prs = Prs(_resolve_master_template(original_config))
  983. original_slide_count = len(prs.slides)
  984. df = load_generic_excel(data_file)
  985. data_profile = original_config.data_profiling or {}
  986. template_profile = _resolve_template_profile(original_config)
  987. ctx = LayoutContext.from_template_profile(template_profile)
  988. colors = _resolve_colors(original_config, template_profile)
  989. fonts = _resolve_fonts(original_config, template_profile)
  990. metrics = calc_generic_metrics(df, original_config)
  991. content_top = template_profile.get_content_top('content')
  992. for page_def in original_config.pages:
  993. if not page_def.selected:
  994. continue
  995. if page_def.page_type == 'cover':
  996. _build_cover_page(prs, original_config, colors, fonts, template_profile)
  997. elif page_def.page_type == 'kpi_overview':
  998. _build_kpi_overview_page(prs, original_config, metrics, colors, fonts, content_top, df, data_profile, ctx)
  999. elif page_def.page_type == 'trend':
  1000. if not _build_trend_page(prs, original_config, df, data_profile, colors, fonts, content_top, ctx):
  1001. _build_fallback_analysis_page(prs, original_config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1002. elif page_def.page_type == 'distribution':
  1003. if not _build_distribution_page(prs, original_config, df, data_profile, colors, fonts, content_top, page_def, ctx):
  1004. _build_fallback_analysis_page(prs, original_config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1005. elif page_def.page_type == 'ranking':
  1006. if not _build_ranking_page(prs, original_config, df, data_profile, colors, fonts, content_top, page_def, ctx):
  1007. _build_fallback_analysis_page(prs, original_config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1008. elif page_def.page_type == 'summary':
  1009. _build_summary_page(prs, original_config, metrics, data_profile, colors, fonts, content_top, page_def, ctx)
  1010. elif _is_forecast_page_type(page_def.page_type):
  1011. _build_forecast_page(prs, original_config, df, data_profile, metrics, colors, fonts, content_top, page_def, ctx)
  1012. elif page_def.page_type == 'end':
  1013. _build_end_page(prs, original_config, colors, fonts, template_profile)
  1014. elif page_def.page_type == 'toc':
  1015. _build_toc_page(prs, original_config, colors, fonts, template_profile)
  1016. else:
  1017. raise ValueError(f'不支持的页面类型: {page_def.page_type}(页面: {page_def.title})')
  1018. for slide in prs.slides:
  1019. _ensure_word_wrap_all(slide, fonts)
  1020. _delete_template_slides(prs, original_slide_count)
  1021. return prs
  1022. def _build_cover_page(prs, config, colors, fonts, template_profile):
  1023. slide = _duplicate_master_slide(prs, template_profile, 'cover')
  1024. _replace_all_placeholders(slide, {
  1025. '{report_title}': config.title,
  1026. '{report_type}': '数据报告',
  1027. '{date}': config.period_str or config.date_range[0].strftime('%Y年%m月%d日'),
  1028. '{department}': config.source_label,
  1029. '{period}': config.period_str,
  1030. '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
  1031. }, fonts)
  1032. _remove_empty_cover_kpi_placeholders(slide)
  1033. total = len([p for p in config.pages if p.selected]) or len(config.pages)
  1034. _add_footer_if_missing(slide, f'数据来源:{config.source_label} | 1/{total}', slide_width=prs.slide_width, colors=colors)
  1035. def _build_fallback_analysis_page(prs, config, page_def, df, profile, metrics, colors, fonts, content_top, ctx=None):
  1036. """
  1037. Fallback page builder: generates analysis text from available data
  1038. when the primary page type cannot produce content (e.g. no time columns
  1039. for trend, no category columns for distribution).
  1040. Produces at least 4 deep analysis blocks with data citations.
  1041. """
  1042. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1043. page_title = page_def.title if page_def and page_def.title else f'{config.title}数据分析'
  1044. _replace_all_placeholders(slide, {
  1045. '{report_title}': config.title,
  1046. '{date}': config.period_str,
  1047. '{page_title}': page_title,
  1048. '{source}': config.source_label,
  1049. '{period}': '',
  1050. '{page_num}': '',
  1051. }, fonts)
  1052. num_cols = profile.get('numeric_columns', [])
  1053. cat_cols = profile.get('category_columns', [])
  1054. insight_items = []
  1055. if num_cols:
  1056. top_metric = num_cols[0]
  1057. top_name = top_metric.get('inferred_label', top_metric['column_name'])
  1058. top_vals = df[top_metric['column_name']].dropna()
  1059. if len(top_vals) > 0:
  1060. mean_val = top_vals.mean()
  1061. max_val = top_vals.max()
  1062. min_val = top_vals.min()
  1063. median_val = top_vals.median()
  1064. total_val = top_vals.sum()
  1065. insight_items.append({
  1066. 'title': f'{top_name}整体概览',
  1067. 'content': f'报告周期内,{top_name}统计数据共包含 {len(top_vals)} 条有效记录。'
  1068. f'总和为 {total_val:,.0f},平均值为 {mean_val:,.2f},中位数为 {median_val:,.2f}。'
  1069. f'最大值为 {max_val:,.2f},最小值为 {min_val:,.2f}。'
  1070. f'{"数据波动范围较大,最大值与最小值差距显著,说明不同条目间差异明显,建议深入分析极端值成因" if min_val > 0 and max_val / max(min_val, 1) > 100 else "数据整体分布较为均衡,波动性在合理范围内"}。'
  1071. f'中位数与平均值的偏差反映了数据的{"右偏分布(少数大值拉高了均值),说明存在显著头部效应" if median_val < mean_val * 0.8 else "左偏分布" if median_val > mean_val * 1.2 else "较为对称,数据呈正态分布趋势"}。',
  1072. })
  1073. insight_items.append({
  1074. 'title': f'{top_name}分段分析',
  1075. 'content': f'对 {top_name} 进行四分段统计:上四分位数(25%数据高于此值)为 {top_vals.quantile(0.75):,.2f},'
  1076. f'下四分位数(25%数据低于此值)为 {top_vals.quantile(0.25):,.2f},'
  1077. f'四分位距(IQR)为 {top_vals.quantile(0.75) - top_vals.quantile(0.25):,.2f}。'
  1078. f'{"IQR较大,数据分布较为离散,不同类别的表现差异明显,需关注尾部类别的提升空间" if (top_vals.quantile(0.75) - top_vals.quantile(0.25)) > abs(mean_val) * 0.5 else "IQR在合理范围内,数据集中度较好"}。'
  1079. f'建议按四分位将数据分为四组,重点跟踪上四分位组的表现,识别可复制的成功因素。',
  1080. })
  1081. if cat_cols and num_cols:
  1082. cat = cat_cols[0]
  1083. cat_name = cat.get('inferred_label', cat['column_name'])
  1084. num = num_cols[0]
  1085. num_name = num.get('inferred_label', num['column_name'])
  1086. cat_unique = df[cat['column_name']].dropna().nunique()
  1087. insight_items.append({
  1088. 'title': f'{cat_name}分类覆盖分析',
  1089. 'content': f'数据共覆盖 {cat_unique} 个不同的{cat_name},在 {num_name} 维度上呈现差异化分布。'
  1090. f'不同{cat_name}对整体{num_name}的贡献度各异,建议按贡献度大小将{cat_name}进行分类管理。'
  1091. f'高贡献类别应重点维护和深度挖掘,中等贡献类别需持续培育和资源投入,'
  1092. f'低贡献类别可评估其战略价值,适当调整投入节奏。建议建立分类分级管理体系,'
  1093. f'每月跟踪各类别的变化趋势和占比波动。',
  1094. })
  1095. if len(num_cols) >= 2:
  1096. num1 = num_cols[0]
  1097. num2 = num_cols[1]
  1098. ratio = df[num1['column_name']].sum() / max(df[num2['column_name']].sum(), 1)
  1099. insight_items.append({
  1100. 'title': '关键比率与效率指标',
  1101. 'content': f'{num1.get("inferred_label", num1["column_name"])}与{num2.get("inferred_label", num2["column_name"])}的比率为 {ratio:.2f},'
  1102. f'该比率是衡量业务效率的重要参考指标。'
  1103. f'{"比率处于较高水平,表明单位投入产出效率良好" if ratio > 1 else "比率偏低,单位投入的产出效益有限,存在效率提升空间"}。'
  1104. f'建议将此比率纳入定期监控指标,按月环比追踪变化趋势,'
  1105. f'并针对低比率项目制定专项提升计划,分析制约因素和可优化环节。',
  1106. })
  1107. insight_items.append({
  1108. 'title': '数据质量与代表性评估',
  1109. 'content': f'本报告基于共 {len(df)} 条记录进行分析,数据覆盖范围包括上述多个维度。'
  1110. f'建议在后续周期中持续关注数据完整性和及时性,确保分析结果准确反映业务真实情况。'
  1111. f'对于数据量较小或集中度较高的维度,应结合业务判断进行解读,避免以偏概全。'
  1112. f'同时建议补充更多维度的数据(如时间序列数据、竞品对标数据等),'
  1113. f'以支撑更全面的分析视角和更精准的决策建议。',
  1114. })
  1115. if not insight_items:
  1116. insight_items = [{
  1117. 'title': '数据总览',
  1118. 'content': f'当前数据集包含 {len(df)} 条记录,{len(df.columns)} 个字段。'
  1119. f'数值字段 {len(num_cols)} 个,分类字段 {len(cat_cols)} 个。'
  1120. f'建议结合业务场景规划具体的数据分析维度,'
  1121. f'以生成更具洞察力和指导意义的数据报告。',
  1122. }]
  1123. if num_cols and len(df) > 0:
  1124. top_col = num_cols[0]
  1125. chart_zone = get_chart_left_zone(content_top, 0.4, ctx=ctx)
  1126. text_zone = get_insight_right_zone(content_top, 0.4, ctx=ctx)
  1127. sample_vals = df[top_col['column_name']].dropna().head(10).tolist()
  1128. sample_labels = [f'记录{i+1}' for i in range(len(sample_vals))]
  1129. if sample_vals:
  1130. add_bar_chart(slide, sample_labels, sample_vals,
  1131. Emu(chart_zone.x), Emu(chart_zone.y),
  1132. Emu(chart_zone.width), Emu(chart_zone.height),
  1133. series_name=top_col.get('inferred_label', top_col['column_name']),
  1134. color=colors.get('primary'))
  1135. _add_structured_insight(slide, insight_items,
  1136. Emu(text_zone.x), Emu(text_zone.y),
  1137. Emu(text_zone.width), Emu(text_zone.height))
  1138. else:
  1139. zone = get_full_width_zone(content_top, ctx=ctx)
  1140. _add_structured_insight(slide, insight_items,
  1141. Emu(zone.x), Emu(zone.y),
  1142. Emu(zone.width), Emu(zone.height))
  1143. def _build_toc_page(prs, config, colors, fonts, template_profile):
  1144. slide = _duplicate_master_slide(prs, template_profile, 'toc')
  1145. active_pages = [p for p in config.pages if p.selected and p.page_type not in ('cover', 'toc', 'end')]
  1146. _replace_all_placeholders(slide, {
  1147. '{report_title}': config.title,
  1148. '{date}': config.period_str,
  1149. '{page_title}': '目录',
  1150. '{source}': config.source_label,
  1151. '{period}': f'2/{len(config.pages)}',
  1152. '{page_num}': '',
  1153. }, fonts)
  1154. for i, page in enumerate(active_pages[:6], 1):
  1155. _replace_placeholder(slide, f'{{chapter{i}_title}}', page.title, fonts)
  1156. _replace_placeholder(slide, f'{{chapter{i}_desc}}', page.conclusion_title or page.title, fonts)
  1157. def _build_kpi_overview_page(prs, config, metrics, colors, fonts, content_top, df=None, profile=None, ctx=None):
  1158. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), 'content')
  1159. page_title = '核心指标概览'
  1160. _replace_all_placeholders(slide, {
  1161. '{report_title}': config.title,
  1162. '{date}': config.period_str,
  1163. '{page_title}': page_title,
  1164. '{source}': config.source_label,
  1165. '{period}': '',
  1166. '{page_num}': '',
  1167. }, fonts)
  1168. kpi_items = []
  1169. primary_vals = {}
  1170. all_vals = {}
  1171. for md in config.metrics:
  1172. if md.metric_type.value == 'kpi' and md.selected:
  1173. val = metrics.get(md.name, 0)
  1174. display_val = format(val, md.format_spec) if isinstance(val, (int, float)) else str(val)
  1175. kpi_items.append({
  1176. 'label': md.label,
  1177. 'value': display_val,
  1178. 'unit': md.unit,
  1179. 'change': '',
  1180. 'sub': '',
  1181. })
  1182. if md.is_primary:
  1183. primary_vals[md.label] = val
  1184. all_vals[md.label] = val
  1185. if kpi_items:
  1186. kpi_count = len(kpi_items)
  1187. if kpi_count <= 3:
  1188. _add_kpi_cards(slide, kpi_items, start_y=Emu(content_top))
  1189. else:
  1190. shown_kpis = kpi_items[:9]
  1191. compact_card_h = Emu(1780000) if len(shown_kpis) <= 6 else Emu(1600000)
  1192. kpi_bottom = _add_compact_kpi_cards(
  1193. slide,
  1194. shown_kpis,
  1195. start_y=Emu(content_top),
  1196. card_h=compact_card_h,
  1197. gap_y=Emu(220000),
  1198. )
  1199. insight_items = []
  1200. kpi_names = [m.label for m in config.metrics if m.selected]
  1201. kpi_str = "、".join(kpi_names[:6]) if kpi_names else "各指标"
  1202. if len(kpi_names) > 6:
  1203. kpi_str += f'等{len(kpi_names)}项'
  1204. primary_kpis = [m for m in config.metrics if m.is_primary and m.selected]
  1205. if not primary_kpis:
  1206. primary_kpis = [m for m in config.metrics if m.selected][:3]
  1207. kpi_detail_parts = []
  1208. for i, pk in enumerate(primary_kpis):
  1209. val = all_vals.get(pk.label, 0)
  1210. unit_str = pk.unit if pk.unit else ''
  1211. display_val = format(val, pk.format_spec) if isinstance(val, (int, float)) else str(val)
  1212. kpi_detail_parts.append(f'{pk.label}: {display_val}{unit_str}')
  1213. insight_items.append({
  1214. 'title': '核心数据概览',
  1215. 'content': f'本期报告涵盖 {kpi_str} 共 {len(kpi_names)} 项核心指标。'
  1216. f'{";".join(kpi_detail_parts[:4])}。'
  1217. f'其中{"、".join(p.label for p in primary_kpis[:3])}为本次分析的重点关注指标。'
  1218. f'建议将这些指标与历史同期数据进行纵向对比,以及与行业基准进行横向对标,以全面评估当前业务健康度。'
  1219. f'对于波动较大的指标,需深入追溯其背后的业务动因,判断是否为趋势性变化还是季节性波动。',
  1220. })
  1221. cat_cols = profile.get('category_columns', []) if profile else []
  1222. num_cols = profile.get('numeric_columns', []) if profile else []
  1223. total_rows = profile.get('total_rows', 0) if profile else 0
  1224. if cat_cols:
  1225. top_cats = [c.get('inferred_label', c.get('column_name', '')) for c in cat_cols[:3]]
  1226. cat_details = []
  1227. for c in cat_cols[:3]:
  1228. uc = c.get('unique_count', 'N/A')
  1229. cat_details.append(f'{c.get("inferred_label", c.get("column_name", ""))}({uc}类)')
  1230. insight_items.append({
  1231. 'title': '数据覆盖与维度分析',
  1232. 'content': f'数据覆盖 {total_rows:,} 条记录,包含 {", ".join(cat_details)} 等多个分析维度。'
  1233. f'丰富的维度数据支持从 {", ".join(top_cats)} 等角度进行多维度联动分析。'
  1234. f'建议关注各维度下的数据分布特征,识别高贡献或异常的分类群体,'
  1235. f'针对性地分析不同维度的表现差异,为精细化运营和数据驱动决策提供支撑。',
  1236. })
  1237. if len(config.metrics) >= 3:
  1238. compare_items = []
  1239. for a, b in zip(primary_kpis[:2], primary_kpis[1:3]):
  1240. va = all_vals.get(a.label, 0)
  1241. vb = all_vals.get(b.label, 0)
  1242. if va and vb:
  1243. ratio = round(va / vb, 2) if vb else 0
  1244. compare_items.append(f'{a.label}与{b.label}的比值为 {ratio}')
  1245. if compare_items:
  1246. insight_items.append({
  1247. 'title': '指标间关联分析',
  1248. 'content': f'{";".join(compare_items)}。通过指标间的比值关系可以发现数据的内在规律,'
  1249. f'比值异常偏离正常区间时需重点关注。建议进一步计算各指标与核心业务目标之间的相关系数,'
  1250. f'量化不同指标对业务目标的影响力排序,将有限资源聚焦在驱动型指标上。',
  1251. })
  1252. else:
  1253. insight_items.append({
  1254. 'title': '指标间关联分析',
  1255. 'content': f'本期核心指标包括 {", ".join(p.label for p in primary_kpis[:3])}。'
  1256. f'建议通过散点图或相关系数分析探索指标间的线性/非线性关系,识别是否存在协同或对冲效应。'
  1257. f'同时建议按时间序列分析各指标的周期性规律,为资源配置和预测提供依据。',
  1258. })
  1259. insight_items.append({
  1260. 'title': '关键发现与行动建议',
  1261. 'content': f'综合分析 {len(kpi_names)} 项指标,建议重点关注以下方向:'
  1262. f'(1) 定期监控核心指标的趋势变化,建立异常预警机制,当指标偏离正常区间时及时触发排查流程;'
  1263. f'(2) 深化多维度交叉分析,挖掘不同群体间的结构差异,识别增长机会和风险点;'
  1264. f'(3) 结合业务经验和外部数据,验证数据指标的准确性和合理性;'
  1265. f'(4) 将分析结论转化为可执行的具体行动项,明确责任人和时间节点,建立跟踪闭环机制。',
  1266. })
  1267. if kpi_count > 9:
  1268. extra_names = '、'.join(k['label'] for k in kpi_items[9:15])
  1269. insight_items.append({
  1270. 'title': '更多核心指标说明',
  1271. 'content': f'本页优先展示前 9 个核心指标,其余 {kpi_count - 9} 个指标(如 {extra_names})'
  1272. f'已纳入综合分析口径。建议在页面结构确认阶段将核心指标按“结果指标、过程指标、风险指标”分组,'
  1273. f'必要时拆分为多页 KPI 看板,以保证每个指标都有足够的解释空间。',
  1274. })
  1275. if kpi_count <= 3:
  1276. kpi_grid_bottom = int(content_top) + Emu(3048000)
  1277. else:
  1278. kpi_grid_bottom = max(kpi_bottom, int(content_top) + Emu(1780000))
  1279. insight_zone_y = kpi_grid_bottom + Emu(254000)
  1280. remaining_height = int(FOOTER_TOP - insight_zone_y - Emu(140000))
  1281. if remaining_height >= Emu(950000):
  1282. if kpi_count <= 3:
  1283. compact_items = insight_items[:3]
  1284. else:
  1285. compact_items = insight_items[:3] if kpi_count <= 6 else insight_items[:4]
  1286. _add_structured_insight(slide, compact_items,
  1287. Emu(CONTENT_LEFT), Emu(insight_zone_y),
  1288. Emu(SLIDE_WIDTH - 2 * CONTENT_LEFT), Emu(remaining_height),
  1289. title_size=Pt(10), body_size=Pt(9), min_body_size=Pt(8))
  1290. elif kpi_count > 3:
  1291. fallback_top = max(insight_zone_y, int(FOOTER_TOP) - int(Emu(1250000)))
  1292. fallback_height = int(FOOTER_TOP - fallback_top - Emu(120000))
  1293. fallback_items = insight_items[:2]
  1294. _add_structured_insight(slide, fallback_items,
  1295. Emu(CONTENT_LEFT), Emu(fallback_top),
  1296. Emu(SLIDE_WIDTH - 2 * CONTENT_LEFT), Emu(max(fallback_height, Emu(850000))),
  1297. title_size=Pt(9), body_size=Pt(8), min_body_size=Pt(7))
  1298. def _build_trend_page(prs, config, df, profile, colors, fonts, content_top, ctx=None):
  1299. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1300. time_cols = profile.get('time_columns', [])
  1301. num_cols = profile.get('numeric_columns', [])
  1302. if not time_cols or not num_cols:
  1303. _remove_slide(prs, slide)
  1304. return False
  1305. time_col = time_cols[0]['column_name']
  1306. metric_col = num_cols[0]['column_name']
  1307. label = num_cols[0].get('inferred_label', metric_col)
  1308. page_title = f'{label}趋势'
  1309. _replace_all_placeholders(slide, {
  1310. '{report_title}': config.title,
  1311. '{date}': config.period_str,
  1312. '{page_title}': page_title,
  1313. '{source}': config.source_label,
  1314. '{period}': '',
  1315. '{page_num}': '',
  1316. }, fonts)
  1317. trend_data = calc_generic_trend(df, time_col, metric_col)
  1318. if trend_data.get('dates'):
  1319. chart_zone = get_chart_left_zone(content_top, 0.6, ctx=ctx)
  1320. text_zone = get_insight_right_zone(content_top, 0.6, ctx=ctx)
  1321. add_line_chart(slide, trend_data['dates'], trend_data['values'],
  1322. Emu(chart_zone.x), Emu(chart_zone.y),
  1323. Emu(chart_zone.width), Emu(chart_zone.height),
  1324. series_name=label, color=colors.get('primary'))
  1325. dates = trend_data['dates']
  1326. vals = trend_data['values']
  1327. n = len(vals)
  1328. first_v, last_v = vals[0], vals[-1]
  1329. change = last_v - first_v
  1330. change_pct = round(change / first_v * 100, 1) if first_v else 0
  1331. max_v = max(vals) if vals else 0
  1332. min_v = min(vals) if vals else 0
  1333. max_idx = vals.index(max_v) if vals else 0
  1334. min_idx = vals.index(min_v) if vals else 0
  1335. peak_date = dates[max_idx] if max_idx < len(dates) else 'N/A'
  1336. trough_date = dates[min_idx] if min_idx < len(dates) else 'N/A'
  1337. direction_text = '上升' if change > 0 else '下降' if change < 0 else '平稳'
  1338. volatility = round((max_v - min_v) / (sum(vals) / n) * 100, 1) if sum(vals) else 0 if vals else 0
  1339. insight_items = [
  1340. {
  1341. 'title': f'{label}整体趋势概况',
  1342. 'content': f'在报告周期内共采集 {n} 个时间点的数据,{label}'
  1343. f'从 {dates[0]} 的 {first_v:,.0f} 变动至 {dates[-1]} 的 {last_v:,.0f},'
  1344. f'整体{direction_text}{abs(change_pct):.1f}%,{direction_text}趋势{"显著" if abs(change_pct) > 20 else "温和" if abs(change_pct) > 5 else "较为平缓"}。'
  1345. f'数据变化轨迹反映出{"持续向好的增长态势" if direction_text == "上升" and abs(change_pct) > 10 else "温和改善的积极信号" if direction_text == "上升" else "回调盘整的阶段性特征" if direction_text == "下降" else "平稳运行的基本状态"},'
  1346. f'建议将当前趋势与业务目标和历史同期数据进行交叉对比,评估达成全年目标的可行性。如需更详尽的趋势分析,建议增加数据采集频度和时间跨度。',
  1347. },
  1348. {
  1349. 'title': '峰值与谷值分析',
  1350. 'content': f'周期内最高值出现在 {peak_date},为 {max_v:,.0f};'
  1351. f'最低值出现在 {trough_date},为 {min_v:,.0f}。'
  1352. f'极值差距 {max_v - min_v:,.0f},波动幅度 {volatility}%,'
  1353. f'{"波动显著,需关注异常节点的驱动因素,建议排查是否受节假日、促销活动、外部政策变化等因素影响" if volatility > 30 else "波动在可控范围内,但仍需对异常波动保持警觉"}{"." if volatility > 30 else ",建立异常值的快速预警和响应机制。"}',
  1354. },
  1355. {
  1356. 'title': '趋势阶段性特征',
  1357. 'content': f'前半程({dates[0]}至{dates[min(n//2, n-1)]})'
  1358. f'{"呈上升态势" if sum(vals[:n//2]) < sum(vals[n//2:]) else "呈下降态势" if sum(vals[:n//2]) > sum(vals[n//2:]) else "基本持平"},'
  1359. f'后半程均值为 {sum(vals[n//2:])/(n-n//2):,.0f}。建议结合业务事件节点深入分析拐点成因,'
  1360. f'重点关注是否存在季节性波动、周期性波动或外部冲击等结构性因素。'
  1361. f'若数据量较少,趋势解读应以业务经验为主,辅以数据验证。',
  1362. },
  1363. {
  1364. 'title': '业务启示',
  1365. 'content': f'综合趋势分析,当前数据反映出{"积极向好的发展态势" if direction_text == "上升" and abs(change_pct) > 10 else "温和稳定的运行动态" if abs(change_pct) <= 10 else "需重点关注的下行风险"}。'
  1366. f'建议{"加大资源投入以把握增长机遇,同时关注增速的可持续性,避免盲目扩张" if direction_text == "上升" else "排查下降原因并制定针对性应对措施,分析是短期波动还是长期趋势转折" if direction_text == "下降" else "保持当前运营节奏,同时关注潜在变化信号,适时调整策略" if direction_text == "平稳" else "继续观察数据走势"}。'
  1367. f'建议将数据与业务KPI目标进行对标分析,定期回顾趋势变化。',
  1368. },
  1369. ]
  1370. _add_structured_insight(slide, insight_items,
  1371. Emu(text_zone.x), Emu(text_zone.y),
  1372. Emu(text_zone.width), Emu(text_zone.height))
  1373. return True
  1374. return False
  1375. def _build_distribution_page(prs, config, df, profile, colors, fonts, content_top, page_def=None, ctx=None):
  1376. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1377. cat_cols = profile.get('category_columns', [])
  1378. num_cols = profile.get('numeric_columns', [])
  1379. if not cat_cols:
  1380. _remove_slide(prs, slide)
  1381. return False
  1382. elem = (page_def.elements or [{}])[0] if page_def else {}
  1383. cat_col = elem.get('category') or cat_cols[0]['column_name']
  1384. cat_label = elem.get('category_label') or next(
  1385. (c.get('inferred_label', cat_col) for c in cat_cols if c['column_name'] == cat_col), cat_col)
  1386. metric_col = elem.get('metric') or (num_cols[0]['column_name'] if num_cols else None)
  1387. metric_label = elem.get('metric_label') or (next(
  1388. (c.get('inferred_label', metric_col) for c in num_cols if c['column_name'] == metric_col), metric_col) if metric_col else '')
  1389. page_title = page_def.title if page_def and page_def.title else f'{cat_label}分布'
  1390. _replace_all_placeholders(slide, {
  1391. '{report_title}': config.title,
  1392. '{date}': config.period_str,
  1393. '{page_title}': page_title,
  1394. '{source}': config.source_label,
  1395. '{period}': '',
  1396. '{page_num}': '',
  1397. }, fonts)
  1398. dist = calc_generic_distribution(df, cat_col, metric_col, top_n=8)
  1399. if dist.get('categories'):
  1400. chart_zone = get_chart_left_zone(content_top, 0.55, ctx=ctx)
  1401. text_zone = get_insight_right_zone(content_top, 0.55, ctx=ctx)
  1402. if len(dist['categories']) <= 8:
  1403. add_doughnut_chart(slide, dist['categories'], dist['values'],
  1404. Emu(chart_zone.x), Emu(chart_zone.y),
  1405. Emu(chart_zone.width), Emu(chart_zone.height),
  1406. colors=colors.get('series'))
  1407. else:
  1408. add_bar_chart(slide, dist['categories'], dist['values'],
  1409. Emu(chart_zone.x), Emu(chart_zone.y),
  1410. Emu(chart_zone.width), Emu(chart_zone.height),
  1411. series_name=metric_label, color=colors.get('primary'))
  1412. cats, vals, pcts = dist['categories'], dist['values'], dist['percentages']
  1413. grand_total = sum(vals)
  1414. top3_pct = sum(pcts[:3])
  1415. top1_name, top1_val, top1_pct = cats[0], vals[0], pcts[0]
  1416. metric_suffix = metric_label if metric_label else '数量'
  1417. insight_items = [
  1418. {
  1419. 'title': f'{cat_label}分布概况',
  1420. 'content': f'共有 {len(cats)} 个不同的{cat_label},覆盖范围'
  1421. f'{"广泛" if len(cats) >= 8 else "较为丰富" if len(cats) >= 5 else "相对集中"}。'
  1422. f'前3名合计占比 {top3_pct:.1f}%,集中度'
  1423. f'{"较高,呈现显著的头部集中特征" if top3_pct > 70 else "中等,呈现梯度递减分布" if top3_pct > 50 else "较低,分布较为均衡"}。',
  1424. },
  1425. {
  1426. 'title': f'排名第一: {top1_name}',
  1427. 'content': f'{top1_name}以 {top1_val:,}{metric_suffix}(占比 {top1_pct:.1f}%)位居榜首,'
  1428. f'{"是第二名" + cats[1] + "的" + f"{round(top1_val/vals[1],1)}" + "倍,优势极为显著" if len(cats) > 1 else "是该维度中最重要的类别"}。'
  1429. f'该类别贡献了超过三分之一的{metric_label},是整体业务的基本盘和核心增长极。',
  1430. },
  1431. ]
  1432. if len(vals) >= 3:
  1433. top3_sum = sum(vals[:3])
  1434. tail_sum = sum(vals[3:])
  1435. tail_pct = sum(pcts[3:])
  1436. insight_items.append({
  1437. 'title': '长尾分布特征',
  1438. 'content': f'前三名累计 {top3_sum:,}{metric_suffix}({top3_pct:.1f}%),'
  1439. f'剩余 {len(cats)-3} 个合计 {tail_sum:,}{metric_suffix}({tail_pct:.1f}%),'
  1440. f'属于{"头部集中型分布" if top3_pct > 70 else "相对均衡分布" if top3_pct < 50 else "梯度递减型分布"}。'
  1441. f'头部贡献了绝大部分{metric_label},尾部虽数量众多但单个贡献有限。',
  1442. })
  1443. if len(vals) > 1:
  1444. avg_val = sum(vals) / len(vals)
  1445. cv = round(vals[0] / avg_val, 1) if avg_val else 0
  1446. median_idx = len(vals) // 2
  1447. median_val = vals[median_idx]
  1448. insight_items.append({
  1449. 'title': '差异化与离散度分析',
  1450. 'content': f'排名第一的{cat_label}{top1_name}的{metric_suffix}是全部分类均值的 {cv} 倍,'
  1451. f'中位数分类(第{median_idx+1}名)为 {median_val:,}{metric_suffix},'
  1452. f'表明该维度{"差异化显著,资源集中度较高" if cv > 3 else "差异化适中,各分类间差距可控" if cv > 1.5 else "分布较为均匀"}。'
  1453. f'头部与中位数的差距反映了{cat_label}维度上的分层特征,是运营资源重点倾斜方向。',
  1454. })
  1455. insight_items.append({
  1456. 'title': '业务启示',
  1457. 'content': f'建议重点关注 {cats[0]} 的增量拓展与存量维护,同时深入分析排名中位类别的提升空间。'
  1458. f'对于 {metric_label}贡献较小的尾部类别(如占比低于3%的分类),可评估是否优化资源配置、'
  1459. f'调整运营策略或将资源向高回报类别倾斜。结合{cat_label}维度持续跟踪分布变化,及时把握结构性机会。',
  1460. })
  1461. _add_structured_insight(slide, insight_items,
  1462. Emu(text_zone.x), Emu(text_zone.y),
  1463. Emu(text_zone.width), Emu(text_zone.height))
  1464. return True
  1465. return False
  1466. def _build_ranking_page(prs, config, df, profile, colors, fonts, content_top, page_def=None, ctx=None):
  1467. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1468. cat_cols = profile.get('category_columns', [])
  1469. num_cols = profile.get('numeric_columns', [])
  1470. if not cat_cols or not num_cols:
  1471. _remove_slide(prs, slide)
  1472. return False
  1473. elem = (page_def.elements or [{}])[0] if page_def else {}
  1474. rank_col = elem.get('category') or cat_cols[-1]['column_name']
  1475. rank_label = elem.get('category_label') or next(
  1476. (c.get('inferred_label', rank_col) for c in cat_cols if c['column_name'] == rank_col), rank_col)
  1477. metric_col = elem.get('metric') or num_cols[0]['column_name']
  1478. metric_label = elem.get('metric_label') or next(
  1479. (c.get('inferred_label', metric_col) for c in num_cols if c['column_name'] == metric_col), metric_col)
  1480. page_title = page_def.title if page_def and page_def.title else f'{rank_label}TOP排行'
  1481. _replace_all_placeholders(slide, {
  1482. '{report_title}': config.title,
  1483. '{date}': config.period_str,
  1484. '{page_title}': page_title,
  1485. '{source}': config.source_label,
  1486. '{period}': '',
  1487. '{page_num}': '',
  1488. }, fonts)
  1489. ranking = calc_generic_ranking(df, rank_col, metric_col, top_n=15)
  1490. if ranking:
  1491. chart_zone = get_chart_left_zone(content_top, 0.6, ctx=ctx)
  1492. text_zone = get_insight_right_zone(content_top, 0.6, ctx=ctx)
  1493. names = [r['name'] for r in ranking]
  1494. vals = [r['value'] for r in ranking]
  1495. add_bar_chart(slide, names, vals,
  1496. Emu(chart_zone.x), Emu(chart_zone.y),
  1497. Emu(chart_zone.width), Emu(chart_zone.height),
  1498. series_name=metric_label, color=colors.get('primary'))
  1499. total_val = sum(vals)
  1500. top3_names = [r['name'] for r in ranking[:3]]
  1501. top3_vals = [r['value'] for r in ranking[:3]]
  1502. top3_pct = [round(v / total_val * 100, 1) for v in top3_vals] if total_val else [0, 0, 0]
  1503. top1_vs_last = round(vals[0] / vals[-1], 1) if len(vals) > 1 and vals[-1] > 0 else 'N/A'
  1504. insight_items = [
  1505. {
  1506. 'title': f'{rank_label}TOP排行概况',
  1507. 'content': f'共展示 {len(ranking)} 个排名项,前3名分别为 {top3_names[0]}、{top3_names[1]}、'
  1508. f'{top3_names[2]},累计 {sum(top3_vals):,}{metric_label}({sum(top3_pct):.1f}%)。'
  1509. f'前三名合计贡献超过总量的三分之一,表明{rank_label}维度呈现{"显著的头部集中特征" if sum(top3_pct) > 60 else "梯度递减的分布格局" if sum(top3_pct) > 40 else "相对均衡的分布态势"}。',
  1510. },
  1511. {
  1512. 'title': f'榜首分析: {top3_names[0]}',
  1513. 'content': f'{top3_names[0]}以 {top3_vals[0]:,}{metric_label}(占比 {top3_pct[0]:.1f}%)位居榜首,'
  1514. f'{"是第2名" + top3_names[1] + "的" + f"{round(top3_vals[0]/top3_vals[1],1)}倍,领先优势显著" if len(ranking) > 1 and top3_vals[1] > 0 else "优势突出"}。'
  1515. f'作为排名第一的{rank_label},其业绩表现直接影响整体业务大盘,建议重点关注其可持续增长策略。',
  1516. },
  1517. {
  1518. 'title': '头部与尾部差距分析',
  1519. 'content': f'第1名与第{len(ranking)}名差距达 {top1_vs_last} 倍,'
  1520. f'前5名平均 {round(sum(vals[:5])/5):,}{metric_label},'
  1521. f'后5名平均 {round(sum(vals[-5:])/5):,}{metric_label},'
  1522. f'前后差距约 {round((sum(vals[:5])/5)/(sum(vals[-5:])/5),1) if sum(vals[-5:]) > 0 else "N/A"} 倍。'
  1523. f'{"头部效应极为明显,需关注是否因资源分配不均导致" if isinstance(top1_vs_last, float) and top1_vs_last > 10 else "差距较为显著,存在分层优化的空间" if isinstance(top1_vs_last, float) and top1_vs_last > 5 else "梯度分布相对均衡,可针对性提升各层级表现"}。',
  1524. },
  1525. {
  1526. 'title': '累计贡献率与分层分析',
  1527. 'content': f'前5名累计贡献 {sum(vals[:5]):,}{metric_label}({round(sum(vals[:5])/total_val*100,1) if total_val else 0}%),'
  1528. f'前10名累计贡献 {sum(vals[:10]):,}{metric_label}({round(sum(vals[:10])/total_val*100,1) if total_val else 0}%),'
  1529. f'剩余 {len(ranking)-10} 名合计贡献 {sum(vals[10:]):,}{metric_label}({round(sum(vals[10:])/total_val*100,1) if total_val else 0}%)。'
  1530. f'从分层结构来看,可划分为三个梯队:第一梯队(前3名)为业绩核心贡献者,第二梯队(第4-8名)为稳定输出层,'
  1531. f'第三梯队(第9名及以后)为潜力提升层。',
  1532. },
  1533. {
  1534. 'title': '业务建议',
  1535. 'content': f'重点关注 {", ".join(top3_names)} 的发展动态,提炼其成功经验并推广至团队。'
  1536. f'对于排名靠后的{rank_label},可评估其增长潜力与资源匹配度,'
  1537. f'识别可突破的增量空间。建议建立{rank_label}的绩效考核与激励体系,'
  1538. f'通过标杆带动和梯队培养实现整体业绩提升。',
  1539. },
  1540. ]
  1541. _add_structured_insight(slide, insight_items,
  1542. Emu(text_zone.x), Emu(text_zone.y),
  1543. Emu(text_zone.width), Emu(text_zone.height))
  1544. return True
  1545. return False
  1546. def _build_summary_page(prs, config, metrics, profile, colors, fonts, content_top, page_def=None, ctx=None):
  1547. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1548. page_title = page_def.title if page_def and page_def.title else '总结与建议'
  1549. _replace_all_placeholders(slide, {
  1550. '{report_title}': config.title,
  1551. '{date}': config.period_str,
  1552. '{page_title}': page_title,
  1553. '{source}': config.source_label,
  1554. '{period}': '',
  1555. '{page_num}': '',
  1556. }, fonts)
  1557. elem = (page_def.elements or [{}])[0] if page_def else {}
  1558. if elem.get('support_status') is not None:
  1559. status = elem['support_status']
  1560. dept = elem.get('support_by_dept', {})
  1561. sc = elem.get('support_count', 0)
  1562. cc = elem.get('closed_count', 0)
  1563. close_rate = round(cc / sc * 100, 1) if sc else 0
  1564. fully_closed = status.get('已闭环', 0)
  1565. partial_closed = status.get('部分闭环', 0)
  1566. not_closed = status.get('未闭环', 0)
  1567. insight_items = [{
  1568. 'title': '支持需求总览',
  1569. 'content': f'本期共产生 {sc} 项跨部门支持需求,其中已闭环 {cc} 项(含完全闭环 {fully_closed} 项、部分闭环 {partial_closed} 项),'
  1570. f'闭环率 {close_rate}%。未闭环需求 {sc - cc} 项(占比 {round((sc-cc)/sc*100,1) if sc else 0}%),'
  1571. f'闭环率{"较高,跨部门协作效率良好" if close_rate >= 60 else "处于中等水平,仍有提升空间" if close_rate >= 30 else "偏低,需重点关注闭环推动"}。'
  1572. f'跨部门支持是保障项目推进的重要环节,高效的闭环机制有助于提升客户满意度和订单转化效率。',
  1573. }]
  1574. if status:
  1575. total_status = sum(status.values())
  1576. fully_pct = round(fully_closed / total_status * 100, 1) if total_status else 0
  1577. partial_pct = round(partial_closed / total_status * 100, 1) if total_status else 0
  1578. not_pct = round(not_closed / total_status * 100, 1) if total_status else 0
  1579. insight_items.append({
  1580. 'title': '闭环状态明细',
  1581. 'content': f'已闭环 {fully_closed} 项({fully_pct}%)、部分闭环 {partial_closed} 项({partial_pct}%)、'
  1582. f'未闭环 {not_closed} 项({not_pct}%)。'
  1583. f'其中完全闭环占比{"超过七成,闭环质量较高" if fully_pct >= 70 else "处于中等水平" if fully_pct >= 40 else "偏低,需提升闭环完整性"}。'
  1584. f'部分闭环表明需求已部分满足但未完全解决,需持续跟踪至彻底闭环。',
  1585. })
  1586. if dept:
  1587. dept_top = list(dept.items())[:5]
  1588. dept_top_sum = sum(v for _, v in dept_top)
  1589. dept_total = sum(dept.values())
  1590. dept_str = '、'.join([f'{k}({v}项)' for k, v in dept_top])
  1591. avg_dept_load = round(dept_total / len(dept), 1) if dept else 0
  1592. max_dept = dept_top[0]
  1593. insight_items.append({
  1594. 'title': '支持部门工作量分布',
  1595. 'content': f'需求覆盖 {len(dept)} 个部门/科室,前5个部门承接 {dept_top_sum} 项({round(dept_top_sum/dept_total*100,1) if dept_total else 0}%)。'
  1596. f'Top部门:{dept_str}。其中{max_dept[0]}承接最多({max_dept[1]}项),'
  1597. f'平均每个部门承接 {avg_dept_load} 项。请关注工作量较大的部门资源分配是否充足,'
  1598. f'同时识别是否有部门长期未被分配需求(可能表明资源未充分利用)。',
  1599. })
  1600. if sc - cc > 0:
  1601. insight_items.append({
  1602. 'title': '未闭环需求跟进建议',
  1603. 'content': f'当前仍有 {sc - cc} 项需求未完成闭环。建议按以下策略推进:第一,按紧急程度和影响范围对未闭环需求进行优先级排序,'
  1604. f'高优需求指定专人负责限期解决;第二,建立周度闭环跟踪机制,定期更新需求处理进展;'
  1605. f'第三,对于跨部门协同的复杂需求,建议指定牵头部门统筹协调推进,'
  1606. f'并建立问题升级机制(当需求超期未解决时自动升级至更高层级协调)。',
  1607. })
  1608. insight_items.append({
  1609. 'title': '闭环效率提升建议',
  1610. 'content': f'为持续提升支持需求闭环效率,建议:一是建立标准化的需求流转流程,明确各环节责任人和响应时限;'
  1611. f'二是定期开展闭环案例复盘,提炼最佳实践并在团队内推广;'
  1612. f'三是建立闭环率考核指标,将闭环时效纳入部门协作评价体系,'
  1613. f'通过制度保障跨部门协作的效率和质量。',
  1614. })
  1615. else:
  1616. insight_items = generate_generic_insights(profile, metrics)
  1617. insight_items = _ensure_min_insight_items(
  1618. insight_items,
  1619. profile=profile,
  1620. metrics=metrics,
  1621. min_count=2,
  1622. context_label='总结页',
  1623. )
  1624. zone = get_full_width_zone(content_top, ctx=ctx)
  1625. _add_structured_insight(slide, insight_items,
  1626. Emu(zone.x), Emu(zone.y),
  1627. Emu(zone.width), Emu(zone.height))
  1628. def _build_end_page(prs, config, colors, fonts, template_profile):
  1629. slide = _duplicate_master_slide(prs, template_profile, "end")
  1630. total = len([p for p in config.pages if p.selected])
  1631. _add_footer_if_missing(slide, f'数据来源:{config.source_label} | {total}/{total}', colors=colors)
  1632. _replace_all_placeholders(slide, {
  1633. '{report_title}': config.title,
  1634. '{date}': config.period_str or '',
  1635. '{department}': config.source_label,
  1636. }, fonts)
  1637. # Remove empty KPI placeholders on end page (same as cover)
  1638. _remove_empty_cover_kpi_placeholders(slide)
  1639. def _find_metric_def_by_column(config, column):
  1640. for metric in getattr(config, 'metrics', []) or []:
  1641. if getattr(metric, 'column', None) == column:
  1642. return metric
  1643. return None
  1644. def _forecast_items_from_page_def(page_def, df, profile, metrics, config):
  1645. elem = (page_def.elements or [{}])[0] if page_def else {}
  1646. items = []
  1647. explicit_items = elem.get('forecast_items') or elem.get('goals')
  1648. if explicit_items:
  1649. for idx, item in enumerate(explicit_items[:6], 1):
  1650. title = item.get('title') or item.get('label') or f'预测项{idx}'
  1651. value = item.get('value') or item.get('number') or item.get('target') or 0
  1652. items.append({'title': str(title), 'number': value})
  1653. return items
  1654. metric_names = elem.get('metrics') or elem.get('metric_names') or []
  1655. for metric_name in metric_names[:6]:
  1656. if metric_name in metrics:
  1657. metric_def = next((m for m in getattr(config, 'metrics', []) if m.name == metric_name), None)
  1658. label = metric_def.label if metric_def else str(metric_name)
  1659. items.append({'title': label, 'number': metrics.get(metric_name, 0)})
  1660. if items:
  1661. return items
  1662. num_cols = profile.get('numeric_columns', []) if profile else []
  1663. keyword_cols = []
  1664. keywords = ('预测', 'forecast', '目标', '计划', 'target', 'plan')
  1665. for col in num_cols:
  1666. col_name = col.get('column_name', '')
  1667. label = col.get('inferred_label', col_name)
  1668. if any(k in str(col_name).lower() or k in str(label).lower() for k in keywords):
  1669. keyword_cols.append(col)
  1670. for col in keyword_cols[:6]:
  1671. col_name = col.get('column_name')
  1672. metric_def = _find_metric_def_by_column(config, col_name)
  1673. label = metric_def.label if metric_def else col.get('inferred_label', col_name)
  1674. if metric_def and metric_def.name in metrics:
  1675. value = metrics.get(metric_def.name, 0)
  1676. elif col_name in df.columns:
  1677. series = df[col_name].dropna()
  1678. value = int(series.sum()) if not series.empty else 0
  1679. else:
  1680. value = 0
  1681. items.append({'title': label, 'number': value})
  1682. return items
  1683. def _generic_forecast_insights(page_def, forecast_items, profile, metrics):
  1684. title = page_def.title if page_def else '预测与行动计划'
  1685. total = sum(float(item.get('number') or 0) for item in forecast_items)
  1686. item_desc = '、'.join(f"{item['title']} {item.get('number', 0):,.0f}" for item in forecast_items[:5])
  1687. if forecast_items:
  1688. return [
  1689. {
  1690. 'title': f'{title}目标概览',
  1691. 'content': f'本页围绕已确认的预测/计划指标展开,当前纳入 {len(forecast_items)} 个量化项,'
  1692. f'合计规模约 {total:,.0f}。主要项目包括:{item_desc}。'
  1693. f'这些指标应与本期实际结果、历史同期和资源约束一起判断,避免只看单点预测值。',
  1694. },
  1695. {
  1696. 'title': '达成路径与风险控制',
  1697. 'content': f'建议将预测目标拆解为“责任人、关键动作、时间节点、风险预案”四类信息。'
  1698. f'如果目标值明显高于本期实际表现,应同步确认新增订单、库存、产能、交付或预算等支撑条件;'
  1699. f'如果目标值低于当前趋势,则需要说明保守假设,防止业务团队误判资源投入强度。',
  1700. },
  1701. ]
  1702. total_rows = profile.get('total_rows', 0) if profile else 0
  1703. return [
  1704. {
  1705. 'title': f'{title}口径说明',
  1706. 'content': f'当前页面未检测到明确的预测或目标数值字段,因此以数据画像和核心指标进行预测口径说明。'
  1707. f'本期数据覆盖 {total_rows or "若干"} 条记录,建议在六项确认阶段明确预测指标、目标字段和统计口径,'
  1708. f'例如下月交付、销售目标、库存消化、需求闭环或风险事件数量。',
  1709. },
  1710. {
  1711. 'title': '补充数据建议',
  1712. 'content': f'为了生成更可靠的预测页,建议在源数据中补充至少一个预测/目标字段,并提供历史实际值用于校准。'
  1713. f'报告生成后应检查预测值是否与图表一致,文字洞察是否说明关键假设、达成路径和偏差处理机制。',
  1714. },
  1715. ]
  1716. def _build_forecast_page(prs, config, df, profile, metrics, colors, content_top, page_def=None):
  1717. slide = _duplicate_slide(prs, prs.slides[1])
  1718. page_title = page_def.title if page_def and page_def.title else '预测与行动计划'
  1719. _replace_all_placeholders(slide, {
  1720. '{report_title}': config.title,
  1721. '{date}': config.period_str,
  1722. '{page_title}': page_title,
  1723. '{source}': config.source_label,
  1724. '{period}': '',
  1725. '{page_num}': '',
  1726. }, fonts)
  1727. forecast_items = _forecast_items_from_page_def(page_def, df, profile, metrics, config)
  1728. if not forecast_items and metrics.get('next_month_goals'):
  1729. forecast_items = [
  1730. {'title': g['title'].split(':')[0], 'number': g.get('number', 0)}
  1731. for g in metrics.get('next_month_goals', [])[:6]
  1732. ]
  1733. chart_zone = get_chart_left_zone(content_top, 0.58, ctx=ctx)
  1734. text_zone = get_insight_right_zone(content_top, 0.58, ctx=ctx)
  1735. if forecast_items:
  1736. names = [item['title'] for item in forecast_items[:6]]
  1737. values = [float(item.get('number') or 0) for item in forecast_items[:6]]
  1738. add_column_chart(slide, names, values,
  1739. Emu(chart_zone.x), Emu(chart_zone.y),
  1740. Emu(chart_zone.width), Emu(min(chart_zone.height, Emu(5100000))),
  1741. series_name='预测/目标值', color=colors.get('accent', C_ACCENT),
  1742. category_axis_title='预测项', value_axis_title='数值')
  1743. insight_items = _generic_forecast_insights(page_def, forecast_items, profile, metrics)
  1744. insight_items = _ensure_min_insight_items(insight_items, profile, metrics, context_label='预测页')
  1745. _add_structured_insight(slide, insight_items,
  1746. Emu(text_zone.x), Emu(text_zone.y),
  1747. Emu(text_zone.width), Emu(text_zone.height))
  1748. # ==============================================================================
  1749. # CLI
  1750. # ==============================================================================
  1751. if __name__ == '__main__':
  1752. import sys
  1753. if len(sys.argv) >= 3:
  1754. from report_config import load_report_config
  1755. data_file = sys.argv[1]
  1756. config_file = sys.argv[2]
  1757. output = sys.argv[3] if len(sys.argv) >= 4 else 'output.pptx'
  1758. config = load_report_config(config_file)
  1759. quality_assured_build(data_file, config, output)
  1760. else:
  1761. print("Usage: python ppt_builder.py <data_file> <config_file> [output_path]")