ppt_builder.py 139 KB

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