ppt_builder.py 150 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235
  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 (
  20. load_daily, load_weekly, load_monthly, load_date_range,
  21. load_generic_excel,
  22. )
  23. from metrics_calculator import (
  24. calc_daily_metrics, calc_weekly_metrics, calc_monthly_metrics, generate_deep_insights,
  25. calc_generic_metrics, calc_generic_trend, calc_generic_distribution,
  26. calc_generic_ranking, generate_generic_insights,
  27. )
  28. from chart_factory import (
  29. add_column_chart, add_bar_chart, add_line_chart, add_doughnut_chart,
  30. add_pie_chart, add_funnel_chart, add_horizontal_bar_chart,
  31. add_grouped_bar_chart, add_table
  32. )
  33. from page_layouts import (
  34. get_kpi_grid, get_chart_left_zone, get_insight_right_zone,
  35. get_full_width_zone, get_two_column_zones, LayoutContext,
  36. )
  37. from template_parser import (
  38. parse_template, get_builtin_template_profile,
  39. PLACEHOLDER_ALIASES, _matches_any_placeholder,
  40. )
  41. from quality_inspector import QualityInspector
  42. from theme_manager import theme_to_rgb_colors, get_theme
  43. from report_config import (
  44. ReportConfig, PageDef, MetricDef, PeriodType, ChartType,
  45. validate_six_confirmations,
  46. )
  47. from quality_rules import SLIDE_WIDTH, SLIDE_HEIGHT, CONTENT_LEFT, CONTENT_TOP_BASE, FOOTER_TOP
  48. # Colors — aligned with reference design theme YAML
  49. C_PRIMARY = RGBColor(0x1E, 0x3A, 0x5F)
  50. C_ACCENT = RGBColor(0x10, 0xB9, 0x81)
  51. C_ACCENT_NEG = RGBColor(0xEF, 0x44, 0x44)
  52. C_SECONDARY = RGBColor(0x64, 0x74, 0x8B)
  53. C_DARK = RGBColor(0x1F, 0x3A, 0x5C)
  54. C_WHITE = RGBColor(0xFF, 0xFF, 0xFF)
  55. C_GRAY_BG = RGBColor(0xF2, 0xF2, 0xF2)
  56. C_TEXT = RGBColor(0x33, 0x33, 0x33)
  57. C_TEXT_GRAY = RGBColor(0x66, 0x66, 0x66)
  58. C_LINE = RGBColor(0xD9, 0xD9, 0xD9)
  59. C_CARD_BG = RGBColor(0xE7, 0xF0, 0xF7)
  60. C_GREEN = RGBColor(0x10, 0xB9, 0x81)
  61. C_RED = RGBColor(0xEF, 0x44, 0x44)
  62. C_ORANGE = RGBColor(0xED, 0x7D, 0x31)
  63. # ==============================================================================
  64. # MASTER / SLIDE HELPERS
  65. # ==============================================================================
  66. def get_master_template(report_type: str) -> str:
  67. """Route report type to corresponding master template."""
  68. base = os.path.join(os.path.dirname(__file__), '..', 'assets')
  69. template_map = {
  70. 'daily': os.path.join(base, 'report-master.pptx'),
  71. 'weekly': os.path.join(base, 'weekly-master.pptx'),
  72. 'monthly': os.path.join(base, 'monthly-master.pptx'),
  73. }
  74. path = template_map.get(report_type, template_map['daily'])
  75. if os.path.exists(path):
  76. return os.path.abspath(path)
  77. # Fallbacks
  78. for fallback in [template_map['daily']]:
  79. if os.path.exists(fallback):
  80. return os.path.abspath(fallback)
  81. raise FileNotFoundError(f"Master template not found for {report_type}")
  82. def _resolve_master_template(config: ReportConfig) -> str:
  83. if getattr(config, 'template_path', ''):
  84. return os.path.abspath(config.template_path)
  85. period_type = getattr(config, 'period_type', None)
  86. report_type = getattr(period_type, 'value', period_type) or 'daily'
  87. return get_master_template(report_type)
  88. def _resolve_template_profile(config: ReportConfig):
  89. """Resolve TemplateProfile from config (cached or parse on demand)."""
  90. if getattr(config, 'template_profile', None):
  91. return config.template_profile
  92. if getattr(config, 'template_path', ''):
  93. return parse_template(config.template_path)
  94. period_type = getattr(config, 'period_type', None)
  95. report_type = getattr(period_type, 'value', period_type) or 'daily'
  96. return get_builtin_template_profile(report_type)
  97. def _resolve_colors(config: ReportConfig, profile) -> dict:
  98. """Three-tier color resolution: user theme > template theme > defaults."""
  99. # If user explicitly configured a theme and opted out of template theme
  100. if config.theme and not getattr(config, 'use_template_theme', True):
  101. return theme_to_rgb_colors(config.theme)
  102. # Try template-extracted theme
  103. from theme_manager import extract_theme_from_template, ThemeConfig
  104. template_theme = extract_theme_from_template(profile)
  105. if template_theme:
  106. return theme_to_rgb_colors(template_theme)
  107. # Fallback to user theme or default
  108. if config.theme:
  109. return theme_to_rgb_colors(config.theme)
  110. # Ultimate fallback: hard-coded defaults packaged as a theme
  111. return theme_to_rgb_colors(ThemeConfig())
  112. def _resolve_fonts(config: ReportConfig, profile) -> dict:
  113. """Three-tier font resolution: user config > template fonts > defaults."""
  114. result = {
  115. 'title_font': '微软雅黑',
  116. 'body_font': '微软雅黑',
  117. 'number_font': 'Arial',
  118. }
  119. # Template fonts
  120. detected = getattr(profile, 'detected_fonts', {})
  121. if detected.get('title_font'):
  122. result['title_font'] = detected['title_font']
  123. if detected.get('body_font'):
  124. result['body_font'] = detected['body_font']
  125. if detected.get('number_font'):
  126. result['number_font'] = detected['number_font']
  127. # User override via theme config
  128. if config.theme:
  129. if getattr(config.theme, 'title_font', ''):
  130. result['title_font'] = config.theme.title_font
  131. if getattr(config.theme, 'body_font', ''):
  132. result['body_font'] = config.theme.body_font
  133. if getattr(config.theme, 'number_font', ''):
  134. result['number_font'] = config.theme.number_font
  135. return result
  136. def _duplicate_master_slide(prs, profile, page_type: str):
  137. """Duplicate the appropriate master slide for the given page_type."""
  138. idx = profile.get_master_index_for(page_type)
  139. if 0 <= idx < len(prs.slides):
  140. source = prs.slides[idx]
  141. else:
  142. source = prs.slides[0]
  143. return _duplicate_slide(prs, source)
  144. def _is_forecast_page_type(page_type: str) -> bool:
  145. normalized = str(page_type or '').lower()
  146. return normalized in {
  147. 'forecast',
  148. 'prediction',
  149. 'plan',
  150. 'monthly_forecast',
  151. 'monthly_plan',
  152. 'next_month_plan',
  153. 'custom_forecast',
  154. 'custom_prediction',
  155. }
  156. def _detect_content_top(slide) -> int:
  157. """Detect content start Y from a content slide template by reading {page_title} position."""
  158. page_title_bottom = Emu(1422400) # daily default
  159. for shape in slide.shapes:
  160. if shape.has_text_frame and '{page_title}' in shape.text_frame.text:
  161. page_title_bottom = shape.top + shape.height
  162. break
  163. # Gap: generous spacing between page title and content to avoid crowding
  164. gap = Emu(381000)
  165. return int(page_title_bottom) + int(gap)
  166. def _delete_template_slides(prs, count=None):
  167. """Delete original template slides from the presentation.
  168. count: number of original template slides to remove from the beginning.
  169. If None, auto-detect using a heuristic that looks for unreplaced placeholders.
  170. """
  171. if count is None:
  172. # Auto-detect: count leading slides that contain unreplaced placeholders
  173. # or have only template-specific content patterns.
  174. count = 0
  175. for slide in prs.slides:
  176. has_unreplaced_placeholder = False
  177. has_real_content = False
  178. for shape in slide.shapes:
  179. if shape.has_text_frame:
  180. text = shape.text_frame.text.strip()
  181. if text:
  182. if '{' in text and '}' in text:
  183. has_unreplaced_placeholder = True
  184. else:
  185. # Text like copyright, footer, etc. on template slides
  186. # is not "real content" in the report sense
  187. pass
  188. # If slide has unreplaced placeholders, it's an original template slide
  189. if has_unreplaced_placeholder:
  190. count += 1
  191. else:
  192. # Also check if slide is completely empty (some template slides
  193. # may have no placeholders at all)
  194. if len(slide.shapes) == 0:
  195. count += 1
  196. else:
  197. break
  198. # Ensure we don't delete all slides
  199. actual_count = min(count, len(prs.slides) - 1) if len(prs.slides) > 1 else 0
  200. for _ in range(actual_count):
  201. if len(prs.slides) == 0:
  202. break
  203. rId = prs.slides._sldIdLst[0].rId
  204. prs.part.drop_rel(rId)
  205. del prs.slides._sldIdLst[0]
  206. def _duplicate_slide(prs, source_slide):
  207. # Use last available layout (typically blank) to avoid index errors on custom templates
  208. layout_idx = min(6, len(prs.slide_layouts) - 1)
  209. blank_layout = prs.slide_layouts[layout_idx]
  210. new_slide = prs.slides.add_slide(blank_layout)
  211. # Copy slide background (solid, gradient, image) from source
  212. try:
  213. src_cSld = source_slide._element.cSld
  214. new_cSld = new_slide._element.cSld
  215. if src_cSld.bg is not None:
  216. new_bg = copy.deepcopy(src_cSld.bg)
  217. if new_cSld.bg is not None:
  218. new_cSld.remove(new_cSld.bg)
  219. new_cSld.insert(0, new_bg)
  220. except Exception:
  221. pass
  222. for shape in source_slide.shapes:
  223. el = shape.element
  224. new_el = copy.deepcopy(el)
  225. new_slide.shapes._spTree.insert_element_before(new_el, 'p:extLst')
  226. return new_slide
  227. def _replace_placeholder(slide, placeholder, new_text, fonts: dict = None):
  228. fonts = fonts or {}
  229. body_font = fonts.get('body_font', '微软雅黑')
  230. replacement = (
  231. _format_kpi_value_for_placeholder(new_text)
  232. if re_module.fullmatch(r'\{kpi\d+_value\}', placeholder)
  233. else str(new_text)
  234. )
  235. # Gather aliases for this placeholder
  236. aliases = PLACEHOLDER_ALIASES.get(placeholder, [])
  237. targets = [placeholder] + [a for a in aliases if a != placeholder]
  238. for shape in slide.shapes:
  239. if not shape.has_text_frame:
  240. continue
  241. for para in shape.text_frame.paragraphs:
  242. for target in targets:
  243. if target in para.text:
  244. para.text = para.text.replace(target, replacement)
  245. for run in para.runs:
  246. run.font.name = body_font
  247. break # only replace once per paragraph
  248. def _replace_all_placeholders(slide, mapping: dict, fonts: dict = None):
  249. for placeholder, new_text in mapping.items():
  250. _replace_placeholder(slide, placeholder, new_text, fonts)
  251. def _remove_shape(shape):
  252. """Remove a python-pptx shape from its parent tree."""
  253. el = shape.element
  254. el.getparent().remove(el)
  255. def _remove_slide(prs, slide):
  256. """Remove a slide from a presentation by its rId."""
  257. try:
  258. for i, s in enumerate(prs.slides):
  259. if s == slide:
  260. rId = prs.slides._sldIdLst[i].rId
  261. prs.part.drop_rel(rId)
  262. del prs.slides._sldIdLst[i]
  263. return True
  264. except Exception:
  265. pass
  266. return False
  267. def _safe_auto_shape_type(shape):
  268. try:
  269. return shape.auto_shape_type
  270. except (AttributeError, ValueError):
  271. return None
  272. def _remove_empty_cover_kpi_placeholders(slide):
  273. """
  274. Remove template KPI cards when generic cover data does not provide values.
  275. This prevents empty rounded rectangles from staying on the cover.
  276. """
  277. kpi_pattern = re_module.compile(r'\{kpi\d+_(label|value)\}')
  278. placeholder_shapes = [
  279. shape for shape in slide.shapes
  280. if shape.has_text_frame and kpi_pattern.search(shape.text_frame.text or '')
  281. ]
  282. if not placeholder_shapes:
  283. return
  284. x_min = min(int(shape.left) for shape in placeholder_shapes)
  285. x_max = max(int(shape.left) + int(shape.width) for shape in placeholder_shapes)
  286. y_min = min(int(shape.top) for shape in placeholder_shapes)
  287. y_max = max(int(shape.top) + int(shape.height) for shape in placeholder_shapes)
  288. pad = Emu(220000)
  289. to_remove = []
  290. for shape in slide.shapes:
  291. sx = int(shape.left)
  292. sy = int(shape.top)
  293. sw = int(shape.width)
  294. sh = int(shape.height)
  295. in_region = (
  296. sx >= x_min - pad and sx + sw <= x_max + pad and
  297. sy >= y_min - pad and sy + sh <= y_max + pad
  298. )
  299. is_text_placeholder = shape in placeholder_shapes
  300. is_empty_kpi_card = (
  301. in_region and
  302. _safe_auto_shape_type(shape) == MSO_SHAPE.ROUNDED_RECTANGLE
  303. )
  304. if is_text_placeholder or is_empty_kpi_card:
  305. to_remove.append(shape)
  306. for shape in to_remove:
  307. _remove_shape(shape)
  308. # ==============================================================================
  309. # NAVIGATION TABS
  310. # ==============================================================================
  311. def _add_nav_tabs(slide, tabs, active_index=0, slide_width=None,
  312. fonts=None, colors=None,
  313. tab_y=Emu(254000), tab_h=Emu(762000), underline_h=Emu(127000)):
  314. colors = colors or {}
  315. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  316. C_TEXT_GRAY = colors.get('text_gray', RGBColor(0x66, 0x66, 0x66))
  317. if slide_width is None:
  318. slide_width = slide.shapes._spTree.getparent().getparent().attrib.get('cx')
  319. slide_width = Emu(int(slide_width)) if slide_width else Emu(16256000)
  320. n = len(tabs)
  321. tab_w = Emu(int(slide_width) // n)
  322. for i, label in enumerate(tabs):
  323. x = Emu(i * int(tab_w))
  324. box = slide.shapes.add_textbox(x, tab_y, tab_w, tab_h)
  325. p = box.text_frame.paragraphs[0]
  326. p.text = label
  327. p.font.size = Pt(11)
  328. p.font.name = '微软雅黑'
  329. p.font.color.rgb = C_PRIMARY if i == active_index else C_TEXT_GRAY
  330. p.alignment = PP_ALIGN.CENTER
  331. if i == active_index:
  332. line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, Emu(457200), tab_w, underline_h)
  333. line.fill.solid()
  334. line.fill.fore_color.rgb = C_PRIMARY
  335. line.line.fill.background()
  336. # ==============================================================================
  337. # KPI CARDS
  338. # ==============================================================================
  339. def _add_kpi_cards(slide, kpis, start_x=Emu(762000), start_y=Emu(1651000), fonts=None, colors=None):
  340. fonts = fonts or {}
  341. body_font = fonts.get("body_font", "微软雅黑")
  342. number_font = fonts.get("number_font", "Arial")
  343. colors = colors or {}
  344. C_CARD_BG = colors.get('card_bg', RGBColor(0xE7, 0xF0, 0xF7))
  345. C_TEXT_GRAY = colors.get('text_gray', RGBColor(0x66, 0x66, 0x66))
  346. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  347. positions = [
  348. (start_x, start_y),
  349. (Emu(5778500), start_y),
  350. (Emu(10795000), start_y),
  351. (start_x, Emu(start_y + 3429000)),
  352. (Emu(5778500), Emu(start_y + 3429000)),
  353. (Emu(10795000), Emu(start_y + 3429000)),
  354. ]
  355. for i, kpi in enumerate(kpis[:6]):
  356. if i >= len(positions):
  357. break
  358. x, y = positions[i]
  359. w, h = Emu(4699000), Emu(3048000)
  360. card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
  361. card.fill.solid()
  362. card.fill.fore_color.rgb = C_CARD_BG
  363. card.line.fill.background()
  364. # Label
  365. lbl = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 228600), Emu(2540000), Emu(406400))
  366. p = lbl.text_frame.paragraphs[0]
  367. p.text = kpi.get('label', '')
  368. p.font.size = Pt(14)
  369. p.font.color.rgb = C_TEXT_GRAY
  370. p.font.name = '微软雅黑'
  371. # Value
  372. val = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 762000), Emu(2540000), Emu(698500))
  373. p = val.text_frame.paragraphs[0]
  374. p.text = str(kpi.get('value', ''))
  375. p.font.size = Pt(36)
  376. p.font.bold = True
  377. p.font.color.rgb = C_PRIMARY
  378. p.font.name = 'Arial'
  379. # Unit
  380. unit = kpi.get('unit', '')
  381. if unit:
  382. ubox = slide.shapes.add_textbox(Emu(x + 3048000), Emu(y + 1016000), Emu(508000), Emu(381000))
  383. p = ubox.text_frame.paragraphs[0]
  384. p.text = unit
  385. p.font.size = Pt(14)
  386. p.font.color.rgb = C_TEXT_GRAY
  387. p.font.name = '微软雅黑'
  388. # Change badge
  389. chg = kpi.get('change', '')
  390. if chg:
  391. cbox = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 1778000), Emu(4064000), Emu(304800))
  392. p = cbox.text_frame.paragraphs[0]
  393. p.text = chg
  394. p.font.size = Pt(12)
  395. chg_str = str(chg)
  396. is_positive = chg_str.startswith('+') or any(k in chg_str for k in ['↑', '提升', '增长', '上调', '增加', '大幅', '好', '突破', '达成', '优化'])
  397. is_negative = chg_str.startswith('-') or any(k in chg_str for k in ['↓', '下滑', '下降', '减少', '回落', '滞后', '堆积', '阻塞', '缺口', '延迟'])
  398. if is_negative:
  399. p.font.color.rgb = C_RED
  400. elif is_positive:
  401. p.font.color.rgb = C_GREEN
  402. else:
  403. p.font.color.rgb = C_TEXT_GRAY
  404. p.font.name = '微软雅黑'
  405. # Sub note with semantic background color tag (e.g. "日均51笔")
  406. sub = kpi.get('sub', '')
  407. if sub:
  408. sub_text = _truncate_text(sub, 20)
  409. tag_color = _sentiment_color(sub_text)
  410. tag_x = Emu(x + 508000)
  411. tag_y = Emu(y + 2159000)
  412. tag_w = Emu(min(len(sub_text) * 220000 + 400000, 3600000))
  413. tag_h = Emu(304800)
  414. if tag_color:
  415. tag_bg = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, tag_x, tag_y, tag_w, tag_h)
  416. tag_bg.fill.solid()
  417. tag_bg.fill.fore_color.rgb = tag_color
  418. tag_bg.line.fill.background()
  419. sbox = slide.shapes.add_textbox(tag_x, tag_y, tag_w, tag_h)
  420. p = sbox.text_frame.paragraphs[0]
  421. p.text = sub_text
  422. p.font.size = Pt(11)
  423. p.font.color.rgb = C_TEXT_GRAY
  424. p.font.name = '微软雅黑'
  425. p.alignment = PP_ALIGN.CENTER
  426. def _add_compact_kpi_cards(slide, kpis, start_x=Emu(CONTENT_LEFT), start_y=Emu(1651000),
  427. fonts=None, colors=None,
  428. max_cols=3, card_h=Emu(1780000), gap_x=Emu(254000),
  429. gap_y=Emu(254000)):
  430. colors = colors or {}
  431. C_CARD_BG = colors.get('card_bg', RGBColor(0xE7, 0xF0, 0xF7))
  432. C_TEXT_GRAY = colors.get('text_gray', RGBColor(0x66, 0x66, 0x66))
  433. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  434. """Draw compact KPI cards so generic overview pages preserve room for insight text."""
  435. if not kpis:
  436. return 0
  437. content_w = SLIDE_WIDTH - 2 * CONTENT_LEFT
  438. cols = min(max_cols, max(1, len(kpis)))
  439. card_w = int((content_w - (cols - 1) * int(gap_x)) / cols)
  440. rows = (len(kpis) + cols - 1) // cols
  441. for i, kpi in enumerate(kpis):
  442. row = i // cols
  443. col = i % cols
  444. x = int(start_x) + col * (card_w + int(gap_x))
  445. y = int(start_y) + row * (int(card_h) + int(gap_y))
  446. card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, Emu(x), Emu(y), Emu(card_w), card_h)
  447. card.fill.solid()
  448. card.fill.fore_color.rgb = C_CARD_BG
  449. card.line.fill.background()
  450. label = _truncate_text(kpi.get('label', ''), 14)
  451. lbl = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 180000), Emu(card_w - 560000), Emu(330000))
  452. p = lbl.text_frame.paragraphs[0]
  453. p.text = label
  454. p.font.size = Pt(11)
  455. p.font.color.rgb = C_TEXT_GRAY
  456. p.font.name = '微软雅黑'
  457. value = _truncate_text(str(kpi.get('value', '')), 16)
  458. val = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 570000), Emu(card_w - 1000000), Emu(560000))
  459. p = val.text_frame.paragraphs[0]
  460. p.text = value
  461. p.font.size = Pt(24 if len(value) <= 10 else 20)
  462. p.font.bold = True
  463. p.font.color.rgb = C_PRIMARY
  464. p.font.name = 'Arial'
  465. unit = kpi.get('unit', '')
  466. if unit:
  467. ubox = slide.shapes.add_textbox(Emu(x + card_w - 820000), Emu(y + 710000), Emu(540000), Emu(330000))
  468. p = ubox.text_frame.paragraphs[0]
  469. p.text = _truncate_text(str(unit), 4)
  470. p.font.size = Pt(10)
  471. p.font.color.rgb = C_TEXT_GRAY
  472. p.font.name = '微软雅黑'
  473. sub_text = kpi.get('sub') or kpi.get('change') or '核心指标'
  474. sub = slide.shapes.add_textbox(Emu(x + 280000), Emu(y + 1230000), Emu(card_w - 560000), Emu(330000))
  475. p = sub.text_frame.paragraphs[0]
  476. p.text = _truncate_text(str(sub_text), 24)
  477. p.font.size = Pt(9)
  478. p.font.color.rgb = C_TEXT_GRAY
  479. p.font.name = '微软雅黑'
  480. return int(start_y) + rows * int(card_h) + (rows - 1) * int(gap_y)
  481. # ==============================================================================
  482. # TEXT BLOCKS
  483. # ==============================================================================
  484. def _add_text_block(slide, title, body, left, top, width, height,
  485. fonts=None, colors=None,
  486. title_size=Pt(14), body_size=Pt(11), line_space=Pt(6)):
  487. colors = colors or {}
  488. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  489. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  490. """Single text box with title + body."""
  491. box = slide.shapes.add_textbox(left, top, width, height)
  492. tf = box.text_frame
  493. tf.word_wrap = True
  494. p = tf.paragraphs[0]
  495. p.text = title
  496. p.font.size = title_size
  497. p.font.bold = True
  498. p.font.color.rgb = C_PRIMARY if title else C_TEXT
  499. p.font.name = '微软雅黑'
  500. if body:
  501. p2 = tf.add_paragraph()
  502. p2.text = body
  503. p2.font.size = body_size
  504. p2.font.color.rgb = C_TEXT
  505. p2.font.name = '微软雅黑'
  506. p2.space_before = line_space
  507. p2.line_spacing = 1.3
  508. def _estimate_text_height(items, title_size_pt, body_size_pt, width_emu,
  509. line_spacing=1.15, title_extra=1.3):
  510. """Estimate rendered text height in EMU for adaptive font sizing."""
  511. width_pt = width_emu / 12700.0
  512. chars_per_line_body = max(10, int(width_pt / (body_size_pt * 1.15)))
  513. chars_per_line_title = max(10, int(width_pt / (title_size_pt * 1.15)))
  514. line_height_body = int(body_size_pt * line_spacing * 12700)
  515. line_height_title = int(title_size_pt * title_extra * 12700)
  516. total = 0
  517. for item in items:
  518. title = item.get('title', '')
  519. content = item.get('content', '')
  520. title_lines = max(1, (len(title) + chars_per_line_title - 1) // chars_per_line_title)
  521. content_lines = max(1, (len(content) + chars_per_line_body - 1) // chars_per_line_body)
  522. total += title_lines * line_height_title + content_lines * line_height_body + int(6 * 12700)
  523. return total
  524. def _add_structured_insight(slide, items, left, top, width, height,
  525. fonts=None, colors=None,
  526. title_size=Pt(12), body_size=Pt(11),
  527. max_items=None, min_body_size=Pt(9)):
  528. colors = colors or {}
  529. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  530. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  531. """
  532. High-density structured multi-paragraph insight block.
  533. items: list of {'title': str, 'content': str}
  534. Features:
  535. - No truncation; full content rendered
  536. - No max_items limit by default (render all)
  537. - Auto-shrink body font to fit within height (down to min_body_size)
  538. - Compact line spacing (1.15) to maximize density
  539. - Each bullet has emoji + bold title + normal body
  540. """
  541. if not items:
  542. return
  543. # Adaptive font sizing: shrink body_size until it fits
  544. target_height = int(height)
  545. # title_size/body_size may be EMU integers or Pt objects; normalize to pt
  546. _ts = float(title_size) / 12700.0 if float(title_size) > 1000 else float(title_size)
  547. _bs = float(body_size) / 12700.0 if float(body_size) > 1000 else float(body_size)
  548. _min_bs = float(min_body_size) / 12700.0 if float(min_body_size) > 1000 else float(min_body_size)
  549. ts_pt = _ts
  550. bs_pt = _bs
  551. min_bs_pt = _min_bs
  552. # Binary-search-like shrink to fit
  553. while bs_pt > min_bs_pt:
  554. est = _estimate_text_height(items, ts_pt, bs_pt, int(width))
  555. if est <= target_height:
  556. break
  557. bs_pt -= 0.5
  558. ts_pt = max(bs_pt + 1, ts_pt - 0.25)
  559. box = slide.shapes.add_textbox(left, top, width, height)
  560. tf = box.text_frame
  561. tf.word_wrap = True
  562. first = True
  563. for item in items[:max_items] if max_items else items:
  564. if not first:
  565. spacer = tf.add_paragraph()
  566. spacer.text = ''
  567. spacer.space_before = Pt(3)
  568. title = item.get('title', '')
  569. emoji = _emoji_for_item(title)
  570. # Avoid double emoji
  571. if emoji and title.startswith(emoji):
  572. emoji = ''
  573. title_text = f'{emoji} {title}' if emoji else title
  574. p = tf.paragraphs[0] if first else tf.add_paragraph()
  575. p.text = title_text
  576. p.font.size = Pt(ts_pt)
  577. p.font.bold = True
  578. p.font.color.rgb = C_PRIMARY
  579. p.font.name = '微软雅黑'
  580. p.line_spacing = 1.15
  581. first = False
  582. content = item.get('content', '')
  583. if content:
  584. p2 = tf.add_paragraph()
  585. p2.text = content
  586. p2.font.size = Pt(bs_pt)
  587. p2.font.color.rgb = C_TEXT
  588. p2.font.name = '微软雅黑'
  589. p2.line_spacing = 1.15
  590. p2.space_before = Pt(1)
  591. def _ensure_min_insight_items(items, profile=None, metrics=None, min_count=2,
  592. context_label='本页'):
  593. """Guarantee enough long-form insight blocks for quality self-check."""
  594. cleaned = []
  595. for item in items or []:
  596. title = str(item.get('title', '')).strip()
  597. content = str(item.get('content', '')).strip()
  598. if title or content:
  599. cleaned.append({'title': title or '分析说明', 'content': content})
  600. profile = profile or {}
  601. metrics = metrics or {}
  602. total_rows = profile.get('total_rows', 0)
  603. numeric_count = len(profile.get('numeric_columns', []) or [])
  604. category_count = len(profile.get('category_columns', []) or [])
  605. fallback_pool = [
  606. {
  607. 'title': f'{context_label}数据基础',
  608. 'content': f'本页基于当前数据画像进行归纳,覆盖 {total_rows or "若干"} 条记录、'
  609. f'{numeric_count} 个数值指标和 {category_count} 个分类维度。'
  610. f'当原始数据字段较少或业务指标尚未形成充分拆解时,报告优先呈现已经确认的核心指标,'
  611. f'并将可验证的数据范围、维度覆盖和后续分析口径写入页面,避免出现空白页或模板占位内容。',
  612. },
  613. {
  614. 'title': f'{context_label}行动建议',
  615. 'content': f'建议围绕已确认的核心指标建立持续跟踪机制:先核对指标口径与数据字段映射,'
  616. f'再按时间、区域、部门或客户等维度拆解异常变化,最后将发现转化为责任人、截止时间和复盘频率明确的行动项。'
  617. f'如果后续补充历史同期或目标值数据,可进一步增加同比、环比和达成率判断。',
  618. },
  619. {
  620. 'title': f'{context_label}风险提示',
  621. 'content': f'若数据源存在缺失值、合并表头、人工备注列或统计口径变化,自动生成的结论需要结合业务确认进行复核。'
  622. f'建议在报告发布前重点检查核心指标是否全部出现、图表数值是否与原表一致、长文本是否仍在页面安全区域内,'
  623. f'以保证美观度和决策可信度同时达标。',
  624. },
  625. ]
  626. used_titles = {item['title'] for item in cleaned}
  627. for fallback in fallback_pool:
  628. if len(cleaned) >= min_count:
  629. break
  630. if fallback['title'] not in used_titles:
  631. cleaned.append(fallback)
  632. used_titles.add(fallback['title'])
  633. return cleaned
  634. # ==============================================================================
  635. # ALERT / ACTION / ISSUE / GOAL CARDS
  636. # ==============================================================================
  637. def _add_alert_cards(slide, alerts, start_y=Emu(1651000), fonts=None, colors=None):
  638. colors = colors or {}
  639. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  640. C_RED = colors.get('red', RGBColor(0xEF, 0x44, 0x44))
  641. C_ORANGE = colors.get('orange', RGBColor(0xED, 0x7D, 0x31))
  642. C_SECONDARY = colors.get('secondary', RGBColor(0x64, 0x74, 0x8B))
  643. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  644. colors = {'严重': C_RED, '警告': C_ORANGE, '关注': C_PRIMARY, '中度': C_ORANGE, '一般': C_SECONDARY}
  645. positions = [Emu(762000), Emu(5778500), Emu(10795000)]
  646. for i, alert in enumerate(alerts[:3]):
  647. x = positions[i]
  648. y = start_y
  649. lvl = alert.get('level', '关注')
  650. c = colors.get(lvl, C_PRIMARY)
  651. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(2286000))
  652. bar.fill.solid()
  653. bar.fill.fore_color.rgb = c
  654. bar.line.fill.background()
  655. tbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 228600), Emu(4064000), Emu(406400))
  656. p = tbox.text_frame.paragraphs[0]
  657. p.text = alert.get('title', '')
  658. p.font.size = Pt(15)
  659. p.font.bold = True
  660. p.font.color.rgb = C_TEXT
  661. p.font.name = '微软雅黑'
  662. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 762000), Emu(4064000), Emu(1270000))
  663. tf = dbox.text_frame
  664. tf.word_wrap = True
  665. p = tf.paragraphs[0]
  666. p.text = alert.get('detail', '')
  667. p.font.size = Pt(11)
  668. p.font.color.rgb = C_TEXT
  669. p.font.name = '微软雅黑'
  670. def _add_action_cards(slide, actions, start_y=Emu(2540000), fonts=None, colors=None):
  671. colors = colors or {}
  672. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  673. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  674. positions = [Emu(762000), Emu(5778500), Emu(10795000)]
  675. for i, act in enumerate(actions[:3]):
  676. x = positions[i]
  677. y = start_y
  678. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(406400))
  679. bar.fill.solid()
  680. bar.fill.fore_color.rgb = C_PRIMARY
  681. bar.line.fill.background()
  682. tbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 952500), Emu(4064000), Emu(406400))
  683. p = tbox.text_frame.paragraphs[0]
  684. p.text = act.get('title', '')
  685. p.font.size = Pt(17)
  686. p.font.bold = True
  687. p.font.color.rgb = C_TEXT
  688. p.font.name = '微软雅黑'
  689. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 1524000), Emu(4064000), Emu(3429000))
  690. tf = dbox.text_frame
  691. tf.word_wrap = True
  692. p = tf.paragraphs[0]
  693. p.text = act.get('detail', '')
  694. p.font.size = Pt(11)
  695. p.font.color.rgb = C_TEXT
  696. p.font.name = '微软雅黑'
  697. p.line_spacing = 1.3
  698. def _add_issue_cards(slide, issues, start_y=Emu(1524000), fonts=None, colors=None):
  699. colors = colors or {}
  700. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  701. C_RED = colors.get('red', RGBColor(0xEF, 0x44, 0x44))
  702. C_ORANGE = colors.get('orange', RGBColor(0xED, 0x7D, 0x31))
  703. C_SECONDARY = colors.get('secondary', RGBColor(0x64, 0x74, 0x8B))
  704. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  705. colors = {'严重': C_RED, '中度': C_ORANGE, '轻度': C_PRIMARY, '一般': C_SECONDARY}
  706. for i, issue in enumerate(issues[:3]):
  707. x = Emu(762000)
  708. y = Emu(int(start_y) + i * (1778000 + 254000))
  709. sev = issue.get('severity', '中度')
  710. c = colors.get(sev, C_ORANGE)
  711. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(1778000))
  712. bar.fill.solid()
  713. bar.fill.fore_color.rgb = c
  714. bar.line.fill.background()
  715. sbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 228600), Emu(660400), Emu(304800))
  716. p = sbox.text_frame.paragraphs[0]
  717. p.text = sev
  718. p.font.size = Pt(11)
  719. p.font.bold = True
  720. p.font.color.rgb = c
  721. p.font.name = '微软雅黑'
  722. tbox = slide.shapes.add_textbox(Emu(x + 1778000), Emu(y + 228600), Emu(13462000), Emu(355600))
  723. p = tbox.text_frame.paragraphs[0]
  724. p.text = issue.get('title', '')
  725. p.font.size = Pt(13)
  726. p.font.bold = True
  727. p.font.color.rgb = C_TEXT
  728. p.font.name = '微软雅黑'
  729. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 698500), Emu(14224000), Emu(355600))
  730. p = dbox.text_frame.paragraphs[0]
  731. p.text = issue.get('detail', '')
  732. p.font.size = Pt(11)
  733. p.font.color.rgb = C_TEXT
  734. p.font.name = '微软雅黑'
  735. abox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 1193800), Emu(14224000), Emu(609600))
  736. tf = abox.text_frame
  737. tf.word_wrap = True
  738. p = tf.paragraphs[0]
  739. p.text = f"建议措施:{issue.get('action', '')}"
  740. p.font.size = Pt(11)
  741. p.font.color.rgb = C_TEXT_GRAY
  742. p.font.name = '微软雅黑'
  743. def _add_goal_cards(slide, goals, start_y=Emu(1524000), fonts=None, colors=None):
  744. colors = colors or {}
  745. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  746. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  747. C_TEXT_GRAY = colors.get('text_gray', RGBColor(0x66, 0x66, 0x66))
  748. sy = int(start_y)
  749. positions = [
  750. (Emu(762000), Emu(sy)),
  751. (Emu(8318500), Emu(sy)),
  752. (Emu(762000), Emu(sy + 1879600)),
  753. (Emu(8318500), Emu(sy + 1879600)),
  754. ]
  755. icon_chars = ['🎯', '💰', '🚀', '⚡']
  756. for i, goal in enumerate(goals[:4]):
  757. x, y = positions[i]
  758. gid = goal.get('id', f'G{i+1}')
  759. gbox = slide.shapes.add_textbox(x, Emu(y + 101600), Emu(635000), Emu(355600))
  760. p = gbox.text_frame.paragraphs[0]
  761. p.text = f"{icon_chars[i % len(icon_chars)]} {gid}"
  762. p.font.size = Pt(16)
  763. p.font.bold = True
  764. p.font.color.rgb = C_PRIMARY
  765. p.font.name = 'Arial'
  766. tbox = slide.shapes.add_textbox(Emu(x + 863600), Emu(y + 101600), Emu(6096000), Emu(355600))
  767. p = tbox.text_frame.paragraphs[0]
  768. p.text = goal.get('title', '')
  769. p.font.size = Pt(14)
  770. p.font.bold = True
  771. p.font.color.rgb = C_TEXT
  772. p.font.name = '微软雅黑'
  773. dbox = slide.shapes.add_textbox(Emu(x + 228600), Emu(y + 571500), Emu(6731000), Emu(863600))
  774. tf = dbox.text_frame
  775. tf.word_wrap = True
  776. p = tf.paragraphs[0]
  777. p.text = goal.get('detail', '')
  778. p.font.size = Pt(11)
  779. p.font.color.rgb = C_TEXT_GRAY
  780. p.font.name = '微软雅黑'
  781. p.line_spacing = 1.3
  782. def _add_summary_text(slide, text, left=Emu(1016000), top=Emu(5435600), width=Emu(14224000), height=Emu(1270000), fonts=None, colors=None):
  783. box = slide.shapes.add_textbox(left, top, width, height)
  784. tf = box.text_frame
  785. tf.word_wrap = True
  786. p = tf.paragraphs[0]
  787. p.text = text
  788. p.font.size = Pt(12)
  789. p.font.color.rgb = C_TEXT
  790. p.font.name = '微软雅黑'
  791. p.line_spacing = 1.3# ==============================================================================# STRUCTURED INSIGHT GENERATORS# ==============================================================================def _insight_trend_structured(trend_dates, trend_vals, metrics, period_name='近10天'):
  792. colors = colors or {}
  793. C_TEXT = colors.get('text', RGBColor(0x33, 0x33, 0x33))
  794. """Generate structured multi-paragraph trend insight."""
  795. items = []
  796. if not trend_vals or len(trend_vals) < 2:
  797. items.append({'title': '数据概览', 'content': f'{period_name}订单数据平稳,暂无明显波动。'})
  798. return items
  799. peak = max(trend_vals)
  800. peak_idx = trend_vals.index(peak)
  801. low = min(trend_vals)
  802. low_idx = trend_vals.index(low)
  803. last = trend_vals[-1]
  804. first = trend_vals[0]
  805. total = sum(trend_vals)
  806. avg = total / len(trend_vals)
  807. # Paragraph 1: Order scale
  808. curr_orders = metrics.get('tracking_orders', last)
  809. prev_orders = metrics.get('prev_tracking_orders', 0)
  810. order_chg = _pct_val(curr_orders, prev_orders)
  811. curr_qty = metrics.get('total_qty', 0)
  812. prev_qty = metrics.get('prev_total_qty', 0)
  813. qty_chg = _pct_val(curr_qty, prev_qty)
  814. avg_size = metrics.get('avg_order_size', 0)
  815. prev_avg_size = metrics.get('prev_avg_order_size', 0)
  816. scale_text = f'今日订单量{curr_orders}单'
  817. if prev_orders > 0:
  818. diff = curr_orders - prev_orders
  819. scale_text += f',较昨日{"增加" if diff >= 0 else "减少"}{abs(diff)}单'
  820. if curr_qty > 0:
  821. scale_text += f',订单总数量{curr_qty:,}台'
  822. if prev_qty > 0:
  823. qdiff = curr_qty - prev_qty
  824. scale_text += f',较昨日{"增加" if qdiff >= 0 else "减少"}{abs(qdiff)}台'
  825. if avg_size > 0:
  826. scale_text += f',单笔订单平均规模{avg_size:.0f}台'
  827. if prev_avg_size > 0:
  828. adiff = avg_size - prev_avg_size
  829. if abs(adiff) >= 1:
  830. scale_text += f'({"上升" if adiff >= 0 else "下降"}{abs(adiff):.0f}台)'
  831. items.append({'title': '订单规模分析', 'content': scale_text})
  832. # Paragraph 2: Peak fluctuation
  833. peak_text = ''
  834. if peak == last:
  835. peak_text = f'今日达到峰值{peak}单,为{period_name}最高水平。'
  836. elif low == last:
  837. peak_text = f'今日回落至{last}单,为{period_name}最低水平。'
  838. else:
  839. peak_text = f'峰值出现在{trend_dates[peak_idx]}({peak}单),低谷在{trend_dates[low_idx]}({low}单)。'
  840. # Describe recovery pattern
  841. if len(trend_vals) >= 3:
  842. recent = trend_vals[-3:]
  843. if recent[-1] > recent[-2] and recent[-2] < recent[0]:
  844. peak_text += f'连续回落后今日回升至{last}单,呈现反弹态势。'
  845. elif recent[-1] < recent[-2]:
  846. peak_text += f'近期呈回落趋势,需关注后续走势。'
  847. items.append({'title': '峰值波动', 'content': peak_text})
  848. # Paragraph 3: Activity / update
  849. updated = metrics.get('updated_orders', 0)
  850. prev_updated = metrics.get('prev_updated_orders', 0)
  851. if updated > 0 or prev_updated > 0:
  852. act_text = f'今日进度更新{updated}单'
  853. if prev_updated > 0:
  854. udiff = updated - prev_updated
  855. upct = _pct_val(updated, prev_updated)
  856. act_text += f',较昨日{"增加" if udiff >= 0 else "减少"}{abs(udiff)}单({upct:+.1f}%)'
  857. if abs(upct) > 20:
  858. act_text += ',团队活跃度波动较大。' if abs(upct) > 30 else ',团队活跃度有所变化。'
  859. else:
  860. act_text += ',团队活跃度保持平稳。'
  861. else:
  862. act_text += '。'
  863. items.append({'title': '活跃度分析', 'content': act_text})
  864. return items
  865. def _insight_status_structured(status_dist, prev_status_dist=None):
  866. """Generate structured status distribution insight."""
  867. items = []
  868. total = sum(status_dist.values())
  869. if not total:
  870. items.append({'title': '状态概览', 'content': '暂无订单状态数据。'})
  871. return items
  872. max_status = max(status_dist.items(), key=lambda x: x[1])
  873. max_pct = max_status[1] / total * 100
  874. # Production share (C+D)
  875. prod = status_dist.get('已付订金待生产', 0) + status_dist.get('已生产待付尾款', 0)
  876. prod_pct = prod / total * 100
  877. status_text = f'{max_status[0]}占比最高({max_status[1]}单,{max_pct:.1f}%)'
  878. if prod_pct > 0:
  879. status_text += f'。生产端(已付订金+已生产)合计{prod}单({prod_pct:.1f}%)'
  880. if prod_pct > 30:
  881. status_text += ',生产推进力度加大。'
  882. else:
  883. status_text += '。'
  884. items.append({'title': '状态分布特征', 'content': status_text})
  885. # WoW change
  886. if prev_status_dist:
  887. changes = []
  888. for name, curr in status_dist.items():
  889. prev = prev_status_dist.get(name, 0)
  890. if prev > 0:
  891. chg = _pct_val(curr, prev)
  892. if abs(chg) > 5:
  893. changes.append(f'{name}{chg:+.1f}%')
  894. if changes:
  895. items.append({'title': '状态变化(vs 昨日)', 'content': ' | '.join(changes[:4])})
  896. return items
  897. def _insight_region_structured(region_dist):
  898. """Generate structured regional insight with top countries."""
  899. items = []
  900. if not region_dist:
  901. items.append({'title': '区域概览', 'content': '暂无区域分布数据。'})
  902. return items
  903. sorted_regions = sorted(region_dist.items(), key=lambda x: -x[1]['qty'])
  904. top3 = sorted_regions[:3]
  905. total = sum(v['qty'] for v in region_dist.values())
  906. top3_pct = sum(v['qty'] for _, v in top3) / total * 100 if total else 0
  907. top_names = [k for k, _ in top3]
  908. items.append({'title': '核心市场', 'content': f'{"、".join(top_names)}三大核心市场合计占比{top3_pct:.1f}%,是海外订单的核心增长引擎。'})
  909. # Each region detail
  910. for name, data in sorted_regions[:5]:
  911. top_c = data.get('top_countries', [])
  912. top_c_str = '/'.join([c['country'] for c in top_c[:3]]) if top_c else ''
  913. change = data.get('change_pct', 0)
  914. if change is None:
  915. chg_str = ''
  916. elif change > 0:
  917. chg_str = f'(+{change:.1f}%)'
  918. elif change < 0:
  919. chg_str = f'({change:.1f}%)'
  920. else:
  921. chg_str = ''
  922. content = f'{data["pct"]:.1f}% | {data["qty"]:,}台'
  923. if top_c_str:
  924. content += f' | {top_c_str}为主力'
  925. if change != 0:
  926. content += f' {chg_str}'
  927. if change < 0:
  928. content += ' | 需关注'
  929. items.append({'title': name, 'content': content})
  930. return items
  931. def _insight_team_structured(team, total_qty=0, per_capita=0, countries_covered=0):
  932. """Generate structured team performance insight."""
  933. items = []
  934. if not team:
  935. items.append({'title': '团队概览', 'content': '暂无团队绩效数据。'})
  936. return items
  937. n_members = len(team)
  938. avg = per_capita or _safe_div(sum(v.get('orders', 0) for v in team.values()), n_members)
  939. top = max(team.items(), key=lambda x: x[1].get('orders', 0))
  940. overview = f'团队共{n_members}人,'
  941. if countries_covered:
  942. overview += f'覆盖{countries_covered}国,'
  943. overview += f'人均追踪{avg:.0f}单。'
  944. items.append({'title': '团队概况', 'content': overview})
  945. # Top performers
  946. sorted_team = sorted(team.items(), key=lambda x: -x[1].get('orders', 0))
  947. for name, data in sorted_team[:2]:
  948. orders = data.get('orders', 0)
  949. qty = data.get('qty', 0)
  950. comment = '增长主力' if orders > avg * 1.3 else '稳健跟进'
  951. content = f'{name} {orders}单'
  952. if qty:
  953. content += f'/{qty:,}台'
  954. content += f' - {comment}'
  955. items.append({'title': '领跑者点评', 'content': content})
  956. # Find laggard
  957. low = min(team.items(), key=lambda x: x[1].get('orders', 0))
  958. if low[1].get('orders', 0) < avg * 0.7:
  959. items.append({'title': '关注提醒', 'content': f'{low[0]}订单量低于团队均值,建议关注产能提升空间。'})
  960. return items
  961. def _insight_stage_structured(stage_analysis, funnel):
  962. """Generate structured monthly stage funnel insight."""
  963. items = []
  964. early = stage_analysis.get('early', {})
  965. mid = stage_analysis.get('mid', {})
  966. late = stage_analysis.get('late', {})
  967. a_b = early.get('orders', 0)
  968. items.append({
  969. 'title': '前期Pipeline充足',
  970. 'content': f'合同拟定中(A) + 已锁定待付订金(B)共{a_b}单,占总量的{early.get("pct", 0):.1f}%,后续转化空间充足。'
  971. })
  972. c_d = mid.get('orders', 0)
  973. items.append({
  974. 'title': '中期生产推进',
  975. 'content': f'已付订金待生产(C) + 已生产待付尾款(D)共{c_d}单,占总量的{mid.get("pct", 0):.1f}%。'
  976. })
  977. e_f = late.get('orders', 0)
  978. items.append({
  979. 'title': '后期交付待加速',
  980. 'content': f'已付尾款待发运(E) + 已发运(F)仅{e_f}单,占总量的{late.get("pct", 0):.1f}%。'
  981. })
  982. if early.get('pct', 0) > 40:
  983. items.append({
  984. 'title': '⚠ 风险提示',
  985. 'content': f'近半数订单仍停留在合同拟定阶段,需关注A→B的转化效率,加速合同确认和订金回收。'
  986. })
  987. return items
  988. def _insight_top_countries_structured(top_countries_change, total_qty, top_n=6):
  989. """Generate structured TOP countries insight."""
  990. items = []
  991. if not top_countries_change:
  992. items.append({'title': '国家概览', 'content': '暂无国家分布数据。'})
  993. return items
  994. sorted_items = sorted(top_countries_change.items(), key=lambda x: -x[1]['qty'])
  995. top_list = sorted_items[:top_n]
  996. total_top = sum(v['qty'] for _, v in top_list)
  997. pct = total_top / total_qty * 100 if total_qty else 0
  998. items.append({'title': '集中度分析', 'content': f'Top {top_n}目的国合计覆盖{total_top:,}台,占总量的{pct:.1f}%,重点市场集中度高。'})
  999. for i, (country, data) in enumerate(top_list, 1):
  1000. chg = data.get('change_pct', 0)
  1001. comment = ''
  1002. if chg is None:
  1003. comment = '新增市场,潜力可期'
  1004. chg_str = ''
  1005. elif chg > 30:
  1006. comment = '本周增长最快市场之一'
  1007. chg_str = f'({chg:+.1f}%)'
  1008. elif chg > 10:
  1009. comment = '持续增长'
  1010. chg_str = f'({chg:+.1f}%)'
  1011. elif chg < -10:
  1012. comment = '虽有下滑但仍高位' if data['qty'] > total_qty * 0.05 else '需关注'
  1013. chg_str = f'({chg:.1f}%)'
  1014. elif chg < 0:
  1015. comment = '小幅回落'
  1016. chg_str = f'({chg:.1f}%)'
  1017. else:
  1018. comment = 'steady增长' if i <= 3 else '新兴市场,潜力可期'
  1019. chg_str = f'({chg:+.1f}%)' if chg != 0 else ''
  1020. items.append({'title': f'{i}. {country} {data["qty"]:,}台{chg_str}', 'content': comment})
  1021. return items
  1022. # ==============================================================================
  1023. # TEXT / LAYOUT HELPERS
  1024. # ==============================================================================
  1025. def _truncate_text(text, max_chars=60):
  1026. """Truncate text to max_chars, appending '...' if truncated."""
  1027. if not text:
  1028. return text
  1029. if len(text) > max_chars:
  1030. return text[:max_chars - 1] + '...'
  1031. return text
  1032. def _format_kpi_value_for_placeholder(value, max_chars=16):
  1033. """
  1034. KPI value placeholders are fixed-size number slots. If upstream passes a
  1035. category list, compact it to a count instead of letting it overflow.
  1036. """
  1037. if value is None:
  1038. return ''
  1039. text = str(value).strip()
  1040. if len(text) <= max_chars:
  1041. return text
  1042. list_text = text.strip().strip('[]()(){}')
  1043. tokens = [
  1044. token.strip().strip("'\"“”‘’")
  1045. for token in re_module.split(r'[、,,;;\n/]+', list_text)
  1046. ]
  1047. tokens = [token for token in tokens if token]
  1048. if len(tokens) >= 3:
  1049. return f'{len(tokens)}项'
  1050. return _truncate_text(text, max_chars)
  1051. def _sentiment_color(text):
  1052. """Return a light background color based on text sentiment."""
  1053. if not text:
  1054. return None
  1055. text = str(text)
  1056. positive_words = ['提升', '增长', '上调', '增加', '高', '好', '大幅', '冲刺', '领跑', '上升', '扩大', '优化', '改善', '突破', '达成']
  1057. negative_words = ['下滑', '下降', '减少', '低', '差', '回落', '下滑', '滞后', '堆积', '阻塞', '缺口', '延迟', '超期', '逾期', '风险', '警告']
  1058. pos_score = sum(1 for w in positive_words if w in text)
  1059. neg_score = sum(1 for w in negative_words if w in text)
  1060. if neg_score > pos_score:
  1061. return RGBColor(0xFE, 0xE2, 0xE2) # light red ~ #EF444420
  1062. if pos_score > neg_score:
  1063. return RGBColor(0xD1, 0xFA, 0xE5) # light green ~ #10B98120
  1064. return None
  1065. import re
  1066. def _emoji_for_item(title):
  1067. """Return an emoji prefix based on title keywords."""
  1068. if not title:
  1069. return '📈'
  1070. title = str(title)
  1071. # Skip if title already starts with an emoji
  1072. if re.match(r'^[\U0001F300-\U0001F9FF\u2600-\u26FF\u2700-\u27BF]', title):
  1073. return ''
  1074. if any(k in title for k in ['风险', '警告', '关注', '下滑', '下降', '延迟', '超期', '缺口', '阻塞']):
  1075. return '⚠️'
  1076. if any(k in title for k in ['建议', '措施', '行动', '协调', '对接']):
  1077. return '💡'
  1078. if any(k in title for k in ['目标', '计划', '冲刺', '展望', '聚焦']):
  1079. return '🎯'
  1080. if any(k in title for k in ['增长', '上升', '提升', '峰值', '领跑', '突破', '活跃', '好转']):
  1081. return '📈'
  1082. return '💡'
  1083. def _add_footer_if_missing(slide, footer_text, slide_width=None, fonts=None, colors=None):
  1084. colors = colors or {}
  1085. C_PRIMARY = colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
  1086. C_WHITE = colors.get('white', RGBColor(0xFF, 0xFF, 0xFF))
  1087. if slide_width is None:
  1088. slide_width = slide.shapes._spTree.getparent().getparent().attrib.get('cx')
  1089. slide_width = Emu(int(slide_width)) if slide_width else Emu(16256000)
  1090. # Check if footer already exists
  1091. has_footer = False
  1092. for shape in slide.shapes:
  1093. if shape.has_text_frame and '数据来源' in shape.text_frame.text:
  1094. has_footer = True
  1095. break
  1096. if has_footer:
  1097. return
  1098. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, Emu(8824000), slide_width, Emu(320000))
  1099. bar.fill.solid()
  1100. bar.fill.fore_color.rgb = C_PRIMARY
  1101. bar.line.fill.background()
  1102. box = slide.shapes.add_textbox(Emu(762000), Emu(8824000), Emu(14000000), Emu(320000))
  1103. p = box.text_frame.paragraphs[0]
  1104. p.text = footer_text
  1105. p.font.size = Pt(10)
  1106. p.font.color.rgb = C_WHITE
  1107. p.font.name = '微软雅黑'
  1108. def _ensure_word_wrap_all(slide, fonts: dict = None):
  1109. """Enable word_wrap on all text frames in a slide."""
  1110. fonts = fonts or {}
  1111. body_font = fonts.get('body_font', '微软雅黑')
  1112. for shape in slide.shapes:
  1113. if shape.has_text_frame:
  1114. shape.text_frame.word_wrap = True
  1115. for para in shape.text_frame.paragraphs:
  1116. for run in para.runs:
  1117. run.font.name = body_font
  1118. # ==============================================================================
  1119. # MATH HELPERS
  1120. # ==============================================================================
  1121. def _pct_val(curr, prev):
  1122. if prev and prev != 0:
  1123. return (curr - prev) / prev * 100
  1124. return None
  1125. def _format_pct(pct, with_sign=True, suffix='%', zero_suffix=''):
  1126. """Safely format a percentage value. Returns '—' if pct is None."""
  1127. if pct is None:
  1128. return '—'
  1129. sign = '+' if with_sign and pct >= 0 else ''
  1130. return f"{sign}{pct:.1f}{suffix}{zero_suffix}"
  1131. def _pct_str(curr, prev):
  1132. if prev and prev != 0:
  1133. pct = round((curr - prev) / prev * 100, 1)
  1134. sign = '+' if pct >= 0 else ''
  1135. return f"{sign}{pct}% vs 上期"
  1136. return "—"
  1137. def _safe_div(a, b):
  1138. return round(a / b, 1) if b else 0
  1139. # ==============================================================================
  1140. # DYNAMIC / UNIVERSAL REPORT BUILDER
  1141. # ==============================================================================
  1142. def build_report(data_file: str, config: ReportConfig, output_path: str) -> str:
  1143. master_path = _resolve_master_template(config)
  1144. prs = Presentation(master_path)
  1145. original_slide_count = len(prs.slides)
  1146. df = load_generic_excel(data_file)
  1147. if config.require_six_confirmations:
  1148. confirmation_issues = validate_six_confirmations(config, list(df.columns))
  1149. if confirmation_issues:
  1150. raise ValueError('生成前六项确认未通过:\n- ' + '\n- '.join(confirmation_issues))
  1151. data_profile = config.data_profiling or {}
  1152. # Resolve template profile and dynamic layout context
  1153. template_profile = _resolve_template_profile(config)
  1154. ctx = LayoutContext.from_template_profile(template_profile)
  1155. colors = _resolve_colors(config, template_profile)
  1156. fonts = _resolve_fonts(config, template_profile)
  1157. metrics = calc_generic_metrics(df, config)
  1158. content_top = template_profile.get_content_top('content')
  1159. total_pages = len([p for p in config.pages if p.selected])
  1160. if total_pages == 0:
  1161. total_pages = len(config.pages)
  1162. for page_idx, page_def in enumerate(config.pages):
  1163. if not page_def.selected:
  1164. continue
  1165. page_num = page_idx + 1
  1166. if page_def.page_type == 'cover':
  1167. _build_cover_page(prs, config, colors, fonts, template_profile)
  1168. elif page_def.page_type == 'toc':
  1169. _build_toc_page(prs, config, colors, fonts, template_profile)
  1170. elif page_def.page_type == 'kpi_overview':
  1171. _build_kpi_overview_page(prs, config, metrics, colors, fonts, content_top, df, data_profile, ctx)
  1172. elif page_def.page_type == 'trend':
  1173. if not _build_trend_page(prs, config, df, data_profile, colors, fonts, content_top, ctx):
  1174. _build_fallback_analysis_page(prs, config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1175. elif page_def.page_type == 'distribution':
  1176. if not _build_distribution_page(prs, config, df, data_profile, colors, fonts, content_top, page_def, ctx):
  1177. _build_fallback_analysis_page(prs, config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1178. elif page_def.page_type == 'ranking':
  1179. if not _build_ranking_page(prs, config, df, data_profile, colors, fonts, content_top, page_def, ctx):
  1180. _build_fallback_analysis_page(prs, config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1181. elif page_def.page_type == 'summary':
  1182. _build_summary_page(prs, config, metrics, data_profile, colors, fonts, content_top, page_def, ctx)
  1183. elif _is_forecast_page_type(page_def.page_type):
  1184. _build_forecast_page(prs, config, df, data_profile, metrics, colors, fonts, content_top, page_def, ctx)
  1185. elif page_def.page_type == 'end':
  1186. _build_end_page(prs, config, colors, fonts, template_profile)
  1187. else:
  1188. raise ValueError(f'不支持的页面类型: {page_def.page_type}(页面: {page_def.title})')
  1189. for slide in prs.slides:
  1190. _ensure_word_wrap_all(slide, fonts)
  1191. _delete_template_slides(prs, original_slide_count)
  1192. prs.save(output_path)
  1193. print(f"Report saved: {output_path}")
  1194. return output_path
  1195. def quality_assured_build(data_file: str, config: ReportConfig,
  1196. output_path: str) -> tuple:
  1197. if config.require_six_confirmations:
  1198. df = load_generic_excel(data_file)
  1199. confirmation_issues = validate_six_confirmations(config, list(df.columns))
  1200. if confirmation_issues:
  1201. raise ValueError('生成前六项确认未通过:\n- ' + '\n- '.join(confirmation_issues))
  1202. template_profile = _resolve_template_profile(config)
  1203. ctx = LayoutContext.from_template_profile(template_profile)
  1204. colors = _resolve_colors(config, template_profile)
  1205. inspector = QualityInspector(colors, ctx)
  1206. return inspector.quality_assured_build(
  1207. build_fn=lambda d, c: _build_without_save(d, c, config),
  1208. data=data_file,
  1209. config=config,
  1210. output_path=output_path,
  1211. )
  1212. def _build_without_save(data_file, temp_config, original_config):
  1213. from pptx import Presentation as Prs
  1214. prs = Prs(_resolve_master_template(original_config))
  1215. original_slide_count = len(prs.slides)
  1216. df = load_generic_excel(data_file)
  1217. data_profile = original_config.data_profiling or {}
  1218. template_profile = _resolve_template_profile(original_config)
  1219. ctx = LayoutContext.from_template_profile(template_profile)
  1220. colors = _resolve_colors(original_config, template_profile)
  1221. fonts = _resolve_fonts(original_config, template_profile)
  1222. metrics = calc_generic_metrics(df, original_config)
  1223. content_top = template_profile.get_content_top('content')
  1224. for page_def in original_config.pages:
  1225. if not page_def.selected:
  1226. continue
  1227. if page_def.page_type == 'cover':
  1228. _build_cover_page(prs, original_config, colors, fonts, template_profile)
  1229. elif page_def.page_type == 'kpi_overview':
  1230. _build_kpi_overview_page(prs, original_config, metrics, colors, fonts, content_top, df, data_profile, ctx)
  1231. elif page_def.page_type == 'trend':
  1232. if not _build_trend_page(prs, original_config, df, data_profile, colors, fonts, content_top, ctx):
  1233. _build_fallback_analysis_page(prs, original_config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1234. elif page_def.page_type == 'distribution':
  1235. if not _build_distribution_page(prs, original_config, df, data_profile, colors, fonts, content_top, page_def, ctx):
  1236. _build_fallback_analysis_page(prs, original_config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1237. elif page_def.page_type == 'ranking':
  1238. if not _build_ranking_page(prs, original_config, df, data_profile, colors, fonts, content_top, page_def, ctx):
  1239. _build_fallback_analysis_page(prs, original_config, page_def, df, data_profile, metrics, colors, fonts, content_top, ctx)
  1240. elif page_def.page_type == 'summary':
  1241. _build_summary_page(prs, original_config, metrics, data_profile, colors, fonts, content_top, page_def, ctx)
  1242. elif _is_forecast_page_type(page_def.page_type):
  1243. _build_forecast_page(prs, original_config, df, data_profile, metrics, colors, fonts, content_top, page_def, ctx)
  1244. elif page_def.page_type == 'end':
  1245. _build_end_page(prs, original_config, colors, fonts, template_profile)
  1246. elif page_def.page_type == 'toc':
  1247. _build_toc_page(prs, original_config, colors, fonts, template_profile)
  1248. else:
  1249. raise ValueError(f'不支持的页面类型: {page_def.page_type}(页面: {page_def.title})')
  1250. for slide in prs.slides:
  1251. _ensure_word_wrap_all(slide, fonts)
  1252. _delete_template_slides(prs, original_slide_count)
  1253. return prs
  1254. def _build_cover_page(prs, config, colors, fonts, template_profile):
  1255. slide = _duplicate_master_slide(prs, template_profile, 'cover')
  1256. _replace_all_placeholders(slide, {
  1257. '{report_title}': config.title,
  1258. '{report_type}': '数据报告',
  1259. '{date}': config.period_str or config.date_range[0].strftime('%Y年%m月%d日'),
  1260. '{department}': config.source_label,
  1261. '{period}': config.period_str,
  1262. '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
  1263. }, fonts)
  1264. _remove_empty_cover_kpi_placeholders(slide)
  1265. total = len([p for p in config.pages if p.selected]) or len(config.pages)
  1266. _add_footer_if_missing(slide, f'数据来源:{config.source_label} | 1/{total}', slide_width=prs.slide_width, colors=colors)
  1267. def _build_fallback_analysis_page(prs, config, page_def, df, profile, metrics, colors, fonts, content_top, ctx=None):
  1268. """
  1269. Fallback page builder: generates analysis text from available data
  1270. when the primary page type cannot produce content (e.g. no time columns
  1271. for trend, no category columns for distribution).
  1272. Produces at least 4 deep analysis blocks with data citations.
  1273. """
  1274. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1275. page_title = page_def.title if page_def and page_def.title else f'{config.title}数据分析'
  1276. _replace_all_placeholders(slide, {
  1277. '{report_title}': config.title,
  1278. '{date}': config.period_str,
  1279. '{page_title}': page_title,
  1280. '{source}': config.source_label,
  1281. '{period}': '',
  1282. '{page_num}': '',
  1283. }, fonts)
  1284. num_cols = profile.get('numeric_columns', [])
  1285. cat_cols = profile.get('category_columns', [])
  1286. insight_items = []
  1287. if num_cols:
  1288. top_metric = num_cols[0]
  1289. top_name = top_metric.get('inferred_label', top_metric['column_name'])
  1290. top_vals = df[top_metric['column_name']].dropna()
  1291. if len(top_vals) > 0:
  1292. mean_val = top_vals.mean()
  1293. max_val = top_vals.max()
  1294. min_val = top_vals.min()
  1295. median_val = top_vals.median()
  1296. total_val = top_vals.sum()
  1297. insight_items.append({
  1298. 'title': f'{top_name}整体概览',
  1299. 'content': f'报告周期内,{top_name}统计数据共包含 {len(top_vals)} 条有效记录。'
  1300. f'总和为 {total_val:,.0f},平均值为 {mean_val:,.2f},中位数为 {median_val:,.2f}。'
  1301. f'最大值为 {max_val:,.2f},最小值为 {min_val:,.2f}。'
  1302. f'{"数据波动范围较大,最大值与最小值差距显著,说明不同条目间差异明显,建议深入分析极端值成因" if min_val > 0 and max_val / max(min_val, 1) > 100 else "数据整体分布较为均衡,波动性在合理范围内"}。'
  1303. f'中位数与平均值的偏差反映了数据的{"右偏分布(少数大值拉高了均值),说明存在显著头部效应" if median_val < mean_val * 0.8 else "左偏分布" if median_val > mean_val * 1.2 else "较为对称,数据呈正态分布趋势"}。',
  1304. })
  1305. insight_items.append({
  1306. 'title': f'{top_name}分段分析',
  1307. 'content': f'对 {top_name} 进行四分段统计:上四分位数(25%数据高于此值)为 {top_vals.quantile(0.75):,.2f},'
  1308. f'下四分位数(25%数据低于此值)为 {top_vals.quantile(0.25):,.2f},'
  1309. f'四分位距(IQR)为 {top_vals.quantile(0.75) - top_vals.quantile(0.25):,.2f}。'
  1310. f'{"IQR较大,数据分布较为离散,不同类别的表现差异明显,需关注尾部类别的提升空间" if (top_vals.quantile(0.75) - top_vals.quantile(0.25)) > abs(mean_val) * 0.5 else "IQR在合理范围内,数据集中度较好"}。'
  1311. f'建议按四分位将数据分为四组,重点跟踪上四分位组的表现,识别可复制的成功因素。',
  1312. })
  1313. if cat_cols and num_cols:
  1314. cat = cat_cols[0]
  1315. cat_name = cat.get('inferred_label', cat['column_name'])
  1316. num = num_cols[0]
  1317. num_name = num.get('inferred_label', num['column_name'])
  1318. cat_unique = df[cat['column_name']].dropna().nunique()
  1319. insight_items.append({
  1320. 'title': f'{cat_name}分类覆盖分析',
  1321. 'content': f'数据共覆盖 {cat_unique} 个不同的{cat_name},在 {num_name} 维度上呈现差异化分布。'
  1322. f'不同{cat_name}对整体{num_name}的贡献度各异,建议按贡献度大小将{cat_name}进行分类管理。'
  1323. f'高贡献类别应重点维护和深度挖掘,中等贡献类别需持续培育和资源投入,'
  1324. f'低贡献类别可评估其战略价值,适当调整投入节奏。建议建立分类分级管理体系,'
  1325. f'每月跟踪各类别的变化趋势和占比波动。',
  1326. })
  1327. if len(num_cols) >= 2:
  1328. num1 = num_cols[0]
  1329. num2 = num_cols[1]
  1330. ratio = df[num1['column_name']].sum() / max(df[num2['column_name']].sum(), 1)
  1331. insight_items.append({
  1332. 'title': '关键比率与效率指标',
  1333. 'content': f'{num1.get("inferred_label", num1["column_name"])}与{num2.get("inferred_label", num2["column_name"])}的比率为 {ratio:.2f},'
  1334. f'该比率是衡量业务效率的重要参考指标。'
  1335. f'{"比率处于较高水平,表明单位投入产出效率良好" if ratio > 1 else "比率偏低,单位投入的产出效益有限,存在效率提升空间"}。'
  1336. f'建议将此比率纳入定期监控指标,按月环比追踪变化趋势,'
  1337. f'并针对低比率项目制定专项提升计划,分析制约因素和可优化环节。',
  1338. })
  1339. insight_items.append({
  1340. 'title': '数据质量与代表性评估',
  1341. 'content': f'本报告基于共 {len(df)} 条记录进行分析,数据覆盖范围包括上述多个维度。'
  1342. f'建议在后续周期中持续关注数据完整性和及时性,确保分析结果准确反映业务真实情况。'
  1343. f'对于数据量较小或集中度较高的维度,应结合业务判断进行解读,避免以偏概全。'
  1344. f'同时建议补充更多维度的数据(如时间序列数据、竞品对标数据等),'
  1345. f'以支撑更全面的分析视角和更精准的决策建议。',
  1346. })
  1347. if not insight_items:
  1348. insight_items = [{
  1349. 'title': '数据总览',
  1350. 'content': f'当前数据集包含 {len(df)} 条记录,{len(df.columns)} 个字段。'
  1351. f'数值字段 {len(num_cols)} 个,分类字段 {len(cat_cols)} 个。'
  1352. f'建议结合业务场景规划具体的数据分析维度,'
  1353. f'以生成更具洞察力和指导意义的数据报告。',
  1354. }]
  1355. if num_cols and len(df) > 0:
  1356. top_col = num_cols[0]
  1357. chart_zone = get_chart_left_zone(content_top, 0.4, ctx=ctx)
  1358. text_zone = get_insight_right_zone(content_top, 0.4, ctx=ctx)
  1359. sample_vals = df[top_col['column_name']].dropna().head(10).tolist()
  1360. sample_labels = [f'记录{i+1}' for i in range(len(sample_vals))]
  1361. if sample_vals:
  1362. add_bar_chart(slide, sample_labels, sample_vals,
  1363. Emu(chart_zone.x), Emu(chart_zone.y),
  1364. Emu(chart_zone.width), Emu(chart_zone.height),
  1365. series_name=top_col.get('inferred_label', top_col['column_name']),
  1366. color=colors.get('primary'))
  1367. _add_structured_insight(slide, insight_items,
  1368. Emu(text_zone.x), Emu(text_zone.y),
  1369. Emu(text_zone.width), Emu(text_zone.height))
  1370. else:
  1371. zone = get_full_width_zone(content_top, ctx=ctx)
  1372. _add_structured_insight(slide, insight_items,
  1373. Emu(zone.x), Emu(zone.y),
  1374. Emu(zone.width), Emu(zone.height))
  1375. def _build_toc_page(prs, config, colors, fonts, template_profile):
  1376. slide = _duplicate_master_slide(prs, template_profile, 'toc')
  1377. active_pages = [p for p in config.pages if p.selected and p.page_type not in ('cover', 'toc', 'end')]
  1378. _replace_all_placeholders(slide, {
  1379. '{report_title}': config.title,
  1380. '{date}': config.period_str,
  1381. '{page_title}': '目录',
  1382. '{source}': config.source_label,
  1383. '{period}': f'2/{len(config.pages)}',
  1384. '{page_num}': '',
  1385. }, fonts)
  1386. for i, page in enumerate(active_pages[:6], 1):
  1387. _replace_placeholder(slide, f'{{chapter{i}_title}}', page.title, fonts)
  1388. _replace_placeholder(slide, f'{{chapter{i}_desc}}', page.conclusion_title or page.title, fonts)
  1389. def _build_kpi_overview_page(prs, config, metrics, colors, fonts, content_top, df=None, profile=None, ctx=None):
  1390. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), 'content')
  1391. page_title = '核心指标概览'
  1392. _replace_all_placeholders(slide, {
  1393. '{report_title}': config.title,
  1394. '{date}': config.period_str,
  1395. '{page_title}': page_title,
  1396. '{source}': config.source_label,
  1397. '{period}': '',
  1398. '{page_num}': '',
  1399. }, fonts)
  1400. kpi_items = []
  1401. primary_vals = {}
  1402. all_vals = {}
  1403. for md in config.metrics:
  1404. if md.metric_type.value == 'kpi' and md.selected:
  1405. val = metrics.get(md.name, 0)
  1406. display_val = format(val, md.format_spec) if isinstance(val, (int, float)) else str(val)
  1407. kpi_items.append({
  1408. 'label': md.label,
  1409. 'value': display_val,
  1410. 'unit': md.unit,
  1411. 'change': '',
  1412. 'sub': '',
  1413. })
  1414. if md.is_primary:
  1415. primary_vals[md.label] = val
  1416. all_vals[md.label] = val
  1417. if kpi_items:
  1418. kpi_count = len(kpi_items)
  1419. if kpi_count <= 3:
  1420. _add_kpi_cards(slide, kpi_items, start_y=Emu(content_top))
  1421. else:
  1422. shown_kpis = kpi_items[:9]
  1423. compact_card_h = Emu(1780000) if len(shown_kpis) <= 6 else Emu(1600000)
  1424. kpi_bottom = _add_compact_kpi_cards(
  1425. slide,
  1426. shown_kpis,
  1427. start_y=Emu(content_top),
  1428. card_h=compact_card_h,
  1429. gap_y=Emu(220000),
  1430. )
  1431. insight_items = []
  1432. kpi_names = [m.label for m in config.metrics if m.selected]
  1433. kpi_str = "、".join(kpi_names[:6]) if kpi_names else "各指标"
  1434. if len(kpi_names) > 6:
  1435. kpi_str += f'等{len(kpi_names)}项'
  1436. primary_kpis = [m for m in config.metrics if m.is_primary and m.selected]
  1437. if not primary_kpis:
  1438. primary_kpis = [m for m in config.metrics if m.selected][:3]
  1439. kpi_detail_parts = []
  1440. for i, pk in enumerate(primary_kpis):
  1441. val = all_vals.get(pk.label, 0)
  1442. unit_str = pk.unit if pk.unit else ''
  1443. display_val = format(val, pk.format_spec) if isinstance(val, (int, float)) else str(val)
  1444. kpi_detail_parts.append(f'{pk.label}: {display_val}{unit_str}')
  1445. insight_items.append({
  1446. 'title': '核心数据概览',
  1447. 'content': f'本期报告涵盖 {kpi_str} 共 {len(kpi_names)} 项核心指标。'
  1448. f'{";".join(kpi_detail_parts[:4])}。'
  1449. f'其中{"、".join(p.label for p in primary_kpis[:3])}为本次分析的重点关注指标。'
  1450. f'建议将这些指标与历史同期数据进行纵向对比,以及与行业基准进行横向对标,以全面评估当前业务健康度。'
  1451. f'对于波动较大的指标,需深入追溯其背后的业务动因,判断是否为趋势性变化还是季节性波动。',
  1452. })
  1453. cat_cols = profile.get('category_columns', []) if profile else []
  1454. num_cols = profile.get('numeric_columns', []) if profile else []
  1455. total_rows = profile.get('total_rows', 0) if profile else 0
  1456. if cat_cols:
  1457. top_cats = [c.get('inferred_label', c.get('column_name', '')) for c in cat_cols[:3]]
  1458. cat_details = []
  1459. for c in cat_cols[:3]:
  1460. uc = c.get('unique_count', 'N/A')
  1461. cat_details.append(f'{c.get("inferred_label", c.get("column_name", ""))}({uc}类)')
  1462. insight_items.append({
  1463. 'title': '数据覆盖与维度分析',
  1464. 'content': f'数据覆盖 {total_rows:,} 条记录,包含 {", ".join(cat_details)} 等多个分析维度。'
  1465. f'丰富的维度数据支持从 {", ".join(top_cats)} 等角度进行多维度联动分析。'
  1466. f'建议关注各维度下的数据分布特征,识别高贡献或异常的分类群体,'
  1467. f'针对性地分析不同维度的表现差异,为精细化运营和数据驱动决策提供支撑。',
  1468. })
  1469. if len(config.metrics) >= 3:
  1470. compare_items = []
  1471. for a, b in zip(primary_kpis[:2], primary_kpis[1:3]):
  1472. va = all_vals.get(a.label, 0)
  1473. vb = all_vals.get(b.label, 0)
  1474. if va and vb:
  1475. ratio = round(va / vb, 2) if vb else 0
  1476. compare_items.append(f'{a.label}与{b.label}的比值为 {ratio}')
  1477. if compare_items:
  1478. insight_items.append({
  1479. 'title': '指标间关联分析',
  1480. 'content': f'{";".join(compare_items)}。通过指标间的比值关系可以发现数据的内在规律,'
  1481. f'比值异常偏离正常区间时需重点关注。建议进一步计算各指标与核心业务目标之间的相关系数,'
  1482. f'量化不同指标对业务目标的影响力排序,将有限资源聚焦在驱动型指标上。',
  1483. })
  1484. else:
  1485. insight_items.append({
  1486. 'title': '指标间关联分析',
  1487. 'content': f'本期核心指标包括 {", ".join(p.label for p in primary_kpis[:3])}。'
  1488. f'建议通过散点图或相关系数分析探索指标间的线性/非线性关系,识别是否存在协同或对冲效应。'
  1489. f'同时建议按时间序列分析各指标的周期性规律,为资源配置和预测提供依据。',
  1490. })
  1491. insight_items.append({
  1492. 'title': '关键发现与行动建议',
  1493. 'content': f'综合分析 {len(kpi_names)} 项指标,建议重点关注以下方向:'
  1494. f'(1) 定期监控核心指标的趋势变化,建立异常预警机制,当指标偏离正常区间时及时触发排查流程;'
  1495. f'(2) 深化多维度交叉分析,挖掘不同群体间的结构差异,识别增长机会和风险点;'
  1496. f'(3) 结合业务经验和外部数据,验证数据指标的准确性和合理性;'
  1497. f'(4) 将分析结论转化为可执行的具体行动项,明确责任人和时间节点,建立跟踪闭环机制。',
  1498. })
  1499. if kpi_count > 9:
  1500. extra_names = '、'.join(k['label'] for k in kpi_items[9:15])
  1501. insight_items.append({
  1502. 'title': '更多核心指标说明',
  1503. 'content': f'本页优先展示前 9 个核心指标,其余 {kpi_count - 9} 个指标(如 {extra_names})'
  1504. f'已纳入综合分析口径。建议在页面结构确认阶段将核心指标按“结果指标、过程指标、风险指标”分组,'
  1505. f'必要时拆分为多页 KPI 看板,以保证每个指标都有足够的解释空间。',
  1506. })
  1507. if kpi_count <= 3:
  1508. kpi_grid_bottom = int(content_top) + Emu(3048000)
  1509. else:
  1510. kpi_grid_bottom = max(kpi_bottom, int(content_top) + Emu(1780000))
  1511. insight_zone_y = kpi_grid_bottom + Emu(254000)
  1512. remaining_height = int(FOOTER_TOP - insight_zone_y - Emu(140000))
  1513. if remaining_height >= Emu(950000):
  1514. if kpi_count <= 3:
  1515. compact_items = insight_items[:3]
  1516. else:
  1517. compact_items = insight_items[:3] if kpi_count <= 6 else insight_items[:4]
  1518. _add_structured_insight(slide, compact_items,
  1519. Emu(CONTENT_LEFT), Emu(insight_zone_y),
  1520. Emu(SLIDE_WIDTH - 2 * CONTENT_LEFT), Emu(remaining_height),
  1521. title_size=Pt(10), body_size=Pt(9), min_body_size=Pt(8))
  1522. elif kpi_count > 3:
  1523. fallback_top = max(insight_zone_y, int(FOOTER_TOP) - int(Emu(1250000)))
  1524. fallback_height = int(FOOTER_TOP - fallback_top - Emu(120000))
  1525. fallback_items = insight_items[:2]
  1526. _add_structured_insight(slide, fallback_items,
  1527. Emu(CONTENT_LEFT), Emu(fallback_top),
  1528. Emu(SLIDE_WIDTH - 2 * CONTENT_LEFT), Emu(max(fallback_height, Emu(850000))),
  1529. title_size=Pt(9), body_size=Pt(8), min_body_size=Pt(7))
  1530. def _build_trend_page(prs, config, df, profile, colors, fonts, content_top, ctx=None):
  1531. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1532. time_cols = profile.get('time_columns', [])
  1533. num_cols = profile.get('numeric_columns', [])
  1534. if not time_cols or not num_cols:
  1535. _remove_slide(prs, slide)
  1536. return False
  1537. time_col = time_cols[0]['column_name']
  1538. metric_col = num_cols[0]['column_name']
  1539. label = num_cols[0].get('inferred_label', metric_col)
  1540. page_title = f'{label}趋势'
  1541. _replace_all_placeholders(slide, {
  1542. '{report_title}': config.title,
  1543. '{date}': config.period_str,
  1544. '{page_title}': page_title,
  1545. '{source}': config.source_label,
  1546. '{period}': '',
  1547. '{page_num}': '',
  1548. }, fonts)
  1549. trend_data = calc_generic_trend(df, time_col, metric_col)
  1550. if trend_data.get('dates'):
  1551. chart_zone = get_chart_left_zone(content_top, 0.6, ctx=ctx)
  1552. text_zone = get_insight_right_zone(content_top, 0.6, ctx=ctx)
  1553. add_line_chart(slide, trend_data['dates'], trend_data['values'],
  1554. Emu(chart_zone.x), Emu(chart_zone.y),
  1555. Emu(chart_zone.width), Emu(chart_zone.height),
  1556. series_name=label, color=colors.get('primary'))
  1557. dates = trend_data['dates']
  1558. vals = trend_data['values']
  1559. n = len(vals)
  1560. first_v, last_v = vals[0], vals[-1]
  1561. change = last_v - first_v
  1562. change_pct = round(change / first_v * 100, 1) if first_v else 0
  1563. max_v = max(vals) if vals else 0
  1564. min_v = min(vals) if vals else 0
  1565. max_idx = vals.index(max_v) if vals else 0
  1566. min_idx = vals.index(min_v) if vals else 0
  1567. peak_date = dates[max_idx] if max_idx < len(dates) else 'N/A'
  1568. trough_date = dates[min_idx] if min_idx < len(dates) else 'N/A'
  1569. direction_text = '上升' if change > 0 else '下降' if change < 0 else '平稳'
  1570. volatility = round((max_v - min_v) / (sum(vals) / n) * 100, 1) if sum(vals) else 0 if vals else 0
  1571. insight_items = [
  1572. {
  1573. 'title': f'{label}整体趋势概况',
  1574. 'content': f'在报告周期内共采集 {n} 个时间点的数据,{label}'
  1575. f'从 {dates[0]} 的 {first_v:,.0f} 变动至 {dates[-1]} 的 {last_v:,.0f},'
  1576. f'整体{direction_text}{abs(change_pct):.1f}%,{direction_text}趋势{"显著" if abs(change_pct) > 20 else "温和" if abs(change_pct) > 5 else "较为平缓"}。'
  1577. f'数据变化轨迹反映出{"持续向好的增长态势" if direction_text == "上升" and abs(change_pct) > 10 else "温和改善的积极信号" if direction_text == "上升" else "回调盘整的阶段性特征" if direction_text == "下降" else "平稳运行的基本状态"},'
  1578. f'建议将当前趋势与业务目标和历史同期数据进行交叉对比,评估达成全年目标的可行性。如需更详尽的趋势分析,建议增加数据采集频度和时间跨度。',
  1579. },
  1580. {
  1581. 'title': '峰值与谷值分析',
  1582. 'content': f'周期内最高值出现在 {peak_date},为 {max_v:,.0f};'
  1583. f'最低值出现在 {trough_date},为 {min_v:,.0f}。'
  1584. f'极值差距 {max_v - min_v:,.0f},波动幅度 {volatility}%,'
  1585. f'{"波动显著,需关注异常节点的驱动因素,建议排查是否受节假日、促销活动、外部政策变化等因素影响" if volatility > 30 else "波动在可控范围内,但仍需对异常波动保持警觉"}{"." if volatility > 30 else ",建立异常值的快速预警和响应机制。"}',
  1586. },
  1587. {
  1588. 'title': '趋势阶段性特征',
  1589. 'content': f'前半程({dates[0]}至{dates[min(n//2, n-1)]})'
  1590. f'{"呈上升态势" if sum(vals[:n//2]) < sum(vals[n//2:]) else "呈下降态势" if sum(vals[:n//2]) > sum(vals[n//2:]) else "基本持平"},'
  1591. f'后半程均值为 {sum(vals[n//2:])/(n-n//2):,.0f}。建议结合业务事件节点深入分析拐点成因,'
  1592. f'重点关注是否存在季节性波动、周期性波动或外部冲击等结构性因素。'
  1593. f'若数据量较少,趋势解读应以业务经验为主,辅以数据验证。',
  1594. },
  1595. {
  1596. 'title': '业务启示',
  1597. 'content': f'综合趋势分析,当前数据反映出{"积极向好的发展态势" if direction_text == "上升" and abs(change_pct) > 10 else "温和稳定的运行动态" if abs(change_pct) <= 10 else "需重点关注的下行风险"}。'
  1598. f'建议{"加大资源投入以把握增长机遇,同时关注增速的可持续性,避免盲目扩张" if direction_text == "上升" else "排查下降原因并制定针对性应对措施,分析是短期波动还是长期趋势转折" if direction_text == "下降" else "保持当前运营节奏,同时关注潜在变化信号,适时调整策略" if direction_text == "平稳" else "继续观察数据走势"}。'
  1599. f'建议将数据与业务KPI目标进行对标分析,定期回顾趋势变化。',
  1600. },
  1601. ]
  1602. _add_structured_insight(slide, insight_items,
  1603. Emu(text_zone.x), Emu(text_zone.y),
  1604. Emu(text_zone.width), Emu(text_zone.height))
  1605. return True
  1606. return False
  1607. def _build_distribution_page(prs, config, df, profile, colors, fonts, content_top, page_def=None, ctx=None):
  1608. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1609. cat_cols = profile.get('category_columns', [])
  1610. num_cols = profile.get('numeric_columns', [])
  1611. if not cat_cols:
  1612. _remove_slide(prs, slide)
  1613. return False
  1614. elem = (page_def.elements or [{}])[0] if page_def else {}
  1615. cat_col = elem.get('category') or cat_cols[0]['column_name']
  1616. cat_label = elem.get('category_label') or next(
  1617. (c.get('inferred_label', cat_col) for c in cat_cols if c['column_name'] == cat_col), cat_col)
  1618. metric_col = elem.get('metric') or (num_cols[0]['column_name'] if num_cols else None)
  1619. metric_label = elem.get('metric_label') or (next(
  1620. (c.get('inferred_label', metric_col) for c in num_cols if c['column_name'] == metric_col), metric_col) if metric_col else '')
  1621. page_title = page_def.title if page_def and page_def.title else f'{cat_label}分布'
  1622. _replace_all_placeholders(slide, {
  1623. '{report_title}': config.title,
  1624. '{date}': config.period_str,
  1625. '{page_title}': page_title,
  1626. '{source}': config.source_label,
  1627. '{period}': '',
  1628. '{page_num}': '',
  1629. }, fonts)
  1630. dist = calc_generic_distribution(df, cat_col, metric_col, top_n=8)
  1631. if dist.get('categories'):
  1632. chart_zone = get_chart_left_zone(content_top, 0.55, ctx=ctx)
  1633. text_zone = get_insight_right_zone(content_top, 0.55, ctx=ctx)
  1634. if len(dist['categories']) <= 8:
  1635. add_doughnut_chart(slide, dist['categories'], dist['values'],
  1636. Emu(chart_zone.x), Emu(chart_zone.y),
  1637. Emu(chart_zone.width), Emu(chart_zone.height),
  1638. colors=colors.get('series'))
  1639. else:
  1640. add_bar_chart(slide, dist['categories'], dist['values'],
  1641. Emu(chart_zone.x), Emu(chart_zone.y),
  1642. Emu(chart_zone.width), Emu(chart_zone.height),
  1643. series_name=metric_label, color=colors.get('primary'))
  1644. cats, vals, pcts = dist['categories'], dist['values'], dist['percentages']
  1645. grand_total = sum(vals)
  1646. top3_pct = sum(pcts[:3])
  1647. top1_name, top1_val, top1_pct = cats[0], vals[0], pcts[0]
  1648. metric_suffix = metric_label if metric_label else '数量'
  1649. insight_items = [
  1650. {
  1651. 'title': f'{cat_label}分布概况',
  1652. 'content': f'共有 {len(cats)} 个不同的{cat_label},覆盖范围'
  1653. f'{"广泛" if len(cats) >= 8 else "较为丰富" if len(cats) >= 5 else "相对集中"}。'
  1654. f'前3名合计占比 {top3_pct:.1f}%,集中度'
  1655. f'{"较高,呈现显著的头部集中特征" if top3_pct > 70 else "中等,呈现梯度递减分布" if top3_pct > 50 else "较低,分布较为均衡"}。',
  1656. },
  1657. {
  1658. 'title': f'排名第一: {top1_name}',
  1659. 'content': f'{top1_name}以 {top1_val:,}{metric_suffix}(占比 {top1_pct:.1f}%)位居榜首,'
  1660. f'{"是第二名" + cats[1] + "的" + f"{round(top1_val/vals[1],1)}" + "倍,优势极为显著" if len(cats) > 1 else "是该维度中最重要的类别"}。'
  1661. f'该类别贡献了超过三分之一的{metric_label},是整体业务的基本盘和核心增长极。',
  1662. },
  1663. ]
  1664. if len(vals) >= 3:
  1665. top3_sum = sum(vals[:3])
  1666. tail_sum = sum(vals[3:])
  1667. tail_pct = sum(pcts[3:])
  1668. insight_items.append({
  1669. 'title': '长尾分布特征',
  1670. 'content': f'前三名累计 {top3_sum:,}{metric_suffix}({top3_pct:.1f}%),'
  1671. f'剩余 {len(cats)-3} 个合计 {tail_sum:,}{metric_suffix}({tail_pct:.1f}%),'
  1672. f'属于{"头部集中型分布" if top3_pct > 70 else "相对均衡分布" if top3_pct < 50 else "梯度递减型分布"}。'
  1673. f'头部贡献了绝大部分{metric_label},尾部虽数量众多但单个贡献有限。',
  1674. })
  1675. if len(vals) > 1:
  1676. avg_val = sum(vals) / len(vals)
  1677. cv = round(vals[0] / avg_val, 1) if avg_val else 0
  1678. median_idx = len(vals) // 2
  1679. median_val = vals[median_idx]
  1680. insight_items.append({
  1681. 'title': '差异化与离散度分析',
  1682. 'content': f'排名第一的{cat_label}{top1_name}的{metric_suffix}是全部分类均值的 {cv} 倍,'
  1683. f'中位数分类(第{median_idx+1}名)为 {median_val:,}{metric_suffix},'
  1684. f'表明该维度{"差异化显著,资源集中度较高" if cv > 3 else "差异化适中,各分类间差距可控" if cv > 1.5 else "分布较为均匀"}。'
  1685. f'头部与中位数的差距反映了{cat_label}维度上的分层特征,是运营资源重点倾斜方向。',
  1686. })
  1687. insight_items.append({
  1688. 'title': '业务启示',
  1689. 'content': f'建议重点关注 {cats[0]} 的增量拓展与存量维护,同时深入分析排名中位类别的提升空间。'
  1690. f'对于 {metric_label}贡献较小的尾部类别(如占比低于3%的分类),可评估是否优化资源配置、'
  1691. f'调整运营策略或将资源向高回报类别倾斜。结合{cat_label}维度持续跟踪分布变化,及时把握结构性机会。',
  1692. })
  1693. _add_structured_insight(slide, insight_items,
  1694. Emu(text_zone.x), Emu(text_zone.y),
  1695. Emu(text_zone.width), Emu(text_zone.height))
  1696. return True
  1697. return False
  1698. def _build_ranking_page(prs, config, df, profile, colors, fonts, content_top, page_def=None, ctx=None):
  1699. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1700. cat_cols = profile.get('category_columns', [])
  1701. num_cols = profile.get('numeric_columns', [])
  1702. if not cat_cols or not num_cols:
  1703. _remove_slide(prs, slide)
  1704. return False
  1705. elem = (page_def.elements or [{}])[0] if page_def else {}
  1706. rank_col = elem.get('category') or cat_cols[-1]['column_name']
  1707. rank_label = elem.get('category_label') or next(
  1708. (c.get('inferred_label', rank_col) for c in cat_cols if c['column_name'] == rank_col), rank_col)
  1709. metric_col = elem.get('metric') or num_cols[0]['column_name']
  1710. metric_label = elem.get('metric_label') or next(
  1711. (c.get('inferred_label', metric_col) for c in num_cols if c['column_name'] == metric_col), metric_col)
  1712. page_title = page_def.title if page_def and page_def.title else f'{rank_label}TOP排行'
  1713. _replace_all_placeholders(slide, {
  1714. '{report_title}': config.title,
  1715. '{date}': config.period_str,
  1716. '{page_title}': page_title,
  1717. '{source}': config.source_label,
  1718. '{period}': '',
  1719. '{page_num}': '',
  1720. }, fonts)
  1721. ranking = calc_generic_ranking(df, rank_col, metric_col, top_n=15)
  1722. if ranking:
  1723. chart_zone = get_chart_left_zone(content_top, 0.6, ctx=ctx)
  1724. text_zone = get_insight_right_zone(content_top, 0.6, ctx=ctx)
  1725. names = [r['name'] for r in ranking]
  1726. vals = [r['value'] for r in ranking]
  1727. add_bar_chart(slide, names, vals,
  1728. Emu(chart_zone.x), Emu(chart_zone.y),
  1729. Emu(chart_zone.width), Emu(chart_zone.height),
  1730. series_name=metric_label, color=colors.get('primary'))
  1731. total_val = sum(vals)
  1732. top3_names = [r['name'] for r in ranking[:3]]
  1733. top3_vals = [r['value'] for r in ranking[:3]]
  1734. top3_pct = [round(v / total_val * 100, 1) for v in top3_vals] if total_val else [0, 0, 0]
  1735. top1_vs_last = round(vals[0] / vals[-1], 1) if len(vals) > 1 and vals[-1] > 0 else 'N/A'
  1736. insight_items = [
  1737. {
  1738. 'title': f'{rank_label}TOP排行概况',
  1739. 'content': f'共展示 {len(ranking)} 个排名项,前3名分别为 {top3_names[0]}、{top3_names[1]}、'
  1740. f'{top3_names[2]},累计 {sum(top3_vals):,}{metric_label}({sum(top3_pct):.1f}%)。'
  1741. f'前三名合计贡献超过总量的三分之一,表明{rank_label}维度呈现{"显著的头部集中特征" if sum(top3_pct) > 60 else "梯度递减的分布格局" if sum(top3_pct) > 40 else "相对均衡的分布态势"}。',
  1742. },
  1743. {
  1744. 'title': f'榜首分析: {top3_names[0]}',
  1745. 'content': f'{top3_names[0]}以 {top3_vals[0]:,}{metric_label}(占比 {top3_pct[0]:.1f}%)位居榜首,'
  1746. f'{"是第2名" + top3_names[1] + "的" + f"{round(top3_vals[0]/top3_vals[1],1)}倍,领先优势显著" if len(ranking) > 1 and top3_vals[1] > 0 else "优势突出"}。'
  1747. f'作为排名第一的{rank_label},其业绩表现直接影响整体业务大盘,建议重点关注其可持续增长策略。',
  1748. },
  1749. {
  1750. 'title': '头部与尾部差距分析',
  1751. 'content': f'第1名与第{len(ranking)}名差距达 {top1_vs_last} 倍,'
  1752. f'前5名平均 {round(sum(vals[:5])/5):,}{metric_label},'
  1753. f'后5名平均 {round(sum(vals[-5:])/5):,}{metric_label},'
  1754. f'前后差距约 {round((sum(vals[:5])/5)/(sum(vals[-5:])/5),1) if sum(vals[-5:]) > 0 else "N/A"} 倍。'
  1755. 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 "梯度分布相对均衡,可针对性提升各层级表现"}。',
  1756. },
  1757. {
  1758. 'title': '累计贡献率与分层分析',
  1759. 'content': f'前5名累计贡献 {sum(vals[:5]):,}{metric_label}({round(sum(vals[:5])/total_val*100,1) if total_val else 0}%),'
  1760. f'前10名累计贡献 {sum(vals[:10]):,}{metric_label}({round(sum(vals[:10])/total_val*100,1) if total_val else 0}%),'
  1761. f'剩余 {len(ranking)-10} 名合计贡献 {sum(vals[10:]):,}{metric_label}({round(sum(vals[10:])/total_val*100,1) if total_val else 0}%)。'
  1762. f'从分层结构来看,可划分为三个梯队:第一梯队(前3名)为业绩核心贡献者,第二梯队(第4-8名)为稳定输出层,'
  1763. f'第三梯队(第9名及以后)为潜力提升层。',
  1764. },
  1765. {
  1766. 'title': '业务建议',
  1767. 'content': f'重点关注 {", ".join(top3_names)} 的发展动态,提炼其成功经验并推广至团队。'
  1768. f'对于排名靠后的{rank_label},可评估其增长潜力与资源匹配度,'
  1769. f'识别可突破的增量空间。建议建立{rank_label}的绩效考核与激励体系,'
  1770. f'通过标杆带动和梯队培养实现整体业绩提升。',
  1771. },
  1772. ]
  1773. _add_structured_insight(slide, insight_items,
  1774. Emu(text_zone.x), Emu(text_zone.y),
  1775. Emu(text_zone.width), Emu(text_zone.height))
  1776. return True
  1777. return False
  1778. def _build_summary_page(prs, config, metrics, profile, colors, fonts, content_top, page_def=None, ctx=None):
  1779. slide = _duplicate_master_slide(prs, _resolve_template_profile(config), "content")
  1780. page_title = page_def.title if page_def and page_def.title else '总结与建议'
  1781. _replace_all_placeholders(slide, {
  1782. '{report_title}': config.title,
  1783. '{date}': config.period_str,
  1784. '{page_title}': page_title,
  1785. '{source}': config.source_label,
  1786. '{period}': '',
  1787. '{page_num}': '',
  1788. }, fonts)
  1789. elem = (page_def.elements or [{}])[0] if page_def else {}
  1790. if elem.get('support_status') is not None:
  1791. status = elem['support_status']
  1792. dept = elem.get('support_by_dept', {})
  1793. sc = elem.get('support_count', 0)
  1794. cc = elem.get('closed_count', 0)
  1795. close_rate = round(cc / sc * 100, 1) if sc else 0
  1796. fully_closed = status.get('已闭环', 0)
  1797. partial_closed = status.get('部分闭环', 0)
  1798. not_closed = status.get('未闭环', 0)
  1799. insight_items = [{
  1800. 'title': '支持需求总览',
  1801. 'content': f'本期共产生 {sc} 项跨部门支持需求,其中已闭环 {cc} 项(含完全闭环 {fully_closed} 项、部分闭环 {partial_closed} 项),'
  1802. f'闭环率 {close_rate}%。未闭环需求 {sc - cc} 项(占比 {round((sc-cc)/sc*100,1) if sc else 0}%),'
  1803. f'闭环率{"较高,跨部门协作效率良好" if close_rate >= 60 else "处于中等水平,仍有提升空间" if close_rate >= 30 else "偏低,需重点关注闭环推动"}。'
  1804. f'跨部门支持是保障项目推进的重要环节,高效的闭环机制有助于提升客户满意度和订单转化效率。',
  1805. }]
  1806. if status:
  1807. total_status = sum(status.values())
  1808. fully_pct = round(fully_closed / total_status * 100, 1) if total_status else 0
  1809. partial_pct = round(partial_closed / total_status * 100, 1) if total_status else 0
  1810. not_pct = round(not_closed / total_status * 100, 1) if total_status else 0
  1811. insight_items.append({
  1812. 'title': '闭环状态明细',
  1813. 'content': f'已闭环 {fully_closed} 项({fully_pct}%)、部分闭环 {partial_closed} 项({partial_pct}%)、'
  1814. f'未闭环 {not_closed} 项({not_pct}%)。'
  1815. f'其中完全闭环占比{"超过七成,闭环质量较高" if fully_pct >= 70 else "处于中等水平" if fully_pct >= 40 else "偏低,需提升闭环完整性"}。'
  1816. f'部分闭环表明需求已部分满足但未完全解决,需持续跟踪至彻底闭环。',
  1817. })
  1818. if dept:
  1819. dept_top = list(dept.items())[:5]
  1820. dept_top_sum = sum(v for _, v in dept_top)
  1821. dept_total = sum(dept.values())
  1822. dept_str = '、'.join([f'{k}({v}项)' for k, v in dept_top])
  1823. avg_dept_load = round(dept_total / len(dept), 1) if dept else 0
  1824. max_dept = dept_top[0]
  1825. insight_items.append({
  1826. 'title': '支持部门工作量分布',
  1827. 'content': f'需求覆盖 {len(dept)} 个部门/科室,前5个部门承接 {dept_top_sum} 项({round(dept_top_sum/dept_total*100,1) if dept_total else 0}%)。'
  1828. f'Top部门:{dept_str}。其中{max_dept[0]}承接最多({max_dept[1]}项),'
  1829. f'平均每个部门承接 {avg_dept_load} 项。请关注工作量较大的部门资源分配是否充足,'
  1830. f'同时识别是否有部门长期未被分配需求(可能表明资源未充分利用)。',
  1831. })
  1832. if sc - cc > 0:
  1833. insight_items.append({
  1834. 'title': '未闭环需求跟进建议',
  1835. 'content': f'当前仍有 {sc - cc} 项需求未完成闭环。建议按以下策略推进:第一,按紧急程度和影响范围对未闭环需求进行优先级排序,'
  1836. f'高优需求指定专人负责限期解决;第二,建立周度闭环跟踪机制,定期更新需求处理进展;'
  1837. f'第三,对于跨部门协同的复杂需求,建议指定牵头部门统筹协调推进,'
  1838. f'并建立问题升级机制(当需求超期未解决时自动升级至更高层级协调)。',
  1839. })
  1840. insight_items.append({
  1841. 'title': '闭环效率提升建议',
  1842. 'content': f'为持续提升支持需求闭环效率,建议:一是建立标准化的需求流转流程,明确各环节责任人和响应时限;'
  1843. f'二是定期开展闭环案例复盘,提炼最佳实践并在团队内推广;'
  1844. f'三是建立闭环率考核指标,将闭环时效纳入部门协作评价体系,'
  1845. f'通过制度保障跨部门协作的效率和质量。',
  1846. })
  1847. else:
  1848. insight_items = generate_generic_insights(profile, metrics)
  1849. insight_items = _ensure_min_insight_items(
  1850. insight_items,
  1851. profile=profile,
  1852. metrics=metrics,
  1853. min_count=2,
  1854. context_label='总结页',
  1855. )
  1856. zone = get_full_width_zone(content_top, ctx=ctx)
  1857. _add_structured_insight(slide, insight_items,
  1858. Emu(zone.x), Emu(zone.y),
  1859. Emu(zone.width), Emu(zone.height))
  1860. def _build_end_page(prs, config, colors, fonts, template_profile):
  1861. slide = _duplicate_master_slide(prs, template_profile, "end")
  1862. total = len([p for p in config.pages if p.selected])
  1863. _add_footer_if_missing(slide, f'数据来源:{config.source_label} | {total}/{total}', colors=colors)
  1864. _replace_all_placeholders(slide, {
  1865. '{report_title}': config.title,
  1866. '{date}': config.period_str or '',
  1867. '{department}': config.source_label,
  1868. }, fonts)
  1869. # Remove empty KPI placeholders on end page (same as cover)
  1870. _remove_empty_cover_kpi_placeholders(slide)
  1871. def _find_metric_def_by_column(config, column):
  1872. for metric in getattr(config, 'metrics', []) or []:
  1873. if getattr(metric, 'column', None) == column:
  1874. return metric
  1875. return None
  1876. def _forecast_items_from_page_def(page_def, df, profile, metrics, config):
  1877. elem = (page_def.elements or [{}])[0] if page_def else {}
  1878. items = []
  1879. explicit_items = elem.get('forecast_items') or elem.get('goals')
  1880. if explicit_items:
  1881. for idx, item in enumerate(explicit_items[:6], 1):
  1882. title = item.get('title') or item.get('label') or f'预测项{idx}'
  1883. value = item.get('value') or item.get('number') or item.get('target') or 0
  1884. items.append({'title': str(title), 'number': value})
  1885. return items
  1886. metric_names = elem.get('metrics') or elem.get('metric_names') or []
  1887. for metric_name in metric_names[:6]:
  1888. if metric_name in metrics:
  1889. metric_def = next((m for m in getattr(config, 'metrics', []) if m.name == metric_name), None)
  1890. label = metric_def.label if metric_def else str(metric_name)
  1891. items.append({'title': label, 'number': metrics.get(metric_name, 0)})
  1892. if items:
  1893. return items
  1894. num_cols = profile.get('numeric_columns', []) if profile else []
  1895. keyword_cols = []
  1896. keywords = ('预测', 'forecast', '目标', '计划', 'target', 'plan')
  1897. for col in num_cols:
  1898. col_name = col.get('column_name', '')
  1899. label = col.get('inferred_label', col_name)
  1900. if any(k in str(col_name).lower() or k in str(label).lower() for k in keywords):
  1901. keyword_cols.append(col)
  1902. for col in keyword_cols[:6]:
  1903. col_name = col.get('column_name')
  1904. metric_def = _find_metric_def_by_column(config, col_name)
  1905. label = metric_def.label if metric_def else col.get('inferred_label', col_name)
  1906. if metric_def and metric_def.name in metrics:
  1907. value = metrics.get(metric_def.name, 0)
  1908. elif col_name in df.columns:
  1909. series = df[col_name].dropna()
  1910. value = int(series.sum()) if not series.empty else 0
  1911. else:
  1912. value = 0
  1913. items.append({'title': label, 'number': value})
  1914. return items
  1915. def _generic_forecast_insights(page_def, forecast_items, profile, metrics):
  1916. title = page_def.title if page_def else '预测与行动计划'
  1917. total = sum(float(item.get('number') or 0) for item in forecast_items)
  1918. item_desc = '、'.join(f"{item['title']} {item.get('number', 0):,.0f}" for item in forecast_items[:5])
  1919. if forecast_items:
  1920. return [
  1921. {
  1922. 'title': f'{title}目标概览',
  1923. 'content': f'本页围绕已确认的预测/计划指标展开,当前纳入 {len(forecast_items)} 个量化项,'
  1924. f'合计规模约 {total:,.0f}。主要项目包括:{item_desc}。'
  1925. f'这些指标应与本期实际结果、历史同期和资源约束一起判断,避免只看单点预测值。',
  1926. },
  1927. {
  1928. 'title': '达成路径与风险控制',
  1929. 'content': f'建议将预测目标拆解为“责任人、关键动作、时间节点、风险预案”四类信息。'
  1930. f'如果目标值明显高于本期实际表现,应同步确认新增订单、库存、产能、交付或预算等支撑条件;'
  1931. f'如果目标值低于当前趋势,则需要说明保守假设,防止业务团队误判资源投入强度。',
  1932. },
  1933. ]
  1934. total_rows = profile.get('total_rows', 0) if profile else 0
  1935. return [
  1936. {
  1937. 'title': f'{title}口径说明',
  1938. 'content': f'当前页面未检测到明确的预测或目标数值字段,因此以数据画像和核心指标进行预测口径说明。'
  1939. f'本期数据覆盖 {total_rows or "若干"} 条记录,建议在六项确认阶段明确预测指标、目标字段和统计口径,'
  1940. f'例如下月交付、销售目标、库存消化、需求闭环或风险事件数量。',
  1941. },
  1942. {
  1943. 'title': '补充数据建议',
  1944. 'content': f'为了生成更可靠的预测页,建议在源数据中补充至少一个预测/目标字段,并提供历史实际值用于校准。'
  1945. f'报告生成后应检查预测值是否与图表一致,文字洞察是否说明关键假设、达成路径和偏差处理机制。',
  1946. },
  1947. ]
  1948. def _build_forecast_page(prs, config, df, profile, metrics, colors, content_top, page_def=None):
  1949. slide = _duplicate_slide(prs, prs.slides[1])
  1950. page_title = page_def.title if page_def and page_def.title else '预测与行动计划'
  1951. _replace_all_placeholders(slide, {
  1952. '{report_title}': config.title,
  1953. '{date}': config.period_str,
  1954. '{page_title}': page_title,
  1955. '{source}': config.source_label,
  1956. '{period}': '',
  1957. '{page_num}': '',
  1958. }, fonts)
  1959. forecast_items = _forecast_items_from_page_def(page_def, df, profile, metrics, config)
  1960. if not forecast_items and metrics.get('next_month_goals'):
  1961. forecast_items = [
  1962. {'title': g['title'].split(':')[0], 'number': g.get('number', 0)}
  1963. for g in metrics.get('next_month_goals', [])[:6]
  1964. ]
  1965. chart_zone = get_chart_left_zone(content_top, 0.58, ctx=ctx)
  1966. text_zone = get_insight_right_zone(content_top, 0.58, ctx=ctx)
  1967. if forecast_items:
  1968. names = [item['title'] for item in forecast_items[:6]]
  1969. values = [float(item.get('number') or 0) for item in forecast_items[:6]]
  1970. add_column_chart(slide, names, values,
  1971. Emu(chart_zone.x), Emu(chart_zone.y),
  1972. Emu(chart_zone.width), Emu(min(chart_zone.height, Emu(5100000))),
  1973. series_name='预测/目标值', color=colors.get('accent', C_ACCENT),
  1974. category_axis_title='预测项', value_axis_title='数值')
  1975. is_monthly = (
  1976. getattr(config, 'period_type', None) == PeriodType.MONTHLY or
  1977. str(getattr(config, 'period_type', '')).lower() == 'monthly'
  1978. )
  1979. has_order_monthly_plan = bool(metrics.get('next_month_goals') or metrics.get('forecast_next'))
  1980. if is_monthly and has_order_monthly_plan:
  1981. insight_items = generate_deep_insights('monthly', 'monthly_plan', metrics)
  1982. else:
  1983. insight_items = []
  1984. insight_items = _generic_forecast_insights(page_def, forecast_items, profile, metrics) if not insight_items else insight_items
  1985. insight_items = _ensure_min_insight_items(insight_items, profile, metrics, context_label='预测页')
  1986. _add_structured_insight(slide, insight_items,
  1987. Emu(text_zone.x), Emu(text_zone.y),
  1988. Emu(text_zone.width), Emu(text_zone.height))
  1989. # ==============================================================================
  1990. # DAILY REPORT
  1991. # ==============================================================================
  1992. def build_daily_report(data_file: str, date: datetime, output_path: str,
  1993. department='海外事业部', source='海外订单日报系统'):
  1994. master_path = get_master_template('daily')
  1995. prs = Presentation(master_path)
  1996. content_top = _detect_content_top(prs.slides[1])
  1997. df = load_daily(data_file, date)
  1998. prev_date = date - timedelta(days=1)
  1999. try:
  2000. prev_df = load_daily(data_file, prev_date)
  2001. except Exception:
  2002. prev_df = None
  2003. metrics = calc_daily_metrics(df, prev_df)
  2004. prev_metrics = calc_daily_metrics(prev_df, None) if prev_df is not None else {}
  2005. date_str = date.strftime('%Y年%m月%d日')
  2006. period_str = date.strftime('%Y年%m月%d日')
  2007. # ---- Page 1: Cover ----
  2008. slide = _duplicate_slide(prs, prs.slides[0])
  2009. _replace_all_placeholders(slide, {
  2010. '{report_title}': '海外订单数据日报',
  2011. '{report_type}': '数据日报',
  2012. '{date}': date_str,
  2013. '{department}': department,
  2014. '{period}': period_str,
  2015. '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
  2016. }, fonts)
  2017. _add_footer_if_missing(slide, f'数据来源:{source} | 1/8', colors=colors)
  2018. cover_kpis = [
  2019. ('在跟订单', metrics['tracking_orders'], '单',
  2020. _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))),
  2021. ('订单总数量', f"{metrics['total_qty']:,}", '台',
  2022. _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0))),
  2023. ('今日已更新', metrics['updated_orders'], '单',
  2024. _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0))),
  2025. ('支持需求', metrics['support_requests'], '项', '需跨部门协调'),
  2026. ]
  2027. for i, (lbl, val, unit, chg) in enumerate(cover_kpis, 1):
  2028. _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
  2029. _replace_placeholder(slide, f'{{kpi{i}_value}}', str(val))
  2030. # ---- Page 2: KPI Overview ----
  2031. s2 = _duplicate_slide(prs, prs.slides[1])
  2032. _replace_all_placeholders(s2, {
  2033. '{report_title}': '海外订单数据日报',
  2034. '{date}': date_str,
  2035. '{page_title}': '今日核心指标概览',
  2036. '{source}': source,
  2037. '{period}': '2/8',
  2038. '{page_num}': '',
  2039. })
  2040. kpis = [
  2041. {'label': '在跟订单总数', 'value': metrics['tracking_orders'], 'unit': '单',
  2042. 'change': _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0)),
  2043. 'sub': '日均跟踪'},
  2044. {'label': '订单总数量', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
  2045. 'change': _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0)),
  2046. 'sub': '规模稳定'},
  2047. {'label': '今日已更新', 'value': metrics['updated_orders'], 'unit': '单',
  2048. 'change': _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0)),
  2049. 'sub': '团队活跃'},
  2050. {'label': '下月预测交付', 'value': metrics['forecast_next'], 'unit': '台',
  2051. 'change': _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0)),
  2052. 'sub': '交付预期'},
  2053. {'label': '支持需求总数', 'value': metrics['support_requests'], 'unit': '项',
  2054. 'change': '需跨部门协调', 'sub': '建议集中处理'},
  2055. {'label': '已发运订单', 'value': metrics['shipped_orders'], 'unit': '单',
  2056. 'change': f'共{metrics.get("shipped_orders", 0) * 8}台 | {metrics["shipped_orders"] - metrics.get("prev_shipped_orders", 0)}单 vs 昨日',
  2057. 'sub': '交付稳步推进'},
  2058. ]
  2059. _add_kpi_cards(s2, kpis, start_y=Emu(content_top))
  2060. # ---- Page 3: 10-Day Trend ----
  2061. s3 = _duplicate_slide(prs, prs.slides[1])
  2062. trend_dates = []
  2063. trend_vals = []
  2064. for d_offset in range(-9, 1):
  2065. d = date + timedelta(days=d_offset)
  2066. try:
  2067. tdf = load_daily(data_file, d)
  2068. trend_dates.append(d.strftime('%m/%d'))
  2069. trend_vals.append(len(tdf))
  2070. except Exception:
  2071. pass
  2072. # Generate conclusion title
  2073. trend_conclusion = '近10天订单趋势'
  2074. if len(trend_vals) >= 2:
  2075. if trend_vals[-1] > trend_vals[-2]:
  2076. trend_conclusion = '近10天订单趋势:订单规模回升'
  2077. elif trend_vals[-1] < trend_vals[-2]:
  2078. trend_conclusion = '近10天订单趋势:订单量继续回落'
  2079. peak = max(trend_vals)
  2080. if trend_vals[-1] == peak:
  2081. trend_conclusion = '近10天订单趋势:今日达到峰值'
  2082. _replace_all_placeholders(s3, {
  2083. '{report_title}': '海外订单数据日报',
  2084. '{date}': date_str,
  2085. '{page_title}': trend_conclusion,
  2086. '{source}': source,
  2087. '{period}': '3/8',
  2088. '{page_num}': '',
  2089. })
  2090. if len(trend_dates) >= 2:
  2091. add_column_chart(s3, trend_dates, trend_vals,
  2092. Emu(762000), Emu(content_top), Emu(8890000), Emu(5334000),
  2093. series_name='订单量', color=C_ACCENT,
  2094. category_axis_title='日期', value_axis_title='订单数')
  2095. insight_items = generate_deep_insights('daily', 'trend', metrics,
  2096. prev_metrics=prev_metrics,
  2097. trend_dates=trend_dates,
  2098. trend_vals=trend_vals)
  2099. _add_structured_insight(s3, insight_items,
  2100. Emu(9906000), Emu(content_top), Emu(4826000), Emu(5334000))
  2101. # ---- Page 4: Status Distribution ----
  2102. s4 = _duplicate_slide(prs, prs.slides[1])
  2103. status_names = list(metrics['status_dist'].keys())
  2104. status_vals = list(metrics['status_dist'].values())
  2105. total_status = sum(status_vals)
  2106. prod_share = metrics.get('production_share', 0)
  2107. status_title = '订单状态分布'
  2108. if prod_share > 0:
  2109. status_title = f'订单状态分布:生产端占比提升至{prod_share:.1f}%'
  2110. _replace_all_placeholders(s4, {
  2111. '{report_title}': '海外订单数据日报',
  2112. '{date}': date_str,
  2113. '{page_title}': status_title,
  2114. '{source}': source,
  2115. '{period}': '4/8',
  2116. '{page_num}': '',
  2117. })
  2118. if status_names and total_status > 0:
  2119. # Left donut chart: 55% width
  2120. chart_w = Emu(int(prs.slide_width) * 0.55)
  2121. add_doughnut_chart(s4, status_names, status_vals,
  2122. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2123. show_legend=True, show_data_labels=True, show_percent=True,
  2124. ring_ratio=0.6)
  2125. # Right side: status change + deep insights (no table to save space for dense text)
  2126. text_left = Emu(int(prs.slide_width) * 0.62)
  2127. text_w = Emu(int(prs.slide_width) * 0.36)
  2128. prev_status = prev_metrics.get('status_dist', {})
  2129. # Status change text: "合同拟定中 -30.8% ↓ | 已付订金待生产 +55.6% ↑"
  2130. changes = []
  2131. for name, curr in metrics['status_dist'].items():
  2132. prev = prev_status.get(name, 0)
  2133. if prev > 0:
  2134. chg = _pct_val(curr, prev)
  2135. if chg is not None:
  2136. arrow = '↑' if chg >= 0 else '↓'
  2137. changes.append(f'{name} {_format_pct(chg)}{arrow}')
  2138. if changes:
  2139. change_text = ' | '.join(changes[:4])
  2140. _add_text_block(s4, '状态变化(vs 昨日)', change_text,
  2141. text_left, Emu(content_top), text_w, Emu(609600),
  2142. title_size=Pt(12), body_size=Pt(10))
  2143. # Deep insight fills remaining right-side space
  2144. insight_items = generate_deep_insights('daily', 'status', metrics,
  2145. prev_status_dist=prev_status)
  2146. insight_top = Emu(int(content_top) + 685800)
  2147. insight_height = Emu(5334000 - 685800)
  2148. _add_structured_insight(s4, insight_items,
  2149. text_left, insight_top, text_w, insight_height, colors=colors)
  2150. # ---- Page 5: Owner Distribution ----
  2151. s5 = _duplicate_slide(prs, prs.slides[1])
  2152. owner_names = list(metrics['owner_dist'].keys())[:8]
  2153. owner_vals = list(metrics['owner_dist'].values())[:8]
  2154. top_owner = owner_names[0] if owner_names else ''
  2155. second_owner = owner_names[1] if len(owner_names) > 1 else ''
  2156. owner_title = '负责人订单分布'
  2157. if top_owner and second_owner:
  2158. owner_title = f'负责人订单分布:{top_owner}、{second_owner}领跑'
  2159. _replace_all_placeholders(s5, {
  2160. '{report_title}': '海外订单数据日报',
  2161. '{date}': date_str,
  2162. '{page_title}': owner_title,
  2163. '{source}': source,
  2164. '{period}': '5/8',
  2165. '{page_num}': '',
  2166. })
  2167. if owner_names:
  2168. chart_w = Emu(int(prs.slide_width) * 0.55)
  2169. text_left = Emu(int(prs.slide_width) * 0.62)
  2170. text_w = Emu(int(prs.slide_width) * 0.36)
  2171. add_horizontal_bar_chart(s5, owner_names, owner_vals,
  2172. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2173. series_name='订单笔数', color=C_ACCENT, reverse_order=True,
  2174. value_axis_title='订单笔数')
  2175. # Deep insight: owner distribution analysis
  2176. prev_owner_dist = prev_metrics.get('owner_dist', {}) if prev_metrics else {}
  2177. insight_items = generate_deep_insights('daily', 'owner', metrics,
  2178. prev_owner_dist=prev_owner_dist)
  2179. _add_structured_insight(s5, insight_items,
  2180. text_left, Emu(content_top), text_w, Emu(5334000))
  2181. # ---- Page 6: Country TOP8 ----
  2182. s6 = _duplicate_slide(prs, prs.slides[1])
  2183. countries = list(metrics['country_top8'].keys())[:8]
  2184. country_vals = list(metrics['country_top8'].values())[:8]
  2185. top_country = countries[0] if countries else ''
  2186. country_title = '目的国家TOP8'
  2187. if top_country:
  2188. country_title = f'目的国家TOP8:{top_country}订单量领先'
  2189. _replace_all_placeholders(s6, {
  2190. '{report_title}': '海外订单数据日报',
  2191. '{date}': date_str,
  2192. '{page_title}': country_title,
  2193. '{source}': source,
  2194. '{period}': '6/8',
  2195. '{page_num}': '',
  2196. })
  2197. if countries:
  2198. chart_w = Emu(int(prs.slide_width) * 0.55)
  2199. text_left = Emu(int(prs.slide_width) * 0.62)
  2200. text_w = Emu(int(prs.slide_width) * 0.36)
  2201. add_horizontal_bar_chart(s6, countries, country_vals,
  2202. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2203. series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
  2204. value_axis_title='订单量(台)')
  2205. # Deep insight: country top8 analysis
  2206. insight_items = generate_deep_insights('daily', 'country', metrics)
  2207. _add_structured_insight(s6, insight_items,
  2208. text_left, Emu(content_top), text_w, Emu(5334000))
  2209. # ---- Page 7: Support Analysis ----
  2210. s7 = _duplicate_slide(prs, prs.slides[1])
  2211. alerts = metrics['alerts']
  2212. alert_title = '异常告警'
  2213. if alerts:
  2214. severe = [a for a in alerts if a.get('level') == '严重']
  2215. if severe:
  2216. alert_title = f'异常告警:{severe[0]["title"]}'
  2217. else:
  2218. alert_title = f'异常告警:{alerts[0]["title"]}'
  2219. _replace_all_placeholders(s7, {
  2220. '{report_title}': '海外订单数据日报',
  2221. '{date}': date_str,
  2222. '{page_title}': alert_title,
  2223. '{source}': source,
  2224. '{period}': '7/8',
  2225. '{page_num}': '',
  2226. })
  2227. # Unified left-chart + right-insight layout
  2228. sc = metrics.get('support_categories', {})
  2229. chart_w = Emu(int(prs.slide_width) * 0.55)
  2230. text_left = Emu(int(prs.slide_width) * 0.62)
  2231. text_w = Emu(int(prs.slide_width) * 0.36)
  2232. if sc:
  2233. sc_names = list(sc.keys())
  2234. sc_vals = list(sc.values())
  2235. add_doughnut_chart(s7, sc_names, sc_vals,
  2236. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2237. show_legend=True, show_data_labels=True, show_percent=True,
  2238. ring_ratio=0.6)
  2239. # Deep insight: alerts & support analysis
  2240. insight_items = generate_deep_insights('daily', 'alert', metrics)
  2241. _add_structured_insight(s7, insight_items,
  2242. text_left, Emu(content_top), text_w, Emu(5334000))
  2243. # ---- Page 8: Key Points ----
  2244. s8 = _duplicate_slide(prs, prs.slides[1])
  2245. _replace_all_placeholders(s8, {
  2246. '{report_title}': '海外订单数据日报',
  2247. '{date}': date_str,
  2248. '{page_title}': '明日工作重点',
  2249. '{source}': source,
  2250. '{period}': '8/8',
  2251. '{page_num}': '',
  2252. })
  2253. # Left chart: overdue orders horizontal bar (or support categories fallback)
  2254. overdue = metrics.get('overdue_orders', [])
  2255. chart_w = Emu(int(prs.slide_width) * 0.55)
  2256. text_left = Emu(int(prs.slide_width) * 0.62)
  2257. text_w = Emu(int(prs.slide_width) * 0.36)
  2258. if overdue:
  2259. o_countries = [o['country'] for o in overdue[:8]]
  2260. o_days = [o['days'] for o in overdue[:8]]
  2261. add_horizontal_bar_chart(s8, o_countries, o_days,
  2262. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2263. series_name='超期天数', color=C_ORANGE, reverse_order=True,
  2264. value_axis_title='天数')
  2265. elif metrics.get('support_categories'):
  2266. sc = metrics['support_categories']
  2267. add_horizontal_bar_chart(s8, list(sc.keys()), list(sc.values()),
  2268. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2269. series_name='需求数', color=C_ACCENT, reverse_order=True,
  2270. value_axis_title='数量')
  2271. # Deep insight: tomorrow's action items
  2272. action_items = generate_deep_insights('daily', 'action', metrics)
  2273. _add_structured_insight(s8, action_items,
  2274. text_left, Emu(content_top), text_w, Emu(5334000))
  2275. for slide in prs.slides:
  2276. _ensure_word_wrap_all(slide)
  2277. _delete_template_slides(prs)
  2278. prs.save(output_path)
  2279. print(f"Daily report saved: {output_path}")
  2280. # ==============================================================================
  2281. # WEEKLY REPORT
  2282. # ==============================================================================
  2283. def build_weekly_report(data_file: str, year: int, week: int, output_path: str,
  2284. department='海外事业部', source='海外订单日报系统'):
  2285. master_path = get_master_template('weekly')
  2286. prs = Presentation(master_path)
  2287. content_top = _detect_content_top(prs.slides[1])
  2288. df, prev_df = load_weekly(data_file, year, week)
  2289. metrics = calc_weekly_metrics(df, prev_df)
  2290. period_str = f"{year}年第{week}周"
  2291. date_range_str = f"{df['_data_date'].min().strftime('%m/%d')} - {df['_data_date'].max().strftime('%m/%d')}"
  2292. # Page 1: Cover
  2293. slide = _duplicate_slide(prs, prs.slides[0])
  2294. _replace_all_placeholders(slide, {
  2295. '{report_title}': '海外订单数据周报',
  2296. '{report_type}': 'Weekly Overseas Order Data Report',
  2297. '{date}': period_str,
  2298. '{department}': department,
  2299. '{period}': date_range_str,
  2300. '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
  2301. }, fonts)
  2302. _add_footer_if_missing(slide, f'数据来源:{source} | 1/9', colors=colors)
  2303. # Fix {kpi4_label} {kpi4_value} with actual metric (下月预测交付)
  2304. cover_kpis = [
  2305. ('跟踪订单笔数', f"{metrics['tracking_orders']:,}", '笔',
  2306. _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))),
  2307. ('订单总数量', f"{metrics['total_qty']:,}", '台',
  2308. _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0))),
  2309. ('覆盖目的国', f"{metrics['countries']}", '个', '全球布局持续深化'),
  2310. ('下月预测交付', f"{metrics['forecast_next']:,}", '台',
  2311. _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0))),
  2312. ]
  2313. for i, (lbl, val, unit, chg) in enumerate(cover_kpis, 1):
  2314. _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
  2315. _replace_placeholder(slide, f'{{kpi{i}_value}}', str(val))
  2316. nav_labels = ['周汇总', '趋势图', '环比分析', '区域排行', '问题建议', '下周计划']
  2317. # Page 2: Weekly Summary (KPI cards)
  2318. s2 = _duplicate_slide(prs, prs.slides[1])
  2319. t_chg = _pct_val(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))
  2320. q_chg = _pct_val(metrics['total_qty'], metrics.get('prev_total_qty', 0))
  2321. t_chg_str = _format_pct(t_chg)
  2322. q_chg_str = _format_pct(q_chg)
  2323. _replace_all_placeholders(s2, {
  2324. '{report_title}': '海外订单数据周报',
  2325. '{date}': period_str,
  2326. '{page_title}': f"周汇总:跟踪订单环比{t_chg_str},订单总量增长{q_chg_str}",
  2327. '{source}': source,
  2328. '{period}': '2/9',
  2329. '{page_num}': '',
  2330. })
  2331. _add_nav_tabs(s2, nav_labels, active_index=0, slide_width=prs.slide_width, colors=colors)
  2332. kpis = [
  2333. {'label': '跟踪订单笔数', 'value': f"{metrics['tracking_orders']:,}", 'unit': '笔',
  2334. '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 '—'),
  2335. 'sub': f'日均{metrics["avg_daily_orders"]:.0f}笔'},
  2336. {'label': '订单总数量', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
  2337. '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 '—'),
  2338. 'sub': f'日均{metrics["avg_daily_qty"]:.0f}台'},
  2339. {'label': '已发运订单', 'value': metrics['shipped_orders'], 'unit': '笔',
  2340. 'change': _pct_str(metrics['shipped_orders'], metrics.get('prev_shipped_orders', 0)),
  2341. 'sub': '环比大幅提升'},
  2342. {'label': '覆盖目的国', 'value': metrics['countries'], 'unit': '个',
  2343. 'change': '持平', 'sub': '全球布局持续深化'},
  2344. {'label': '进度更新订单', 'value': metrics['updated_orders'], 'unit': '笔',
  2345. 'change': _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0)),
  2346. 'sub': '推进效率提升'},
  2347. {'label': '下月预测交付', 'value': f"{metrics['forecast_next']:,}", 'unit': '台',
  2348. 'change': _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0)),
  2349. 'sub': '交付预期大幅上调'},
  2350. ]
  2351. _add_kpi_cards(s2, kpis, start_y=Emu(content_top))
  2352. # Page 3: 7-Day Trend
  2353. s3 = _duplicate_slide(prs, prs.slides[1])
  2354. trend = metrics.get('daily_trend', {})
  2355. trend_title = '7日趋势:订单总量稳步上升'
  2356. if trend:
  2357. dates = list(trend.keys())
  2358. vals = list(trend.values())
  2359. if vals:
  2360. peak = max(vals)
  2361. peak_date = dates[vals.index(peak)]
  2362. if vals[-1] >= vals[0]:
  2363. trend_title = f'7日趋势:订单总量稳步上升,峰值出现在{peak_date}'
  2364. else:
  2365. trend_title = f'7日趋势:订单量有所波动,峰值出现在{peak_date}'
  2366. _replace_all_placeholders(s3, {
  2367. '{report_title}': '海外订单数据周报',
  2368. '{date}': period_str,
  2369. '{page_title}': trend_title,
  2370. '{source}': source,
  2371. '{period}': '3/9',
  2372. '{page_num}': '',
  2373. })
  2374. _add_nav_tabs(s3, nav_labels, active_index=1, slide_width=prs.slide_width, colors=colors)
  2375. trend = metrics.get('daily_trend', {})
  2376. if trend:
  2377. dates = list(trend.keys())
  2378. vals = list(trend.values())
  2379. chart_w = Emu(int(prs.slide_width) * 0.55)
  2380. text_left = Emu(int(prs.slide_width) * 0.62)
  2381. text_w = Emu(int(prs.slide_width) * 0.36)
  2382. add_line_chart(s3, dates, vals,
  2383. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2384. series_name='订单量', color=C_ACCENT,
  2385. category_axis_title='日期(MM/DD)', value_axis_title='订单数')
  2386. peak = max(vals) if vals else 0
  2387. peak_date = dates[vals.index(peak)] if vals else ''
  2388. avg = sum(vals) // len(vals) if vals else 0
  2389. prev_avg = metrics.get('prev_avg_daily_orders', 0)
  2390. above_days = metrics.get('days_above_prev_avg', 0)
  2391. total_days = len(vals)
  2392. # Deep insight: weekly trend analysis
  2393. insight_items = generate_deep_insights('weekly', 'weekly_trend', metrics,
  2394. trend_dates=dates, trend_vals=vals)
  2395. _add_structured_insight(s3, insight_items,
  2396. text_left, Emu(content_top), text_w, Emu(5334000))
  2397. # Page 4: WoW Analysis
  2398. s4 = _duplicate_slide(prs, prs.slides[1])
  2399. _replace_all_placeholders(s4, {
  2400. '{report_title}': '海外订单数据周报',
  2401. '{date}': period_str,
  2402. '{page_title}': '环比分析:各阶段全面增长,已发运环节增幅最大',
  2403. '{source}': source,
  2404. '{period}': '4/9',
  2405. '{page_num}': '',
  2406. })
  2407. _add_nav_tabs(s4, nav_labels, active_index=2, slide_width=prs.slide_width, colors=colors)
  2408. sw_data = metrics.get('status_wow', {})
  2409. if sw_data:
  2410. names = list(sw_data.keys())
  2411. current_vals = [v['current'] for v in sw_data.values()]
  2412. previous_vals = [v['previous'] for v in sw_data.values()]
  2413. # Replace None with 0 for chart data to avoid crashes
  2414. changes = [v['change_pct'] if v['change_pct'] is not None else 0 for v in sw_data.values()]
  2415. chart_w = Emu(int(prs.slide_width) * 0.55)
  2416. text_left = Emu(int(prs.slide_width) * 0.62)
  2417. text_w = Emu(int(prs.slide_width) * 0.36)
  2418. # Grouped bar chart: 本期 vs 上期
  2419. add_grouped_bar_chart(s4, names,
  2420. [('本期', current_vals), ('上期', previous_vals)],
  2421. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2422. colors=[C_ACCENT, C_SECONDARY],
  2423. show_legend=True, show_data_labels=True,
  2424. category_axis_title='订单状态', value_axis_title='订单数')
  2425. # WoW增幅 labels below chart
  2426. wow_label_items = []
  2427. for k, v in sw_data.items():
  2428. pct_str = _format_pct(v['change_pct'])
  2429. arrow = '↑' if v['change_pct'] is not None and v['change_pct'] >= 0 else '↓' if v['change_pct'] is not None else ''
  2430. wow_label_items.append(f'{k} {pct_str}{arrow}')
  2431. _add_text_block(s4, '环比增幅', ' | '.join(wow_label_items, colors=colors),
  2432. Emu(762000), Emu(6604000), chart_w, Emu(609600),
  2433. title_size=Pt(11), body_size=Pt(10))
  2434. # Deep insight: WoW analysis
  2435. insight_items = generate_deep_insights('weekly', 'weekly_wow', metrics)
  2436. _add_structured_insight(s4, insight_items,
  2437. text_left, Emu(content_top), text_w, Emu(4826000))
  2438. # Page 5: Region Distribution
  2439. s5 = _duplicate_slide(prs, prs.slides[1])
  2440. _replace_all_placeholders(s5, {
  2441. '{report_title}': '海外订单数据周报',
  2442. '{date}': period_str,
  2443. '{page_title}': '区域分布:中东增速领跑,欧洲为唯一下滑区域',
  2444. '{source}': source,
  2445. '{period}': '5/9',
  2446. '{page_num}': '',
  2447. })
  2448. _add_nav_tabs(s5, nav_labels, active_index=3, slide_width=prs.slide_width, colors=colors)
  2449. rdist = metrics.get('region_dist', {})
  2450. if rdist:
  2451. regions = list(rdist.keys())
  2452. qtys = [v['qty'] for v in rdist.values()]
  2453. chart_w = Emu(int(prs.slide_width) * 0.55)
  2454. text_left = Emu(int(prs.slide_width) * 0.62)
  2455. text_w = Emu(int(prs.slide_width) * 0.36)
  2456. add_doughnut_chart(s5, regions, qtys,
  2457. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2458. show_legend=True, show_data_labels=True, show_percent=True,
  2459. ring_ratio=0.6)
  2460. # Deep insight: regional analysis
  2461. insight_items = generate_deep_insights('weekly', 'weekly_region', metrics)
  2462. _add_structured_insight(s5, insight_items,
  2463. text_left, Emu(content_top), text_w, Emu(5334000))
  2464. # Page 6: TOP Countries
  2465. s6 = _duplicate_slide(prs, prs.slides[1])
  2466. _replace_all_placeholders(s6, {
  2467. '{report_title}': '海外订单数据周报',
  2468. '{date}': period_str,
  2469. '{page_title}': 'TOP国家排行:科威特344台居首,TOP15贡献70%+总量',
  2470. '{source}': source,
  2471. '{period}': '6/9',
  2472. '{page_num}': '',
  2473. })
  2474. _add_nav_tabs(s6, nav_labels, active_index=3, slide_width=prs.slide_width, colors=colors)
  2475. topc_change = metrics.get('top_countries_change', {})
  2476. if topc_change:
  2477. countries = list(topc_change.keys())[:10]
  2478. vals = [v['qty'] for v in list(topc_change.values())[:10]]
  2479. chart_w = Emu(int(prs.slide_width) * 0.55)
  2480. text_left = Emu(int(prs.slide_width) * 0.62)
  2481. text_w = Emu(int(prs.slide_width) * 0.36)
  2482. add_horizontal_bar_chart(s6, countries, vals,
  2483. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2484. series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
  2485. value_axis_title='订单量(台)')
  2486. # Deep insight: top countries analysis
  2487. insight_items = generate_deep_insights('weekly', 'weekly_country', metrics)
  2488. _add_structured_insight(s6, insight_items,
  2489. text_left, Emu(content_top), text_w, Emu(4826000))
  2490. # Page 7: Team Tracking
  2491. s7 = _duplicate_slide(prs, prs.slides[1])
  2492. team = metrics.get('team', {})
  2493. n_members = len(team.get('owners', {})) if team else 0
  2494. n_growers = sum(1 for v in metrics.get('team_wow', {}).values() if v.get('change', 0) > 0)
  2495. team_title = f'团队追踪:{n_members}人团队全面覆盖,{n_growers}人实现增长'
  2496. _replace_all_placeholders(s7, {
  2497. '{report_title}': '海外订单数据周报',
  2498. '{date}': period_str,
  2499. '{page_title}': team_title,
  2500. '{source}': source,
  2501. '{period}': '7/9',
  2502. '{page_num}': '',
  2503. })
  2504. _add_nav_tabs(s7, nav_labels, active_index=4, slide_width=prs.slide_width, colors=colors)
  2505. team = metrics.get('team', {})
  2506. owners = team.get('owners', {})
  2507. if owners:
  2508. names = list(owners.keys())
  2509. vals = list(owners.values())
  2510. chart_w = Emu(int(prs.slide_width) * 0.55)
  2511. text_left = Emu(int(prs.slide_width) * 0.62)
  2512. text_w = Emu(int(prs.slide_width) * 0.36)
  2513. add_horizontal_bar_chart(s7, names, vals,
  2514. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2515. series_name='订单笔数', color=C_ACCENT, reverse_order=True,
  2516. value_axis_title='订单笔数')
  2517. # Deep insight: team tracking
  2518. insight_items = generate_deep_insights('weekly', 'weekly_team', metrics)
  2519. _add_structured_insight(s7, insight_items,
  2520. text_left, Emu(content_top), text_w, Emu(4826000))
  2521. # Page 8: Issues
  2522. s8 = _duplicate_slide(prs, prs.slides[1])
  2523. _replace_all_placeholders(s8, {
  2524. '{report_title}': '海外订单数据周报',
  2525. '{date}': period_str,
  2526. '{page_title}': '问题识别:系统数据匹配问题为本周首要障碍',
  2527. '{source}': source,
  2528. '{period}': '8/9',
  2529. '{page_num}': '',
  2530. })
  2531. _add_nav_tabs(s8, nav_labels, active_index=4, slide_width=prs.slide_width, colors=colors)
  2532. # Left chart: support request categories
  2533. sc = metrics.get('support_categories', {})
  2534. chart_w = Emu(int(prs.slide_width) * 0.55)
  2535. text_left = Emu(int(prs.slide_width) * 0.62)
  2536. text_w = Emu(int(prs.slide_width) * 0.36)
  2537. if sc:
  2538. add_horizontal_bar_chart(s8, list(sc.keys()), list(sc.values()),
  2539. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2540. series_name='需求数', color=C_ORANGE, reverse_order=True,
  2541. value_axis_title='数量')
  2542. # Right: deep insight
  2543. insight_items = generate_deep_insights('weekly', 'weekly_issue', metrics)
  2544. _add_structured_insight(s8, insight_items,
  2545. text_left, Emu(content_top), text_w, Emu(5334000))
  2546. # Page 9: Next Week Plan
  2547. s9 = _duplicate_slide(prs, prs.slides[1])
  2548. _replace_all_placeholders(s9, {
  2549. '{report_title}': '海外订单数据周报',
  2550. '{date}': period_str,
  2551. '{page_title}': '下周计划:聚焦发运交付,冲刺交付目标',
  2552. '{source}': source,
  2553. '{period}': '9/9',
  2554. '{page_num}': '',
  2555. })
  2556. _add_nav_tabs(s9, nav_labels, active_index=5, slide_width=prs.slide_width, colors=colors)
  2557. # Left chart: goals as column chart
  2558. goals = metrics.get('next_week_goals', [])
  2559. chart_w = Emu(int(prs.slide_width) * 0.55)
  2560. text_left = Emu(int(prs.slide_width) * 0.62)
  2561. text_w = Emu(int(prs.slide_width) * 0.36)
  2562. if goals:
  2563. goal_names = [g['title'].split(':')[0] for g in goals[:4]]
  2564. goal_nums = [g.get('number', 0) for g in goals[:4]]
  2565. add_column_chart(s9, goal_names, goal_nums,
  2566. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2567. series_name='目标数量', color=C_ACCENT,
  2568. category_axis_title='目标', value_axis_title='数量')
  2569. # Deep insight: next week plan
  2570. insight_items = generate_deep_insights('weekly', 'weekly_plan', metrics)
  2571. _add_structured_insight(s9, insight_items,
  2572. text_left, Emu(content_top), text_w, Emu(5334000))
  2573. for slide in prs.slides:
  2574. _ensure_word_wrap_all(slide)
  2575. _delete_template_slides(prs)
  2576. prs.save(output_path)
  2577. print(f"Weekly report saved: {output_path}")
  2578. # ==============================================================================
  2579. # MONTHLY REPORT
  2580. # ==============================================================================
  2581. def build_monthly_report(data_file: str, year: int, month: int, output_path: str,
  2582. department='海外事业部', source='海外订单日报系统'):
  2583. master_path = get_master_template('monthly')
  2584. prs = Presentation(master_path)
  2585. content_top = _detect_content_top(prs.slides[1])
  2586. df, prev_df, yoy_df = load_monthly(data_file, year, month)
  2587. metrics = calc_monthly_metrics(df, prev_df, yoy_df)
  2588. period_str = f"{year}年{month}月"
  2589. # Page 1: Cover
  2590. slide = _duplicate_slide(prs, prs.slides[0])
  2591. _replace_all_placeholders(slide, {
  2592. '{report_title}': '海外订单月度数据报告',
  2593. '{report_type}': 'Monthly Overseas Order Data Report',
  2594. '{date}': period_str,
  2595. '{department}': department,
  2596. '{period}': period_str,
  2597. '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
  2598. }, fonts)
  2599. _add_footer_if_missing(slide, f'数据来源:{source} | 1/11', colors=colors)
  2600. cover_kpis = [
  2601. ('合同总数', f"{metrics['total_contracts']:,}"),
  2602. ('车辆总数', f"{metrics['total_qty']:,}"),
  2603. ('目的国家', f"{metrics['countries']}+"),
  2604. ('负责团队', '9人'),
  2605. ]
  2606. for i, (lbl, val) in enumerate(cover_kpis, 1):
  2607. _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
  2608. _replace_placeholder(slide, f'{{kpi{i}_value}}', val)
  2609. # Page 2: TOC
  2610. s_toc = _duplicate_slide(prs, prs.slides[2])
  2611. _add_footer_if_missing(s_toc, f'数据来源:{source} | 2/11', colors=colors)
  2612. _replace_all_placeholders(s_toc, {
  2613. '{chapter1_title}': '月度总览',
  2614. '{chapter1_desc}': '核心KPI一览:合同总数、车辆规模、新签与发运表现',
  2615. '{chapter2_title}': '订单状态分析',
  2616. '{chapter2_desc}': '订单阶段漏斗:从合同拟定到发运的全流程追踪',
  2617. '{chapter3_title}': '区域与趋势',
  2618. '{chapter3_desc}': '区域分布、国家排名与30日追踪趋势',
  2619. '{chapter4_title}': '团队与展望',
  2620. '{chapter4_desc}': '团队绩效、支持需求与下月工作规划',
  2621. })
  2622. nav_labels = ['月度总览', '订单状态', '区域趋势', '团队展望']
  2623. # Page 3: Monthly Overview
  2624. s3 = _duplicate_slide(prs, prs.slides[1])
  2625. _replace_all_placeholders(s3, {
  2626. '{report_title}': '海外订单月度数据报告',
  2627. '{date}': period_str,
  2628. '{page_title}': f"{month}月核心指标:累计追踪{metrics['total_contracts']:,}单,覆盖{metrics['total_qty']:,}台车辆",
  2629. '{source}': source,
  2630. '{period}': '3/11',
  2631. '{page_num}': '',
  2632. })
  2633. _add_nav_tabs(s3, nav_labels, active_index=0, slide_width=prs.slide_width, colors=colors)
  2634. kpis = [
  2635. {'label': '合同总数', 'value': f"{metrics['total_contracts']:,}", 'unit': '单',
  2636. 'change': f'日均{metrics["avg_daily_orders"]:.0f}单', 'sub': '覆盖全月'},
  2637. {'label': '车辆总数', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
  2638. 'change': f"{metrics['shipped_qty']:,}台已发运", 'sub': '交付持续推进'},
  2639. {'label': f'{month}月新签', 'value': metrics['new_contracts'], 'unit': '单',
  2640. 'change': f"{metrics['new_qty']:,}台", 'sub': '新签势头良好'},
  2641. {'label': '已发运', 'value': metrics['shipped_orders'], 'unit': '单',
  2642. 'change': f"{metrics['shipped_qty']:,}台", 'sub': '交付稳步推进'},
  2643. {'label': '目的国', 'value': f"{metrics['countries']}+", 'unit': '个',
  2644. 'change': '全球市场布局', 'sub': '持续深化'},
  2645. {'label': '待处理需求', 'value': metrics['support_count'], 'unit': '单',
  2646. 'change': f"{metrics['support_pct']}%订单涉及", 'sub': '需跨部门协调'},
  2647. ]
  2648. _add_kpi_cards(s3, kpis, start_y=Emu(content_top))
  2649. # Monthly overview: KPI cards only, no bottom insight text to avoid overlap with cards
  2650. # Page 4: Status Funnel
  2651. s4 = _duplicate_slide(prs, prs.slides[1])
  2652. _replace_all_placeholders(s4, {
  2653. '{report_title}': '海外订单月度数据报告',
  2654. '{date}': period_str,
  2655. '{page_title}': '订单阶段漏斗:合同拟定与生产中订单占主导地位',
  2656. '{source}': source,
  2657. '{period}': '4/11',
  2658. '{page_num}': '',
  2659. })
  2660. _add_nav_tabs(s4, nav_labels, active_index=1, slide_width=prs.slide_width, colors=colors)
  2661. funnel = metrics.get('status_funnel', {})
  2662. if funnel:
  2663. names = list(funnel.keys())
  2664. orders = [v['orders'] for v in funnel.values()]
  2665. chart_w = Emu(int(prs.slide_width) * 0.55)
  2666. text_left = Emu(int(prs.slide_width) * 0.62)
  2667. text_w = Emu(int(prs.slide_width) * 0.36)
  2668. add_funnel_chart(s4, names, orders,
  2669. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2670. show_data_labels=True, show_percent=True)
  2671. # Deep insight: monthly funnel
  2672. insight_items = generate_deep_insights('monthly', 'monthly_funnel', metrics)
  2673. _add_structured_insight(s4, insight_items,
  2674. text_left, Emu(content_top), text_w, Emu(5334000))
  2675. # Page 5: Region Distribution
  2676. s5 = _duplicate_slide(prs, prs.slides[1])
  2677. _replace_all_placeholders(s5, {
  2678. '{report_title}': '海外订单月度数据报告',
  2679. '{date}': period_str,
  2680. '{page_title}': '区域分布:拉美、东南亚、非洲三大市场并驾齐驱',
  2681. '{source}': source,
  2682. '{period}': '5/11',
  2683. '{page_num}': '',
  2684. })
  2685. _add_nav_tabs(s5, nav_labels, active_index=2, slide_width=prs.slide_width, colors=colors)
  2686. rdist = metrics.get('region_dist', {})
  2687. if rdist:
  2688. regions = list(rdist.keys())
  2689. qtys = [v['qty'] for v in rdist.values()]
  2690. chart_w = Emu(int(prs.slide_width) * 0.55)
  2691. text_left = Emu(int(prs.slide_width) * 0.62)
  2692. text_w = Emu(int(prs.slide_width) * 0.36)
  2693. add_doughnut_chart(s5, regions, qtys,
  2694. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2695. show_legend=True, show_data_labels=True, show_percent=True,
  2696. ring_ratio=0.6)
  2697. # Deep insight: monthly region
  2698. insight_items = generate_deep_insights('monthly', 'monthly_region', metrics)
  2699. _add_structured_insight(s5, insight_items,
  2700. text_left, Emu(content_top), text_w, Emu(5334000))
  2701. # Page 6: TOP10 Countries
  2702. s6 = _duplicate_slide(prs, prs.slides[1])
  2703. top10 = metrics.get('top_countries', {})
  2704. top_country = list(top10.keys())[0] if top10 else ''
  2705. top_qty = list(top10.values())[0]['qty'] if top10 else 0
  2706. top10_title = f'Top 10目的国:{top_country}{top_qty:,}台领跑' if top_country else 'Top 10目的国:重点市场领跑'
  2707. _replace_all_placeholders(s6, {
  2708. '{report_title}': '海外订单月度数据报告',
  2709. '{date}': period_str,
  2710. '{page_title}': top10_title,
  2711. '{source}': source,
  2712. '{period}': '6/11',
  2713. '{page_num}': '',
  2714. })
  2715. _add_nav_tabs(s6, nav_labels, active_index=2, slide_width=prs.slide_width, colors=colors)
  2716. top10 = metrics.get('top_countries', {})
  2717. if top10:
  2718. countries = list(top10.keys())
  2719. qtys = [v['qty'] for v in top10.values()]
  2720. chart_w = Emu(int(prs.slide_width) * 0.55)
  2721. text_left = Emu(int(prs.slide_width) * 0.62)
  2722. text_w = Emu(int(prs.slide_width) * 0.36)
  2723. add_horizontal_bar_chart(s6, countries, qtys,
  2724. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2725. series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
  2726. value_axis_title='订单量(台)')
  2727. # Deep insight: monthly top countries
  2728. insight_items = generate_deep_insights('monthly', 'monthly_country', metrics)
  2729. _add_structured_insight(s6, insight_items,
  2730. text_left, Emu(content_top), text_w, Emu(4826000))
  2731. # Page 7: 30-Day Trend
  2732. s7 = _duplicate_slide(prs, prs.slides[1])
  2733. _replace_all_placeholders(s7, {
  2734. '{report_title}': '海外订单月度数据报告',
  2735. '{date}': period_str,
  2736. '{page_title}': '30日追踪趋势:下旬订单活跃度显著提升',
  2737. '{source}': source,
  2738. '{period}': '7/11',
  2739. '{page_num}': '',
  2740. })
  2741. _add_nav_tabs(s7, nav_labels, active_index=2, slide_width=prs.slide_width, colors=colors)
  2742. trend = metrics.get('daily_trend', {})
  2743. chart_w = Emu(int(prs.slide_width) * 0.55)
  2744. text_left = Emu(int(prs.slide_width) * 0.62)
  2745. text_w = Emu(int(prs.slide_width) * 0.36)
  2746. if trend:
  2747. dates = list(trend.keys())
  2748. vals = list(trend.values())
  2749. add_line_chart(s7, dates, vals,
  2750. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  2751. series_name='订单量', color=C_ACCENT,
  2752. category_axis_title='日期(MM/DD)', value_axis_title='订单数')
  2753. tbp = metrics.get('trend_by_period', {})
  2754. late_change = tbp.get('late_change_pct', 0)
  2755. late_change_str = _format_pct(late_change, with_sign=True) if late_change is not None else '—'
  2756. # Deep insight: monthly trend
  2757. insight_items = generate_deep_insights('monthly', 'monthly_trend', metrics)
  2758. _add_structured_insight(s7, insight_items,
  2759. text_left, Emu(content_top), text_w, Emu(5334000))
  2760. # Page 8: Team Performance
  2761. s8 = _duplicate_slide(prs, prs.slides[1])
  2762. _replace_all_placeholders(s8, {
  2763. '{report_title}': '海外订单月度数据报告',
  2764. '{date}': period_str,
  2765. '{page_title}': '团队绩效:9位负责人均匀分布,多人领跑',
  2766. '{source}': source,
  2767. '{period}': '8/11',
  2768. '{page_num}': '',
  2769. })
  2770. _add_nav_tabs(s8, nav_labels, active_index=3, slide_width=prs.slide_width, colors=colors)
  2771. team = metrics.get('team', {})
  2772. if team:
  2773. names = list(team.keys())
  2774. orders = [v['orders'] for v in team.values()]
  2775. qtys = [v['qty'] for v in team.values()]
  2776. chart_w = Emu(int(prs.slide_width) * 0.55)
  2777. text_left = Emu(int(prs.slide_width) * 0.62)
  2778. text_w = Emu(int(prs.slide_width) * 0.36)
  2779. # Horizontal bar chart for orders + secondary series for qty
  2780. add_grouped_bar_chart(s8, names,
  2781. [('订单数', orders), ('车辆数', qtys)],
  2782. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2783. colors=[C_ACCENT, C_ORANGE],
  2784. show_legend=True, show_data_labels=True,
  2785. category_axis_title='负责人', value_axis_title='数量')
  2786. # Deep insight: monthly team
  2787. insight_items = generate_deep_insights('monthly', 'monthly_team', metrics)
  2788. _add_structured_insight(s8, insight_items,
  2789. text_left, Emu(content_top), text_w, Emu(4826000))
  2790. # Page 9: Support Analysis
  2791. s9 = _duplicate_slide(prs, prs.slides[1])
  2792. _replace_all_placeholders(s9, {
  2793. '{report_title}': '海外订单月度数据报告',
  2794. '{date}': period_str,
  2795. '{page_title}': '支持需求分析:财务、售后、法务为三大核心诉求',
  2796. '{source}': source,
  2797. '{period}': '9/11',
  2798. '{page_num}': '',
  2799. })
  2800. _add_nav_tabs(s9, nav_labels, active_index=3, slide_width=prs.slide_width, colors=colors)
  2801. sc = metrics.get('support_categories', {})
  2802. if sc:
  2803. cats = list(sc.keys())
  2804. vals = list(sc.values())
  2805. add_horizontal_bar_chart(s9, cats, vals,
  2806. Emu(762000), Emu(content_top), Emu(8636000), Emu(5334000),
  2807. series_name='需求数', color=C_ACCENT, reverse_order=True,
  2808. value_axis_title='需求数')
  2809. top_cat = max(sc.items(), key=lambda x: x[1])
  2810. # Deep insight: monthly support
  2811. insight_items = generate_deep_insights('monthly', 'monthly_support', metrics)
  2812. _add_structured_insight(s9, insight_items,
  2813. Emu(9779000), Emu(content_top), Emu(5715000), Emu(5334000))
  2814. # Page 10: Next Month Plan
  2815. s10 = _duplicate_slide(prs, prs.slides[1])
  2816. _replace_all_placeholders(s10, {
  2817. '{report_title}': '海外订单月度数据报告',
  2818. '{date}': period_str,
  2819. '{page_title}': f'{month+1 if month < 12 else 1}月展望:预测交付{metrics["forecast_next"]}台,重点关注交付转化',
  2820. '{source}': source,
  2821. '{period}': '10/11',
  2822. '{page_num}': '',
  2823. })
  2824. _add_nav_tabs(s10, nav_labels, active_index=3, slide_width=prs.slide_width, colors=colors)
  2825. # Left chart: next month goals as column chart
  2826. goals = metrics.get('next_month_goals', [])
  2827. chart_w = Emu(int(prs.slide_width) * 0.55)
  2828. text_left = Emu(int(prs.slide_width) * 0.62)
  2829. text_w = Emu(int(prs.slide_width) * 0.36)
  2830. if goals:
  2831. goal_names = [g['title'].split(':')[0] for g in goals[:5]]
  2832. goal_nums = [g.get('number', 0) for g in goals[:5]]
  2833. add_column_chart(s10, goal_names, goal_nums,
  2834. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  2835. series_name='目标数量', color=C_ACCENT,
  2836. category_axis_title='目标', value_axis_title='数量')
  2837. # Deep insight: monthly plan
  2838. insight_items = generate_deep_insights('monthly', 'monthly_plan', metrics)
  2839. _add_structured_insight(s10, insight_items,
  2840. text_left, Emu(content_top), text_w, Emu(5334000))
  2841. # Page 11: End
  2842. s_end = _duplicate_slide(prs, prs.slides[3])
  2843. _add_footer_if_missing(s_end, f'数据来源:{source} | 11/11', colors=colors)
  2844. _replace_all_placeholders(s_end, {
  2845. '{report_title}': '海外订单月度数据报告',
  2846. '{date}': period_str,
  2847. '{department}': department,
  2848. })
  2849. end_kpis = [
  2850. ('合同总数', f"{metrics['total_contracts']:,}"),
  2851. ('车辆总数', f"{metrics['total_qty']:,}"),
  2852. ('目的国家', f"{metrics['countries']}+"),
  2853. ('团队', '9人'),
  2854. ]
  2855. for i, (lbl, val) in enumerate(end_kpis, 1):
  2856. _replace_placeholder(s_end, f'{{kpi{i}_label}}', lbl)
  2857. _replace_placeholder(s_end, f'{{kpi{i}_value}}', val)
  2858. for slide in prs.slides:
  2859. _ensure_word_wrap_all(slide)
  2860. _delete_template_slides(prs)
  2861. prs.save(output_path)
  2862. print(f"Monthly report saved: {output_path}")
  2863. # ==============================================================================
  2864. # CLI
  2865. # ==============================================================================
  2866. if __name__ == '__main__':
  2867. import sys
  2868. if len(sys.argv) >= 4:
  2869. cmd = sys.argv[1]
  2870. data_file = sys.argv[2]
  2871. output = sys.argv[3]
  2872. if cmd == 'daily':
  2873. d = datetime.strptime(sys.argv[4], '%Y-%m-%d')
  2874. build_daily_report(data_file, d, output)
  2875. elif cmd == 'weekly':
  2876. build_weekly_report(data_file, int(sys.argv[4]), int(sys.argv[5]), output)
  2877. elif cmd == 'monthly':
  2878. build_monthly_report(data_file, int(sys.argv[4]), int(sys.argv[5]), output)