ppt_builder.py 124 KB

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