ppt_builder.py 79 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875
  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. from pathlib import Path
  11. from datetime import datetime, timedelta
  12. sys.path.insert(0, str(Path(__file__).parent))
  13. from pptx import Presentation
  14. from pptx.util import Emu, Pt
  15. from pptx.dml.color import RGBColor
  16. from pptx.enum.text import PP_ALIGN
  17. from pptx.enum.shapes import MSO_SHAPE
  18. from data_loader import load_daily, load_weekly, load_monthly, load_date_range
  19. from metrics_calculator import calc_daily_metrics, calc_weekly_metrics, calc_monthly_metrics, generate_deep_insights
  20. from chart_factory import (
  21. add_column_chart, add_bar_chart, add_line_chart, add_doughnut_chart,
  22. add_pie_chart, add_funnel_chart, add_horizontal_bar_chart,
  23. add_grouped_bar_chart, add_table
  24. )
  25. # Colors — aligned with reference design theme YAML
  26. C_PRIMARY = RGBColor(0x1E, 0x3A, 0x5F)
  27. C_ACCENT = RGBColor(0x10, 0xB9, 0x81)
  28. C_ACCENT_NEG = RGBColor(0xEF, 0x44, 0x44)
  29. C_SECONDARY = RGBColor(0x64, 0x74, 0x8B)
  30. C_DARK = RGBColor(0x1F, 0x3A, 0x5C)
  31. C_WHITE = RGBColor(0xFF, 0xFF, 0xFF)
  32. C_GRAY_BG = RGBColor(0xF2, 0xF2, 0xF2)
  33. C_TEXT = RGBColor(0x33, 0x33, 0x33)
  34. C_TEXT_GRAY = RGBColor(0x66, 0x66, 0x66)
  35. C_LINE = RGBColor(0xD9, 0xD9, 0xD9)
  36. C_CARD_BG = RGBColor(0xE7, 0xF0, 0xF7)
  37. C_GREEN = RGBColor(0x10, 0xB9, 0x81)
  38. C_RED = RGBColor(0xEF, 0x44, 0x44)
  39. C_ORANGE = RGBColor(0xED, 0x7D, 0x31)
  40. # ==============================================================================
  41. # MASTER / SLIDE HELPERS
  42. # ==============================================================================
  43. def get_master_template(report_type: str) -> str:
  44. """Route report type to corresponding master template."""
  45. base = os.path.join(os.path.dirname(__file__), '..', 'assets')
  46. template_map = {
  47. 'daily': os.path.join(base, 'report-master.pptx'),
  48. 'weekly': os.path.join(base, 'weekly-master.pptx'),
  49. 'monthly': os.path.join(base, 'monthly-master.pptx'),
  50. }
  51. path = template_map.get(report_type, template_map['daily'])
  52. if os.path.exists(path):
  53. return os.path.abspath(path)
  54. # Fallbacks
  55. for fallback in [template_map['daily']]:
  56. if os.path.exists(fallback):
  57. return os.path.abspath(fallback)
  58. raise FileNotFoundError(f"Master template not found for {report_type}")
  59. def _detect_content_top(slide) -> int:
  60. """Detect content start Y from a content slide template by reading {page_title} position."""
  61. page_title_bottom = Emu(1422400) # daily default
  62. for shape in slide.shapes:
  63. if shape.has_text_frame and '{page_title}' in shape.text_frame.text:
  64. page_title_bottom = shape.top + shape.height
  65. break
  66. # Gap: generous spacing between page title and content to avoid crowding
  67. gap = Emu(381000)
  68. return int(page_title_bottom) + int(gap)
  69. def _delete_template_slides(prs, count=4):
  70. for _ in range(count):
  71. if len(prs.slides) == 0:
  72. break
  73. rId = prs.slides._sldIdLst[0].rId
  74. prs.part.drop_rel(rId)
  75. del prs.slides._sldIdLst[0]
  76. def _duplicate_slide(prs, source_slide):
  77. blank_layout = prs.slide_layouts[6]
  78. new_slide = prs.slides.add_slide(blank_layout)
  79. for shape in source_slide.shapes:
  80. el = shape.element
  81. new_el = copy.deepcopy(el)
  82. new_slide.shapes._spTree.insert_element_before(new_el, 'p:extLst')
  83. return new_slide
  84. def _replace_placeholder(slide, placeholder, new_text):
  85. for shape in slide.shapes:
  86. if not shape.has_text_frame:
  87. continue
  88. for para in shape.text_frame.paragraphs:
  89. if placeholder in para.text:
  90. para.text = para.text.replace(placeholder, str(new_text))
  91. for run in para.runs:
  92. run.font.name = '微软雅黑'
  93. def _replace_all_placeholders(slide, mapping: dict):
  94. for placeholder, new_text in mapping.items():
  95. _replace_placeholder(slide, placeholder, new_text)
  96. # ==============================================================================
  97. # NAVIGATION TABS
  98. # ==============================================================================
  99. def _add_nav_tabs(slide, tabs, active_index=0, slide_width=None,
  100. tab_y=Emu(254000), tab_h=Emu(762000), underline_h=Emu(127000)):
  101. if slide_width is None:
  102. slide_width = slide.shapes._spTree.getparent().getparent().attrib.get('cx')
  103. slide_width = Emu(int(slide_width)) if slide_width else Emu(16256000)
  104. n = len(tabs)
  105. tab_w = Emu(int(slide_width) // n)
  106. for i, label in enumerate(tabs):
  107. x = Emu(i * int(tab_w))
  108. box = slide.shapes.add_textbox(x, tab_y, tab_w, tab_h)
  109. p = box.text_frame.paragraphs[0]
  110. p.text = label
  111. p.font.size = Pt(11)
  112. p.font.name = '微软雅黑'
  113. p.font.color.rgb = C_PRIMARY if i == active_index else C_TEXT_GRAY
  114. p.alignment = PP_ALIGN.CENTER
  115. if i == active_index:
  116. line = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, Emu(457200), tab_w, underline_h)
  117. line.fill.solid()
  118. line.fill.fore_color.rgb = C_PRIMARY
  119. line.line.fill.background()
  120. # ==============================================================================
  121. # KPI CARDS
  122. # ==============================================================================
  123. def _add_kpi_cards(slide, kpis, start_x=Emu(762000), start_y=Emu(1651000)):
  124. """Draw 3x2 KPI card grid. Each kpi: {'label', 'value', 'unit', 'change', 'sub'}"""
  125. positions = [
  126. (start_x, start_y),
  127. (Emu(5778500), start_y),
  128. (Emu(10795000), start_y),
  129. (start_x, Emu(start_y + 3429000)),
  130. (Emu(5778500), Emu(start_y + 3429000)),
  131. (Emu(10795000), Emu(start_y + 3429000)),
  132. ]
  133. for i, kpi in enumerate(kpis[:6]):
  134. if i >= len(positions):
  135. break
  136. x, y = positions[i]
  137. w, h = Emu(4699000), Emu(3048000)
  138. card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
  139. card.fill.solid()
  140. card.fill.fore_color.rgb = C_CARD_BG
  141. card.line.fill.background()
  142. # Label
  143. lbl = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 228600), Emu(2540000), Emu(406400))
  144. p = lbl.text_frame.paragraphs[0]
  145. p.text = kpi.get('label', '')
  146. p.font.size = Pt(14)
  147. p.font.color.rgb = C_TEXT_GRAY
  148. p.font.name = '微软雅黑'
  149. # Value
  150. val = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 762000), Emu(2540000), Emu(698500))
  151. p = val.text_frame.paragraphs[0]
  152. p.text = str(kpi.get('value', ''))
  153. p.font.size = Pt(36)
  154. p.font.bold = True
  155. p.font.color.rgb = C_PRIMARY
  156. p.font.name = 'Arial'
  157. # Unit
  158. unit = kpi.get('unit', '')
  159. if unit:
  160. ubox = slide.shapes.add_textbox(Emu(x + 3048000), Emu(y + 1016000), Emu(508000), Emu(381000))
  161. p = ubox.text_frame.paragraphs[0]
  162. p.text = unit
  163. p.font.size = Pt(14)
  164. p.font.color.rgb = C_TEXT_GRAY
  165. p.font.name = '微软雅黑'
  166. # Change badge
  167. chg = kpi.get('change', '')
  168. if chg:
  169. cbox = slide.shapes.add_textbox(Emu(x + 508000), Emu(y + 1778000), Emu(4064000), Emu(304800))
  170. p = cbox.text_frame.paragraphs[0]
  171. p.text = chg
  172. p.font.size = Pt(12)
  173. chg_str = str(chg)
  174. is_positive = chg_str.startswith('+') or any(k in chg_str for k in ['↑', '提升', '增长', '上调', '增加', '大幅', '好', '突破', '达成', '优化'])
  175. is_negative = chg_str.startswith('-') or any(k in chg_str for k in ['↓', '下滑', '下降', '减少', '回落', '滞后', '堆积', '阻塞', '缺口', '延迟'])
  176. if is_negative:
  177. p.font.color.rgb = C_RED
  178. elif is_positive:
  179. p.font.color.rgb = C_GREEN
  180. else:
  181. p.font.color.rgb = C_TEXT_GRAY
  182. p.font.name = '微软雅黑'
  183. # Sub note with semantic background color tag (e.g. "日均51笔")
  184. sub = kpi.get('sub', '')
  185. if sub:
  186. sub_text = _truncate_text(sub, 20)
  187. tag_color = _sentiment_color(sub_text)
  188. tag_x = Emu(x + 508000)
  189. tag_y = Emu(y + 2159000)
  190. tag_w = Emu(min(len(sub_text) * 220000 + 400000, 3600000))
  191. tag_h = Emu(304800)
  192. if tag_color:
  193. tag_bg = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, tag_x, tag_y, tag_w, tag_h)
  194. tag_bg.fill.solid()
  195. tag_bg.fill.fore_color.rgb = tag_color
  196. tag_bg.line.fill.background()
  197. sbox = slide.shapes.add_textbox(tag_x, tag_y, tag_w, tag_h)
  198. p = sbox.text_frame.paragraphs[0]
  199. p.text = sub_text
  200. p.font.size = Pt(11)
  201. p.font.color.rgb = C_TEXT_GRAY
  202. p.font.name = '微软雅黑'
  203. p.alignment = PP_ALIGN.CENTER
  204. # ==============================================================================
  205. # TEXT BLOCKS
  206. # ==============================================================================
  207. def _add_text_block(slide, title, body, left, top, width, height,
  208. title_size=Pt(14), body_size=Pt(11), line_space=Pt(6)):
  209. """Single text box with title + body."""
  210. box = slide.shapes.add_textbox(left, top, width, height)
  211. tf = box.text_frame
  212. tf.word_wrap = True
  213. p = tf.paragraphs[0]
  214. p.text = title
  215. p.font.size = title_size
  216. p.font.bold = True
  217. p.font.color.rgb = C_PRIMARY if title else C_TEXT
  218. p.font.name = '微软雅黑'
  219. if body:
  220. p2 = tf.add_paragraph()
  221. p2.text = body
  222. p2.font.size = body_size
  223. p2.font.color.rgb = C_TEXT
  224. p2.font.name = '微软雅黑'
  225. p2.space_before = line_space
  226. p2.line_spacing = 1.3
  227. def _estimate_text_height(items, title_size_pt, body_size_pt, width_emu,
  228. line_spacing=1.15, title_extra=1.3):
  229. """Estimate rendered text height in EMU for adaptive font sizing."""
  230. width_pt = width_emu / 12700.0
  231. chars_per_line_body = max(10, int(width_pt / (body_size_pt * 1.15)))
  232. chars_per_line_title = max(10, int(width_pt / (title_size_pt * 1.15)))
  233. line_height_body = int(body_size_pt * line_spacing * 12700)
  234. line_height_title = int(title_size_pt * title_extra * 12700)
  235. total = 0
  236. for item in items:
  237. title = item.get('title', '')
  238. content = item.get('content', '')
  239. title_lines = max(1, (len(title) + chars_per_line_title - 1) // chars_per_line_title)
  240. content_lines = max(1, (len(content) + chars_per_line_body - 1) // chars_per_line_body)
  241. total += title_lines * line_height_title + content_lines * line_height_body + int(6 * 12700)
  242. return total
  243. def _add_structured_insight(slide, items, left, top, width, height,
  244. title_size=Pt(12), body_size=Pt(11),
  245. max_items=None, min_body_size=Pt(9)):
  246. """
  247. High-density structured multi-paragraph insight block.
  248. items: list of {'title': str, 'content': str}
  249. Features:
  250. - No truncation; full content rendered
  251. - No max_items limit by default (render all)
  252. - Auto-shrink body font to fit within height (down to min_body_size)
  253. - Compact line spacing (1.15) to maximize density
  254. - Each bullet has emoji + bold title + normal body
  255. """
  256. if not items:
  257. return
  258. # Adaptive font sizing: shrink body_size until it fits
  259. target_height = int(height)
  260. # title_size/body_size may be EMU integers or Pt objects; normalize to pt
  261. _ts = float(title_size) / 12700.0 if float(title_size) > 1000 else float(title_size)
  262. _bs = float(body_size) / 12700.0 if float(body_size) > 1000 else float(body_size)
  263. _min_bs = float(min_body_size) / 12700.0 if float(min_body_size) > 1000 else float(min_body_size)
  264. ts_pt = _ts
  265. bs_pt = _bs
  266. min_bs_pt = _min_bs
  267. # Binary-search-like shrink to fit
  268. while bs_pt > min_bs_pt:
  269. est = _estimate_text_height(items, ts_pt, bs_pt, int(width))
  270. if est <= target_height:
  271. break
  272. bs_pt -= 0.5
  273. ts_pt = max(bs_pt + 1, ts_pt - 0.25)
  274. box = slide.shapes.add_textbox(left, top, width, height)
  275. tf = box.text_frame
  276. tf.word_wrap = True
  277. first = True
  278. for item in items[:max_items] if max_items else items:
  279. if not first:
  280. spacer = tf.add_paragraph()
  281. spacer.text = ''
  282. spacer.space_before = Pt(3)
  283. title = item.get('title', '')
  284. emoji = _emoji_for_item(title)
  285. # Avoid double emoji
  286. if emoji and title.startswith(emoji):
  287. emoji = ''
  288. title_text = f'{emoji} {title}' if emoji else title
  289. p = tf.paragraphs[0] if first else tf.add_paragraph()
  290. p.text = title_text
  291. p.font.size = Pt(ts_pt)
  292. p.font.bold = True
  293. p.font.color.rgb = C_PRIMARY
  294. p.font.name = '微软雅黑'
  295. p.line_spacing = 1.15
  296. first = False
  297. content = item.get('content', '')
  298. if content:
  299. p2 = tf.add_paragraph()
  300. p2.text = content
  301. p2.font.size = Pt(bs_pt)
  302. p2.font.color.rgb = C_TEXT
  303. p2.font.name = '微软雅黑'
  304. p2.line_spacing = 1.15
  305. p2.space_before = Pt(1)
  306. # ==============================================================================
  307. # ALERT / ACTION / ISSUE / GOAL CARDS
  308. # ==============================================================================
  309. def _add_alert_cards(slide, alerts, start_y=Emu(1651000)):
  310. """Draw 1-3 alert cards horizontally. Supports 严重/中度/一般 levels."""
  311. colors = {'严重': C_RED, '警告': C_ORANGE, '关注': C_PRIMARY, '中度': C_ORANGE, '一般': C_SECONDARY}
  312. positions = [Emu(762000), Emu(5778500), Emu(10795000)]
  313. for i, alert in enumerate(alerts[:3]):
  314. x = positions[i]
  315. y = start_y
  316. lvl = alert.get('level', '关注')
  317. c = colors.get(lvl, C_PRIMARY)
  318. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(2286000))
  319. bar.fill.solid()
  320. bar.fill.fore_color.rgb = c
  321. bar.line.fill.background()
  322. tbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 228600), Emu(4064000), Emu(406400))
  323. p = tbox.text_frame.paragraphs[0]
  324. p.text = alert.get('title', '')
  325. p.font.size = Pt(15)
  326. p.font.bold = True
  327. p.font.color.rgb = C_TEXT
  328. p.font.name = '微软雅黑'
  329. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 762000), Emu(4064000), Emu(1270000))
  330. tf = dbox.text_frame
  331. tf.word_wrap = True
  332. p = tf.paragraphs[0]
  333. p.text = alert.get('detail', '')
  334. p.font.size = Pt(11)
  335. p.font.color.rgb = C_TEXT
  336. p.font.name = '微软雅黑'
  337. def _add_action_cards(slide, actions, start_y=Emu(2540000)):
  338. """Draw 3 action cards horizontally."""
  339. positions = [Emu(762000), Emu(5778500), Emu(10795000)]
  340. for i, act in enumerate(actions[:3]):
  341. x = positions[i]
  342. y = start_y
  343. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(406400))
  344. bar.fill.solid()
  345. bar.fill.fore_color.rgb = C_PRIMARY
  346. bar.line.fill.background()
  347. tbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 952500), Emu(4064000), Emu(406400))
  348. p = tbox.text_frame.paragraphs[0]
  349. p.text = act.get('title', '')
  350. p.font.size = Pt(17)
  351. p.font.bold = True
  352. p.font.color.rgb = C_TEXT
  353. p.font.name = '微软雅黑'
  354. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 1524000), Emu(4064000), Emu(3429000))
  355. tf = dbox.text_frame
  356. tf.word_wrap = True
  357. p = tf.paragraphs[0]
  358. p.text = act.get('detail', '')
  359. p.font.size = Pt(11)
  360. p.font.color.rgb = C_TEXT
  361. p.font.name = '微软雅黑'
  362. p.line_spacing = 1.3
  363. def _add_issue_cards(slide, issues, start_y=Emu(1524000)):
  364. """Draw stacked issue cards with severity, title, detail, action."""
  365. colors = {'严重': C_RED, '中度': C_ORANGE, '轻度': C_PRIMARY, '一般': C_SECONDARY}
  366. for i, issue in enumerate(issues[:3]):
  367. x = Emu(762000)
  368. y = Emu(int(start_y) + i * (1778000 + 254000))
  369. sev = issue.get('severity', '中度')
  370. c = colors.get(sev, C_ORANGE)
  371. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(50800), Emu(1778000))
  372. bar.fill.solid()
  373. bar.fill.fore_color.rgb = c
  374. bar.line.fill.background()
  375. sbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 228600), Emu(660400), Emu(304800))
  376. p = sbox.text_frame.paragraphs[0]
  377. p.text = sev
  378. p.font.size = Pt(11)
  379. p.font.bold = True
  380. p.font.color.rgb = c
  381. p.font.name = '微软雅黑'
  382. tbox = slide.shapes.add_textbox(Emu(x + 1778000), Emu(y + 228600), Emu(13462000), Emu(355600))
  383. p = tbox.text_frame.paragraphs[0]
  384. p.text = issue.get('title', '')
  385. p.font.size = Pt(13)
  386. p.font.bold = True
  387. p.font.color.rgb = C_TEXT
  388. p.font.name = '微软雅黑'
  389. dbox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 698500), Emu(14224000), Emu(355600))
  390. p = dbox.text_frame.paragraphs[0]
  391. p.text = issue.get('detail', '')
  392. p.font.size = Pt(11)
  393. p.font.color.rgb = C_TEXT
  394. p.font.name = '微软雅黑'
  395. abox = slide.shapes.add_textbox(Emu(x + 101600), Emu(y + 1193800), Emu(14224000), Emu(609600))
  396. tf = abox.text_frame
  397. tf.word_wrap = True
  398. p = tf.paragraphs[0]
  399. p.text = f"建议措施:{issue.get('action', '')}"
  400. p.font.size = Pt(11)
  401. p.font.color.rgb = C_TEXT_GRAY
  402. p.font.name = '微软雅黑'
  403. def _add_goal_cards(slide, goals, start_y=Emu(1524000)):
  404. """Draw G1-G4 goal cards in 2x2 grid with icon+title+detail."""
  405. sy = int(start_y)
  406. positions = [
  407. (Emu(762000), Emu(sy)),
  408. (Emu(8318500), Emu(sy)),
  409. (Emu(762000), Emu(sy + 1879600)),
  410. (Emu(8318500), Emu(sy + 1879600)),
  411. ]
  412. icon_chars = ['🎯', '💰', '🚀', '⚡']
  413. for i, goal in enumerate(goals[:4]):
  414. x, y = positions[i]
  415. gid = goal.get('id', f'G{i+1}')
  416. gbox = slide.shapes.add_textbox(x, Emu(y + 101600), Emu(635000), Emu(355600))
  417. p = gbox.text_frame.paragraphs[0]
  418. p.text = f"{icon_chars[i % len(icon_chars)]} {gid}"
  419. p.font.size = Pt(16)
  420. p.font.bold = True
  421. p.font.color.rgb = C_PRIMARY
  422. p.font.name = 'Arial'
  423. tbox = slide.shapes.add_textbox(Emu(x + 863600), Emu(y + 101600), Emu(6096000), Emu(355600))
  424. p = tbox.text_frame.paragraphs[0]
  425. p.text = goal.get('title', '')
  426. p.font.size = Pt(14)
  427. p.font.bold = True
  428. p.font.color.rgb = C_TEXT
  429. p.font.name = '微软雅黑'
  430. dbox = slide.shapes.add_textbox(Emu(x + 228600), Emu(y + 571500), Emu(6731000), Emu(863600))
  431. tf = dbox.text_frame
  432. tf.word_wrap = True
  433. p = tf.paragraphs[0]
  434. p.text = goal.get('detail', '')
  435. p.font.size = Pt(11)
  436. p.font.color.rgb = C_TEXT_GRAY
  437. p.font.name = '微软雅黑'
  438. p.line_spacing = 1.3
  439. def _add_summary_text(slide, text, left=Emu(1016000), top=Emu(5435600), width=Emu(14224000), height=Emu(1270000)):
  440. box = slide.shapes.add_textbox(left, top, width, height)
  441. tf = box.text_frame
  442. tf.word_wrap = True
  443. p = tf.paragraphs[0]
  444. p.text = text
  445. p.font.size = Pt(12)
  446. p.font.color.rgb = C_TEXT
  447. p.font.name = '微软雅黑'
  448. p.line_spacing = 1.3
  449. # ==============================================================================
  450. # STRUCTURED INSIGHT GENERATORS
  451. # ==============================================================================
  452. def _insight_trend_structured(trend_dates, trend_vals, metrics, period_name='近10天'):
  453. """Generate structured multi-paragraph trend insight."""
  454. items = []
  455. if not trend_vals or len(trend_vals) < 2:
  456. items.append({'title': '数据概览', 'content': f'{period_name}订单数据平稳,暂无明显波动。'})
  457. return items
  458. peak = max(trend_vals)
  459. peak_idx = trend_vals.index(peak)
  460. low = min(trend_vals)
  461. low_idx = trend_vals.index(low)
  462. last = trend_vals[-1]
  463. first = trend_vals[0]
  464. total = sum(trend_vals)
  465. avg = total / len(trend_vals)
  466. # Paragraph 1: Order scale
  467. curr_orders = metrics.get('tracking_orders', last)
  468. prev_orders = metrics.get('prev_tracking_orders', 0)
  469. order_chg = _pct_val(curr_orders, prev_orders)
  470. curr_qty = metrics.get('total_qty', 0)
  471. prev_qty = metrics.get('prev_total_qty', 0)
  472. qty_chg = _pct_val(curr_qty, prev_qty)
  473. avg_size = metrics.get('avg_order_size', 0)
  474. prev_avg_size = metrics.get('prev_avg_order_size', 0)
  475. scale_text = f'今日订单量{curr_orders}单'
  476. if prev_orders > 0:
  477. diff = curr_orders - prev_orders
  478. scale_text += f',较昨日{"增加" if diff >= 0 else "减少"}{abs(diff)}单'
  479. if curr_qty > 0:
  480. scale_text += f',订单总数量{curr_qty:,}台'
  481. if prev_qty > 0:
  482. qdiff = curr_qty - prev_qty
  483. scale_text += f',较昨日{"增加" if qdiff >= 0 else "减少"}{abs(qdiff)}台'
  484. if avg_size > 0:
  485. scale_text += f',单笔订单平均规模{avg_size:.0f}台'
  486. if prev_avg_size > 0:
  487. adiff = avg_size - prev_avg_size
  488. if abs(adiff) >= 1:
  489. scale_text += f'({"上升" if adiff >= 0 else "下降"}{abs(adiff):.0f}台)'
  490. items.append({'title': '订单规模分析', 'content': scale_text})
  491. # Paragraph 2: Peak fluctuation
  492. peak_text = ''
  493. if peak == last:
  494. peak_text = f'今日达到峰值{peak}单,为{period_name}最高水平。'
  495. elif low == last:
  496. peak_text = f'今日回落至{last}单,为{period_name}最低水平。'
  497. else:
  498. peak_text = f'峰值出现在{trend_dates[peak_idx]}({peak}单),低谷在{trend_dates[low_idx]}({low}单)。'
  499. # Describe recovery pattern
  500. if len(trend_vals) >= 3:
  501. recent = trend_vals[-3:]
  502. if recent[-1] > recent[-2] and recent[-2] < recent[0]:
  503. peak_text += f'连续回落后今日回升至{last}单,呈现反弹态势。'
  504. elif recent[-1] < recent[-2]:
  505. peak_text += f'近期呈回落趋势,需关注后续走势。'
  506. items.append({'title': '峰值波动', 'content': peak_text})
  507. # Paragraph 3: Activity / update
  508. updated = metrics.get('updated_orders', 0)
  509. prev_updated = metrics.get('prev_updated_orders', 0)
  510. if updated > 0 or prev_updated > 0:
  511. act_text = f'今日进度更新{updated}单'
  512. if prev_updated > 0:
  513. udiff = updated - prev_updated
  514. upct = _pct_val(updated, prev_updated)
  515. act_text += f',较昨日{"增加" if udiff >= 0 else "减少"}{abs(udiff)}单({upct:+.1f}%)'
  516. if abs(upct) > 20:
  517. act_text += ',团队活跃度波动较大。' if abs(upct) > 30 else ',团队活跃度有所变化。'
  518. else:
  519. act_text += ',团队活跃度保持平稳。'
  520. else:
  521. act_text += '。'
  522. items.append({'title': '活跃度分析', 'content': act_text})
  523. return items
  524. def _insight_status_structured(status_dist, prev_status_dist=None):
  525. """Generate structured status distribution insight."""
  526. items = []
  527. total = sum(status_dist.values())
  528. if not total:
  529. items.append({'title': '状态概览', 'content': '暂无订单状态数据。'})
  530. return items
  531. max_status = max(status_dist.items(), key=lambda x: x[1])
  532. max_pct = max_status[1] / total * 100
  533. # Production share (C+D)
  534. prod = status_dist.get('已付订金待生产', 0) + status_dist.get('已生产待付尾款', 0)
  535. prod_pct = prod / total * 100
  536. status_text = f'{max_status[0]}占比最高({max_status[1]}单,{max_pct:.1f}%)'
  537. if prod_pct > 0:
  538. status_text += f'。生产端(已付订金+已生产)合计{prod}单({prod_pct:.1f}%)'
  539. if prod_pct > 30:
  540. status_text += ',生产推进力度加大。'
  541. else:
  542. status_text += '。'
  543. items.append({'title': '状态分布特征', 'content': status_text})
  544. # WoW change
  545. if prev_status_dist:
  546. changes = []
  547. for name, curr in status_dist.items():
  548. prev = prev_status_dist.get(name, 0)
  549. if prev > 0:
  550. chg = _pct_val(curr, prev)
  551. if abs(chg) > 5:
  552. changes.append(f'{name}{chg:+.1f}%')
  553. if changes:
  554. items.append({'title': '状态变化(vs 昨日)', 'content': ' | '.join(changes[:4])})
  555. return items
  556. def _insight_region_structured(region_dist):
  557. """Generate structured regional insight with top countries."""
  558. items = []
  559. if not region_dist:
  560. items.append({'title': '区域概览', 'content': '暂无区域分布数据。'})
  561. return items
  562. sorted_regions = sorted(region_dist.items(), key=lambda x: -x[1]['qty'])
  563. top3 = sorted_regions[:3]
  564. total = sum(v['qty'] for v in region_dist.values())
  565. top3_pct = sum(v['qty'] for _, v in top3) / total * 100 if total else 0
  566. top_names = [k for k, _ in top3]
  567. items.append({'title': '核心市场', 'content': f'{"、".join(top_names)}三大核心市场合计占比{top3_pct:.1f}%,是海外订单的核心增长引擎。'})
  568. # Each region detail
  569. for name, data in sorted_regions[:5]:
  570. top_c = data.get('top_countries', [])
  571. top_c_str = '/'.join([c['country'] for c in top_c[:3]]) if top_c else ''
  572. change = data.get('change_pct', 0)
  573. if change is None:
  574. chg_str = ''
  575. elif change > 0:
  576. chg_str = f'(+{change:.1f}%)'
  577. elif change < 0:
  578. chg_str = f'({change:.1f}%)'
  579. else:
  580. chg_str = ''
  581. content = f'{data["pct"]:.1f}% | {data["qty"]:,}台'
  582. if top_c_str:
  583. content += f' | {top_c_str}为主力'
  584. if change != 0:
  585. content += f' {chg_str}'
  586. if change < 0:
  587. content += ' | 需关注'
  588. items.append({'title': name, 'content': content})
  589. return items
  590. def _insight_team_structured(team, total_qty=0, per_capita=0, countries_covered=0):
  591. """Generate structured team performance insight."""
  592. items = []
  593. if not team:
  594. items.append({'title': '团队概览', 'content': '暂无团队绩效数据。'})
  595. return items
  596. n_members = len(team)
  597. avg = per_capita or _safe_div(sum(v.get('orders', 0) for v in team.values()), n_members)
  598. top = max(team.items(), key=lambda x: x[1].get('orders', 0))
  599. overview = f'团队共{n_members}人,'
  600. if countries_covered:
  601. overview += f'覆盖{countries_covered}国,'
  602. overview += f'人均追踪{avg:.0f}单。'
  603. items.append({'title': '团队概况', 'content': overview})
  604. # Top performers
  605. sorted_team = sorted(team.items(), key=lambda x: -x[1].get('orders', 0))
  606. for name, data in sorted_team[:2]:
  607. orders = data.get('orders', 0)
  608. qty = data.get('qty', 0)
  609. comment = '增长主力' if orders > avg * 1.3 else '稳健跟进'
  610. content = f'{name} {orders}单'
  611. if qty:
  612. content += f'/{qty:,}台'
  613. content += f' - {comment}'
  614. items.append({'title': '领跑者点评', 'content': content})
  615. # Find laggard
  616. low = min(team.items(), key=lambda x: x[1].get('orders', 0))
  617. if low[1].get('orders', 0) < avg * 0.7:
  618. items.append({'title': '关注提醒', 'content': f'{low[0]}订单量低于团队均值,建议关注产能提升空间。'})
  619. return items
  620. def _insight_stage_structured(stage_analysis, funnel):
  621. """Generate structured monthly stage funnel insight."""
  622. items = []
  623. early = stage_analysis.get('early', {})
  624. mid = stage_analysis.get('mid', {})
  625. late = stage_analysis.get('late', {})
  626. a_b = early.get('orders', 0)
  627. items.append({
  628. 'title': '前期Pipeline充足',
  629. 'content': f'合同拟定中(A) + 已锁定待付订金(B)共{a_b}单,占总量的{early.get("pct", 0):.1f}%,后续转化空间充足。'
  630. })
  631. c_d = mid.get('orders', 0)
  632. items.append({
  633. 'title': '中期生产推进',
  634. 'content': f'已付订金待生产(C) + 已生产待付尾款(D)共{c_d}单,占总量的{mid.get("pct", 0):.1f}%。'
  635. })
  636. e_f = late.get('orders', 0)
  637. items.append({
  638. 'title': '后期交付待加速',
  639. 'content': f'已付尾款待发运(E) + 已发运(F)仅{e_f}单,占总量的{late.get("pct", 0):.1f}%。'
  640. })
  641. if early.get('pct', 0) > 40:
  642. items.append({
  643. 'title': '⚠ 风险提示',
  644. 'content': f'近半数订单仍停留在合同拟定阶段,需关注A→B的转化效率,加速合同确认和订金回收。'
  645. })
  646. return items
  647. def _insight_top_countries_structured(top_countries_change, total_qty, top_n=6):
  648. """Generate structured TOP countries insight."""
  649. items = []
  650. if not top_countries_change:
  651. items.append({'title': '国家概览', 'content': '暂无国家分布数据。'})
  652. return items
  653. sorted_items = sorted(top_countries_change.items(), key=lambda x: -x[1]['qty'])
  654. top_list = sorted_items[:top_n]
  655. total_top = sum(v['qty'] for _, v in top_list)
  656. pct = total_top / total_qty * 100 if total_qty else 0
  657. items.append({'title': '集中度分析', 'content': f'Top {top_n}目的国合计覆盖{total_top:,}台,占总量的{pct:.1f}%,重点市场集中度高。'})
  658. for i, (country, data) in enumerate(top_list, 1):
  659. chg = data.get('change_pct', 0)
  660. comment = ''
  661. if chg is None:
  662. comment = '新增市场,潜力可期'
  663. chg_str = ''
  664. elif chg > 30:
  665. comment = '本周增长最快市场之一'
  666. chg_str = f'({chg:+.1f}%)'
  667. elif chg > 10:
  668. comment = '持续增长'
  669. chg_str = f'({chg:+.1f}%)'
  670. elif chg < -10:
  671. comment = '虽有下滑但仍高位' if data['qty'] > total_qty * 0.05 else '需关注'
  672. chg_str = f'({chg:.1f}%)'
  673. elif chg < 0:
  674. comment = '小幅回落'
  675. chg_str = f'({chg:.1f}%)'
  676. else:
  677. comment = 'steady增长' if i <= 3 else '新兴市场,潜力可期'
  678. chg_str = f'({chg:+.1f}%)' if chg != 0 else ''
  679. items.append({'title': f'{i}. {country} {data["qty"]:,}台{chg_str}', 'content': comment})
  680. return items
  681. # ==============================================================================
  682. # TEXT / LAYOUT HELPERS
  683. # ==============================================================================
  684. def _truncate_text(text, max_chars=60):
  685. """Truncate text to max_chars, appending '...' if truncated."""
  686. if not text:
  687. return text
  688. if len(text) > max_chars:
  689. return text[:max_chars - 1] + '...'
  690. return text
  691. def _sentiment_color(text):
  692. """Return a light background color based on text sentiment."""
  693. if not text:
  694. return None
  695. text = str(text)
  696. positive_words = ['提升', '增长', '上调', '增加', '高', '好', '大幅', '冲刺', '领跑', '上升', '扩大', '优化', '改善', '突破', '达成']
  697. negative_words = ['下滑', '下降', '减少', '低', '差', '回落', '下滑', '滞后', '堆积', '阻塞', '缺口', '延迟', '超期', '逾期', '风险', '警告']
  698. pos_score = sum(1 for w in positive_words if w in text)
  699. neg_score = sum(1 for w in negative_words if w in text)
  700. if neg_score > pos_score:
  701. return RGBColor(0xFE, 0xE2, 0xE2) # light red ~ #EF444420
  702. if pos_score > neg_score:
  703. return RGBColor(0xD1, 0xFA, 0xE5) # light green ~ #10B98120
  704. return None
  705. import re
  706. def _emoji_for_item(title):
  707. """Return an emoji prefix based on title keywords."""
  708. if not title:
  709. return '📈'
  710. title = str(title)
  711. # Skip if title already starts with an emoji
  712. if re.match(r'^[\U0001F300-\U0001F9FF\u2600-\u26FF\u2700-\u27BF]', title):
  713. return ''
  714. if any(k in title for k in ['风险', '警告', '关注', '下滑', '下降', '延迟', '超期', '缺口', '阻塞']):
  715. return '⚠️'
  716. if any(k in title for k in ['建议', '措施', '行动', '协调', '对接']):
  717. return '💡'
  718. if any(k in title for k in ['目标', '计划', '冲刺', '展望', '聚焦']):
  719. return '🎯'
  720. if any(k in title for k in ['增长', '上升', '提升', '峰值', '领跑', '突破', '活跃', '好转']):
  721. return '📈'
  722. return '💡'
  723. def _add_footer_if_missing(slide, footer_text, slide_width=None):
  724. """Add a bottom footer bar to slides that don't already have one (Cover, TOC, End)."""
  725. if slide_width is None:
  726. slide_width = slide.shapes._spTree.getparent().getparent().attrib.get('cx')
  727. slide_width = Emu(int(slide_width)) if slide_width else Emu(16256000)
  728. # Check if footer already exists
  729. has_footer = False
  730. for shape in slide.shapes:
  731. if shape.has_text_frame and '数据来源' in shape.text_frame.text:
  732. has_footer = True
  733. break
  734. if has_footer:
  735. return
  736. bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, Emu(8824000), slide_width, Emu(320000))
  737. bar.fill.solid()
  738. bar.fill.fore_color.rgb = C_PRIMARY
  739. bar.line.fill.background()
  740. box = slide.shapes.add_textbox(Emu(762000), Emu(8824000), Emu(14000000), Emu(320000))
  741. p = box.text_frame.paragraphs[0]
  742. p.text = footer_text
  743. p.font.size = Pt(10)
  744. p.font.color.rgb = C_WHITE
  745. p.font.name = '微软雅黑'
  746. def _ensure_word_wrap_all(slide):
  747. """Enable word_wrap on all text frames in a slide."""
  748. for shape in slide.shapes:
  749. if shape.has_text_frame:
  750. shape.text_frame.word_wrap = True
  751. for para in shape.text_frame.paragraphs:
  752. for run in para.runs:
  753. run.font.name = '微软雅黑'
  754. # ==============================================================================
  755. # MATH HELPERS
  756. # ==============================================================================
  757. def _pct_val(curr, prev):
  758. if prev and prev != 0:
  759. return (curr - prev) / prev * 100
  760. return None
  761. def _format_pct(pct, with_sign=True, suffix='%', zero_suffix=''):
  762. """Safely format a percentage value. Returns '—' if pct is None."""
  763. if pct is None:
  764. return '—'
  765. sign = '+' if with_sign and pct >= 0 else ''
  766. return f"{sign}{pct:.1f}{suffix}{zero_suffix}"
  767. def _pct_str(curr, prev):
  768. if prev and prev != 0:
  769. pct = round((curr - prev) / prev * 100, 1)
  770. sign = '+' if pct >= 0 else ''
  771. return f"{sign}{pct}% vs 上期"
  772. return "—"
  773. def _safe_div(a, b):
  774. return round(a / b, 1) if b else 0
  775. # ==============================================================================
  776. # DAILY REPORT
  777. # ==============================================================================
  778. def build_daily_report(data_file: str, date: datetime, output_path: str,
  779. department='海外事业部', source='海外订单日报系统'):
  780. master_path = get_master_template('daily')
  781. prs = Presentation(master_path)
  782. content_top = _detect_content_top(prs.slides[1])
  783. df = load_daily(data_file, date)
  784. prev_date = date - timedelta(days=1)
  785. try:
  786. prev_df = load_daily(data_file, prev_date)
  787. except Exception:
  788. prev_df = None
  789. metrics = calc_daily_metrics(df, prev_df)
  790. prev_metrics = calc_daily_metrics(prev_df, None) if prev_df is not None else {}
  791. date_str = date.strftime('%Y年%m月%d日')
  792. period_str = date.strftime('%Y年%m月%d日')
  793. # ---- Page 1: Cover ----
  794. slide = _duplicate_slide(prs, prs.slides[0])
  795. _replace_all_placeholders(slide, {
  796. '{report_title}': '海外订单数据日报',
  797. '{report_type}': '数据日报',
  798. '{date}': date_str,
  799. '{department}': department,
  800. '{period}': period_str,
  801. '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
  802. })
  803. _add_footer_if_missing(slide, f'数据来源:{source} | 1/8')
  804. cover_kpis = [
  805. ('在跟订单', metrics['tracking_orders'], '单',
  806. _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))),
  807. ('订单总数量', f"{metrics['total_qty']:,}", '台',
  808. _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0))),
  809. ('今日已更新', metrics['updated_orders'], '单',
  810. _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0))),
  811. ('支持需求', metrics['support_requests'], '项', '需跨部门协调'),
  812. ]
  813. for i, (lbl, val, unit, chg) in enumerate(cover_kpis, 1):
  814. _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
  815. _replace_placeholder(slide, f'{{kpi{i}_value}}', str(val))
  816. # ---- Page 2: KPI Overview ----
  817. s2 = _duplicate_slide(prs, prs.slides[1])
  818. _replace_all_placeholders(s2, {
  819. '{report_title}': '海外订单数据日报',
  820. '{date}': date_str,
  821. '{page_title}': '今日核心指标概览',
  822. '{source}': source,
  823. '{period}': '2/8',
  824. '{page_num}': '',
  825. })
  826. kpis = [
  827. {'label': '在跟订单总数', 'value': metrics['tracking_orders'], 'unit': '单',
  828. 'change': _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0)),
  829. 'sub': '日均跟踪'},
  830. {'label': '订单总数量', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
  831. 'change': _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0)),
  832. 'sub': '规模稳定'},
  833. {'label': '今日已更新', 'value': metrics['updated_orders'], 'unit': '单',
  834. 'change': _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0)),
  835. 'sub': '团队活跃'},
  836. {'label': '下月预测交付', 'value': metrics['forecast_next'], 'unit': '台',
  837. 'change': _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0)),
  838. 'sub': '交付预期'},
  839. {'label': '支持需求总数', 'value': metrics['support_requests'], 'unit': '项',
  840. 'change': '需跨部门协调', 'sub': '建议集中处理'},
  841. {'label': '已发运订单', 'value': metrics['shipped_orders'], 'unit': '单',
  842. 'change': f'共{metrics.get("shipped_orders", 0) * 8}台 | {metrics["shipped_orders"] - metrics.get("prev_shipped_orders", 0)}单 vs 昨日',
  843. 'sub': '交付稳步推进'},
  844. ]
  845. _add_kpi_cards(s2, kpis, start_y=Emu(content_top))
  846. # ---- Page 3: 10-Day Trend ----
  847. s3 = _duplicate_slide(prs, prs.slides[1])
  848. trend_dates = []
  849. trend_vals = []
  850. for d_offset in range(-9, 1):
  851. d = date + timedelta(days=d_offset)
  852. try:
  853. tdf = load_daily(data_file, d)
  854. trend_dates.append(d.strftime('%m/%d'))
  855. trend_vals.append(len(tdf))
  856. except Exception:
  857. pass
  858. # Generate conclusion title
  859. trend_conclusion = '近10天订单趋势'
  860. if len(trend_vals) >= 2:
  861. if trend_vals[-1] > trend_vals[-2]:
  862. trend_conclusion = '近10天订单趋势:订单规模回升'
  863. elif trend_vals[-1] < trend_vals[-2]:
  864. trend_conclusion = '近10天订单趋势:订单量继续回落'
  865. peak = max(trend_vals)
  866. if trend_vals[-1] == peak:
  867. trend_conclusion = '近10天订单趋势:今日达到峰值'
  868. _replace_all_placeholders(s3, {
  869. '{report_title}': '海外订单数据日报',
  870. '{date}': date_str,
  871. '{page_title}': trend_conclusion,
  872. '{source}': source,
  873. '{period}': '3/8',
  874. '{page_num}': '',
  875. })
  876. if len(trend_dates) >= 2:
  877. add_column_chart(s3, trend_dates, trend_vals,
  878. Emu(762000), Emu(content_top), Emu(8890000), Emu(5334000),
  879. series_name='订单量', color=C_ACCENT,
  880. category_axis_title='日期', value_axis_title='订单数')
  881. insight_items = generate_deep_insights('daily', 'trend', metrics,
  882. prev_metrics=prev_metrics,
  883. trend_dates=trend_dates,
  884. trend_vals=trend_vals)
  885. _add_structured_insight(s3, insight_items,
  886. Emu(9906000), Emu(content_top), Emu(4826000), Emu(5334000))
  887. # ---- Page 4: Status Distribution ----
  888. s4 = _duplicate_slide(prs, prs.slides[1])
  889. status_names = list(metrics['status_dist'].keys())
  890. status_vals = list(metrics['status_dist'].values())
  891. total_status = sum(status_vals)
  892. prod_share = metrics.get('production_share', 0)
  893. status_title = '订单状态分布'
  894. if prod_share > 0:
  895. status_title = f'订单状态分布:生产端占比提升至{prod_share:.1f}%'
  896. _replace_all_placeholders(s4, {
  897. '{report_title}': '海外订单数据日报',
  898. '{date}': date_str,
  899. '{page_title}': status_title,
  900. '{source}': source,
  901. '{period}': '4/8',
  902. '{page_num}': '',
  903. })
  904. if status_names and total_status > 0:
  905. # Left donut chart: 55% width
  906. chart_w = Emu(int(prs.slide_width) * 0.55)
  907. add_doughnut_chart(s4, status_names, status_vals,
  908. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  909. show_legend=True, show_data_labels=True, show_percent=True,
  910. ring_ratio=0.6)
  911. # Right side: status change + deep insights (no table to save space for dense text)
  912. text_left = Emu(int(prs.slide_width) * 0.62)
  913. text_w = Emu(int(prs.slide_width) * 0.36)
  914. prev_status = prev_metrics.get('status_dist', {})
  915. # Status change text: "合同拟定中 -30.8% ↓ | 已付订金待生产 +55.6% ↑"
  916. changes = []
  917. for name, curr in metrics['status_dist'].items():
  918. prev = prev_status.get(name, 0)
  919. if prev > 0:
  920. chg = _pct_val(curr, prev)
  921. if chg is not None:
  922. arrow = '↑' if chg >= 0 else '↓'
  923. changes.append(f'{name} {_format_pct(chg)}{arrow}')
  924. if changes:
  925. change_text = ' | '.join(changes[:4])
  926. _add_text_block(s4, '状态变化(vs 昨日)', change_text,
  927. text_left, Emu(content_top), text_w, Emu(609600),
  928. title_size=Pt(12), body_size=Pt(10))
  929. # Deep insight fills remaining right-side space
  930. insight_items = generate_deep_insights('daily', 'status', metrics,
  931. prev_status_dist=prev_status)
  932. insight_top = Emu(int(content_top) + 685800)
  933. insight_height = Emu(5334000 - 685800)
  934. _add_structured_insight(s4, insight_items,
  935. text_left, insight_top, text_w, insight_height)
  936. # ---- Page 5: Owner Distribution ----
  937. s5 = _duplicate_slide(prs, prs.slides[1])
  938. owner_names = list(metrics['owner_dist'].keys())[:8]
  939. owner_vals = list(metrics['owner_dist'].values())[:8]
  940. top_owner = owner_names[0] if owner_names else ''
  941. second_owner = owner_names[1] if len(owner_names) > 1 else ''
  942. owner_title = '负责人订单分布'
  943. if top_owner and second_owner:
  944. owner_title = f'负责人订单分布:{top_owner}、{second_owner}领跑'
  945. _replace_all_placeholders(s5, {
  946. '{report_title}': '海外订单数据日报',
  947. '{date}': date_str,
  948. '{page_title}': owner_title,
  949. '{source}': source,
  950. '{period}': '5/8',
  951. '{page_num}': '',
  952. })
  953. if owner_names:
  954. chart_w = Emu(int(prs.slide_width) * 0.55)
  955. text_left = Emu(int(prs.slide_width) * 0.62)
  956. text_w = Emu(int(prs.slide_width) * 0.36)
  957. add_horizontal_bar_chart(s5, owner_names, owner_vals,
  958. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  959. series_name='订单笔数', color=C_ACCENT, reverse_order=True,
  960. value_axis_title='订单笔数')
  961. # Deep insight: owner distribution analysis
  962. prev_owner_dist = prev_metrics.get('owner_dist', {}) if prev_metrics else {}
  963. insight_items = generate_deep_insights('daily', 'owner', metrics,
  964. prev_owner_dist=prev_owner_dist)
  965. _add_structured_insight(s5, insight_items,
  966. text_left, Emu(content_top), text_w, Emu(5334000))
  967. # ---- Page 6: Country TOP8 ----
  968. s6 = _duplicate_slide(prs, prs.slides[1])
  969. countries = list(metrics['country_top8'].keys())[:8]
  970. country_vals = list(metrics['country_top8'].values())[:8]
  971. top_country = countries[0] if countries else ''
  972. country_title = '目的国家TOP8'
  973. if top_country:
  974. country_title = f'目的国家TOP8:{top_country}订单量领先'
  975. _replace_all_placeholders(s6, {
  976. '{report_title}': '海外订单数据日报',
  977. '{date}': date_str,
  978. '{page_title}': country_title,
  979. '{source}': source,
  980. '{period}': '6/8',
  981. '{page_num}': '',
  982. })
  983. if countries:
  984. chart_w = Emu(int(prs.slide_width) * 0.55)
  985. text_left = Emu(int(prs.slide_width) * 0.62)
  986. text_w = Emu(int(prs.slide_width) * 0.36)
  987. add_horizontal_bar_chart(s6, countries, country_vals,
  988. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  989. series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
  990. value_axis_title='订单量(台)')
  991. # Deep insight: country top8 analysis
  992. insight_items = generate_deep_insights('daily', 'country', metrics)
  993. _add_structured_insight(s6, insight_items,
  994. text_left, Emu(content_top), text_w, Emu(5334000))
  995. # ---- Page 7: Support Analysis ----
  996. s7 = _duplicate_slide(prs, prs.slides[1])
  997. alerts = metrics['alerts']
  998. alert_title = '异常告警'
  999. if alerts:
  1000. severe = [a for a in alerts if a.get('level') == '严重']
  1001. if severe:
  1002. alert_title = f'异常告警:{severe[0]["title"]}'
  1003. else:
  1004. alert_title = f'异常告警:{alerts[0]["title"]}'
  1005. _replace_all_placeholders(s7, {
  1006. '{report_title}': '海外订单数据日报',
  1007. '{date}': date_str,
  1008. '{page_title}': alert_title,
  1009. '{source}': source,
  1010. '{period}': '7/8',
  1011. '{page_num}': '',
  1012. })
  1013. # Unified left-chart + right-insight layout
  1014. sc = metrics.get('support_categories', {})
  1015. chart_w = Emu(int(prs.slide_width) * 0.55)
  1016. text_left = Emu(int(prs.slide_width) * 0.62)
  1017. text_w = Emu(int(prs.slide_width) * 0.36)
  1018. if sc:
  1019. sc_names = list(sc.keys())
  1020. sc_vals = list(sc.values())
  1021. add_doughnut_chart(s7, sc_names, sc_vals,
  1022. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  1023. show_legend=True, show_data_labels=True, show_percent=True,
  1024. ring_ratio=0.6)
  1025. # Deep insight: alerts & support analysis
  1026. insight_items = generate_deep_insights('daily', 'alert', metrics)
  1027. _add_structured_insight(s7, insight_items,
  1028. text_left, Emu(content_top), text_w, Emu(5334000))
  1029. # ---- Page 8: Key Points ----
  1030. s8 = _duplicate_slide(prs, prs.slides[1])
  1031. _replace_all_placeholders(s8, {
  1032. '{report_title}': '海外订单数据日报',
  1033. '{date}': date_str,
  1034. '{page_title}': '明日工作重点',
  1035. '{source}': source,
  1036. '{period}': '8/8',
  1037. '{page_num}': '',
  1038. })
  1039. # Left chart: overdue orders horizontal bar (or support categories fallback)
  1040. overdue = metrics.get('overdue_orders', [])
  1041. chart_w = Emu(int(prs.slide_width) * 0.55)
  1042. text_left = Emu(int(prs.slide_width) * 0.62)
  1043. text_w = Emu(int(prs.slide_width) * 0.36)
  1044. if overdue:
  1045. o_countries = [o['country'] for o in overdue[:8]]
  1046. o_days = [o['days'] for o in overdue[:8]]
  1047. add_horizontal_bar_chart(s8, o_countries, o_days,
  1048. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1049. series_name='超期天数', color=C_ORANGE, reverse_order=True,
  1050. value_axis_title='天数')
  1051. elif metrics.get('support_categories'):
  1052. sc = metrics['support_categories']
  1053. add_horizontal_bar_chart(s8, list(sc.keys()), list(sc.values()),
  1054. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1055. series_name='需求数', color=C_ACCENT, reverse_order=True,
  1056. value_axis_title='数量')
  1057. # Deep insight: tomorrow's action items
  1058. action_items = generate_deep_insights('daily', 'action', metrics)
  1059. _add_structured_insight(s8, action_items,
  1060. text_left, Emu(content_top), text_w, Emu(5334000))
  1061. for slide in prs.slides:
  1062. _ensure_word_wrap_all(slide)
  1063. _delete_template_slides(prs)
  1064. prs.save(output_path)
  1065. print(f"Daily report saved: {output_path}")
  1066. # ==============================================================================
  1067. # WEEKLY REPORT
  1068. # ==============================================================================
  1069. def build_weekly_report(data_file: str, year: int, week: int, output_path: str,
  1070. department='海外事业部', source='海外订单日报系统'):
  1071. master_path = get_master_template('weekly')
  1072. prs = Presentation(master_path)
  1073. content_top = _detect_content_top(prs.slides[1])
  1074. df, prev_df = load_weekly(data_file, year, week)
  1075. metrics = calc_weekly_metrics(df, prev_df)
  1076. period_str = f"{year}年第{week}周"
  1077. date_range_str = f"{df['_data_date'].min().strftime('%m/%d')} - {df['_data_date'].max().strftime('%m/%d')}"
  1078. # Page 1: Cover
  1079. slide = _duplicate_slide(prs, prs.slides[0])
  1080. _replace_all_placeholders(slide, {
  1081. '{report_title}': '海外订单数据周报',
  1082. '{report_type}': 'Weekly Overseas Order Data Report',
  1083. '{date}': period_str,
  1084. '{department}': department,
  1085. '{period}': date_range_str,
  1086. '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
  1087. })
  1088. _add_footer_if_missing(slide, f'数据来源:{source} | 1/9')
  1089. # Fix {kpi4_label} {kpi4_value} with actual metric (下月预测交付)
  1090. cover_kpis = [
  1091. ('跟踪订单笔数', f"{metrics['tracking_orders']:,}", '笔',
  1092. _pct_str(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))),
  1093. ('订单总数量', f"{metrics['total_qty']:,}", '台',
  1094. _pct_str(metrics['total_qty'], metrics.get('prev_total_qty', 0))),
  1095. ('覆盖目的国', f"{metrics['countries']}", '个', '全球布局持续深化'),
  1096. ('下月预测交付', f"{metrics['forecast_next']:,}", '台',
  1097. _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0))),
  1098. ]
  1099. for i, (lbl, val, unit, chg) in enumerate(cover_kpis, 1):
  1100. _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
  1101. _replace_placeholder(slide, f'{{kpi{i}_value}}', str(val))
  1102. nav_labels = ['周汇总', '趋势图', '环比分析', '区域排行', '问题建议', '下周计划']
  1103. # Page 2: Weekly Summary (KPI cards)
  1104. s2 = _duplicate_slide(prs, prs.slides[1])
  1105. t_chg = _pct_val(metrics['tracking_orders'], metrics.get('prev_tracking_orders', 0))
  1106. q_chg = _pct_val(metrics['total_qty'], metrics.get('prev_total_qty', 0))
  1107. t_chg_str = _format_pct(t_chg)
  1108. q_chg_str = _format_pct(q_chg)
  1109. _replace_all_placeholders(s2, {
  1110. '{report_title}': '海外订单数据周报',
  1111. '{date}': period_str,
  1112. '{page_title}': f"周汇总:跟踪订单环比{t_chg_str},订单总量增长{q_chg_str}",
  1113. '{source}': source,
  1114. '{period}': '2/9',
  1115. '{page_num}': '',
  1116. })
  1117. _add_nav_tabs(s2, nav_labels, active_index=0, slide_width=prs.slide_width)
  1118. kpis = [
  1119. {'label': '跟踪订单笔数', 'value': f"{metrics['tracking_orders']:,}", 'unit': '笔',
  1120. '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 '—'),
  1121. 'sub': f'日均{metrics["avg_daily_orders"]:.0f}笔'},
  1122. {'label': '订单总数量', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
  1123. '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 '—'),
  1124. 'sub': f'日均{metrics["avg_daily_qty"]:.0f}台'},
  1125. {'label': '已发运订单', 'value': metrics['shipped_orders'], 'unit': '笔',
  1126. 'change': _pct_str(metrics['shipped_orders'], metrics.get('prev_shipped_orders', 0)),
  1127. 'sub': '环比大幅提升'},
  1128. {'label': '覆盖目的国', 'value': metrics['countries'], 'unit': '个',
  1129. 'change': '持平', 'sub': '全球布局持续深化'},
  1130. {'label': '进度更新订单', 'value': metrics['updated_orders'], 'unit': '笔',
  1131. 'change': _pct_str(metrics['updated_orders'], metrics.get('prev_updated_orders', 0)),
  1132. 'sub': '推进效率提升'},
  1133. {'label': '下月预测交付', 'value': f"{metrics['forecast_next']:,}", 'unit': '台',
  1134. 'change': _pct_str(metrics['forecast_next'], metrics.get('prev_forecast_next', 0)),
  1135. 'sub': '交付预期大幅上调'},
  1136. ]
  1137. _add_kpi_cards(s2, kpis, start_y=Emu(content_top))
  1138. # Page 3: 7-Day Trend
  1139. s3 = _duplicate_slide(prs, prs.slides[1])
  1140. trend = metrics.get('daily_trend', {})
  1141. trend_title = '7日趋势:订单总量稳步上升'
  1142. if trend:
  1143. dates = list(trend.keys())
  1144. vals = list(trend.values())
  1145. if vals:
  1146. peak = max(vals)
  1147. peak_date = dates[vals.index(peak)]
  1148. if vals[-1] >= vals[0]:
  1149. trend_title = f'7日趋势:订单总量稳步上升,峰值出现在{peak_date}'
  1150. else:
  1151. trend_title = f'7日趋势:订单量有所波动,峰值出现在{peak_date}'
  1152. _replace_all_placeholders(s3, {
  1153. '{report_title}': '海外订单数据周报',
  1154. '{date}': period_str,
  1155. '{page_title}': trend_title,
  1156. '{source}': source,
  1157. '{period}': '3/9',
  1158. '{page_num}': '',
  1159. })
  1160. _add_nav_tabs(s3, nav_labels, active_index=1, slide_width=prs.slide_width)
  1161. trend = metrics.get('daily_trend', {})
  1162. if trend:
  1163. dates = list(trend.keys())
  1164. vals = list(trend.values())
  1165. chart_w = Emu(int(prs.slide_width) * 0.55)
  1166. text_left = Emu(int(prs.slide_width) * 0.62)
  1167. text_w = Emu(int(prs.slide_width) * 0.36)
  1168. add_line_chart(s3, dates, vals,
  1169. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  1170. series_name='订单量', color=C_ACCENT,
  1171. category_axis_title='日期(MM/DD)', value_axis_title='订单数')
  1172. peak = max(vals) if vals else 0
  1173. peak_date = dates[vals.index(peak)] if vals else ''
  1174. avg = sum(vals) // len(vals) if vals else 0
  1175. prev_avg = metrics.get('prev_avg_daily_orders', 0)
  1176. above_days = metrics.get('days_above_prev_avg', 0)
  1177. total_days = len(vals)
  1178. # Deep insight: weekly trend analysis
  1179. insight_items = generate_deep_insights('weekly', 'weekly_trend', metrics,
  1180. trend_dates=dates, trend_vals=vals)
  1181. _add_structured_insight(s3, insight_items,
  1182. text_left, Emu(content_top), text_w, Emu(5334000))
  1183. # Page 4: WoW Analysis
  1184. s4 = _duplicate_slide(prs, prs.slides[1])
  1185. _replace_all_placeholders(s4, {
  1186. '{report_title}': '海外订单数据周报',
  1187. '{date}': period_str,
  1188. '{page_title}': '环比分析:各阶段全面增长,已发运环节增幅最大',
  1189. '{source}': source,
  1190. '{period}': '4/9',
  1191. '{page_num}': '',
  1192. })
  1193. _add_nav_tabs(s4, nav_labels, active_index=2, slide_width=prs.slide_width)
  1194. sw_data = metrics.get('status_wow', {})
  1195. if sw_data:
  1196. names = list(sw_data.keys())
  1197. current_vals = [v['current'] for v in sw_data.values()]
  1198. previous_vals = [v['previous'] for v in sw_data.values()]
  1199. # Replace None with 0 for chart data to avoid crashes
  1200. changes = [v['change_pct'] if v['change_pct'] is not None else 0 for v in sw_data.values()]
  1201. chart_w = Emu(int(prs.slide_width) * 0.55)
  1202. text_left = Emu(int(prs.slide_width) * 0.62)
  1203. text_w = Emu(int(prs.slide_width) * 0.36)
  1204. # Grouped bar chart: 本期 vs 上期
  1205. add_grouped_bar_chart(s4, names,
  1206. [('本期', current_vals), ('上期', previous_vals)],
  1207. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1208. colors=[C_ACCENT, C_SECONDARY],
  1209. show_legend=True, show_data_labels=True,
  1210. category_axis_title='订单状态', value_axis_title='订单数')
  1211. # WoW增幅 labels below chart
  1212. wow_label_items = []
  1213. for k, v in sw_data.items():
  1214. pct_str = _format_pct(v['change_pct'])
  1215. arrow = '↑' if v['change_pct'] is not None and v['change_pct'] >= 0 else '↓' if v['change_pct'] is not None else ''
  1216. wow_label_items.append(f'{k} {pct_str}{arrow}')
  1217. _add_text_block(s4, '环比增幅', ' | '.join(wow_label_items),
  1218. Emu(762000), Emu(6604000), chart_w, Emu(609600),
  1219. title_size=Pt(11), body_size=Pt(10))
  1220. # Deep insight: WoW analysis
  1221. insight_items = generate_deep_insights('weekly', 'weekly_wow', metrics)
  1222. _add_structured_insight(s4, insight_items,
  1223. text_left, Emu(content_top), text_w, Emu(4826000))
  1224. # Page 5: Region Distribution
  1225. s5 = _duplicate_slide(prs, prs.slides[1])
  1226. _replace_all_placeholders(s5, {
  1227. '{report_title}': '海外订单数据周报',
  1228. '{date}': period_str,
  1229. '{page_title}': '区域分布:中东增速领跑,欧洲为唯一下滑区域',
  1230. '{source}': source,
  1231. '{period}': '5/9',
  1232. '{page_num}': '',
  1233. })
  1234. _add_nav_tabs(s5, nav_labels, active_index=3, slide_width=prs.slide_width)
  1235. rdist = metrics.get('region_dist', {})
  1236. if rdist:
  1237. regions = list(rdist.keys())
  1238. qtys = [v['qty'] for v in rdist.values()]
  1239. chart_w = Emu(int(prs.slide_width) * 0.55)
  1240. text_left = Emu(int(prs.slide_width) * 0.62)
  1241. text_w = Emu(int(prs.slide_width) * 0.36)
  1242. add_doughnut_chart(s5, regions, qtys,
  1243. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  1244. show_legend=True, show_data_labels=True, show_percent=True,
  1245. ring_ratio=0.6)
  1246. # Deep insight: regional analysis
  1247. insight_items = generate_deep_insights('weekly', 'weekly_region', metrics)
  1248. _add_structured_insight(s5, insight_items,
  1249. text_left, Emu(content_top), text_w, Emu(5334000))
  1250. # Page 6: TOP Countries
  1251. s6 = _duplicate_slide(prs, prs.slides[1])
  1252. _replace_all_placeholders(s6, {
  1253. '{report_title}': '海外订单数据周报',
  1254. '{date}': period_str,
  1255. '{page_title}': 'TOP国家排行:科威特344台居首,TOP15贡献70%+总量',
  1256. '{source}': source,
  1257. '{period}': '6/9',
  1258. '{page_num}': '',
  1259. })
  1260. _add_nav_tabs(s6, nav_labels, active_index=3, slide_width=prs.slide_width)
  1261. topc_change = metrics.get('top_countries_change', {})
  1262. if topc_change:
  1263. countries = list(topc_change.keys())[:10]
  1264. vals = [v['qty'] for v in list(topc_change.values())[:10]]
  1265. chart_w = Emu(int(prs.slide_width) * 0.55)
  1266. text_left = Emu(int(prs.slide_width) * 0.62)
  1267. text_w = Emu(int(prs.slide_width) * 0.36)
  1268. add_horizontal_bar_chart(s6, countries, vals,
  1269. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1270. series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
  1271. value_axis_title='订单量(台)')
  1272. # Deep insight: top countries analysis
  1273. insight_items = generate_deep_insights('weekly', 'weekly_country', metrics)
  1274. _add_structured_insight(s6, insight_items,
  1275. text_left, Emu(content_top), text_w, Emu(4826000))
  1276. # Page 7: Team Tracking
  1277. s7 = _duplicate_slide(prs, prs.slides[1])
  1278. team = metrics.get('team', {})
  1279. n_members = len(team.get('owners', {})) if team else 0
  1280. n_growers = sum(1 for v in metrics.get('team_wow', {}).values() if v.get('change', 0) > 0)
  1281. team_title = f'团队追踪:{n_members}人团队全面覆盖,{n_growers}人实现增长'
  1282. _replace_all_placeholders(s7, {
  1283. '{report_title}': '海外订单数据周报',
  1284. '{date}': period_str,
  1285. '{page_title}': team_title,
  1286. '{source}': source,
  1287. '{period}': '7/9',
  1288. '{page_num}': '',
  1289. })
  1290. _add_nav_tabs(s7, nav_labels, active_index=4, slide_width=prs.slide_width)
  1291. team = metrics.get('team', {})
  1292. owners = team.get('owners', {})
  1293. if owners:
  1294. names = list(owners.keys())
  1295. vals = list(owners.values())
  1296. chart_w = Emu(int(prs.slide_width) * 0.55)
  1297. text_left = Emu(int(prs.slide_width) * 0.62)
  1298. text_w = Emu(int(prs.slide_width) * 0.36)
  1299. add_horizontal_bar_chart(s7, names, vals,
  1300. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1301. series_name='订单笔数', color=C_ACCENT, reverse_order=True,
  1302. value_axis_title='订单笔数')
  1303. # Deep insight: team tracking
  1304. insight_items = generate_deep_insights('weekly', 'weekly_team', metrics)
  1305. _add_structured_insight(s7, insight_items,
  1306. text_left, Emu(content_top), text_w, Emu(4826000))
  1307. # Page 8: Issues
  1308. s8 = _duplicate_slide(prs, prs.slides[1])
  1309. _replace_all_placeholders(s8, {
  1310. '{report_title}': '海外订单数据周报',
  1311. '{date}': period_str,
  1312. '{page_title}': '问题识别:系统数据匹配问题为本周首要障碍',
  1313. '{source}': source,
  1314. '{period}': '8/9',
  1315. '{page_num}': '',
  1316. })
  1317. _add_nav_tabs(s8, nav_labels, active_index=4, slide_width=prs.slide_width)
  1318. # Left chart: support request categories
  1319. sc = metrics.get('support_categories', {})
  1320. chart_w = Emu(int(prs.slide_width) * 0.55)
  1321. text_left = Emu(int(prs.slide_width) * 0.62)
  1322. text_w = Emu(int(prs.slide_width) * 0.36)
  1323. if sc:
  1324. add_horizontal_bar_chart(s8, list(sc.keys()), list(sc.values()),
  1325. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1326. series_name='需求数', color=C_ORANGE, reverse_order=True,
  1327. value_axis_title='数量')
  1328. # Right: deep insight
  1329. insight_items = generate_deep_insights('weekly', 'weekly_issue', metrics)
  1330. _add_structured_insight(s8, insight_items,
  1331. text_left, Emu(content_top), text_w, Emu(5334000))
  1332. # Page 9: Next Week Plan
  1333. s9 = _duplicate_slide(prs, prs.slides[1])
  1334. _replace_all_placeholders(s9, {
  1335. '{report_title}': '海外订单数据周报',
  1336. '{date}': period_str,
  1337. '{page_title}': '下周计划:聚焦发运交付,冲刺交付目标',
  1338. '{source}': source,
  1339. '{period}': '9/9',
  1340. '{page_num}': '',
  1341. })
  1342. _add_nav_tabs(s9, nav_labels, active_index=5, slide_width=prs.slide_width)
  1343. # Left chart: goals as column chart
  1344. goals = metrics.get('next_week_goals', [])
  1345. chart_w = Emu(int(prs.slide_width) * 0.55)
  1346. text_left = Emu(int(prs.slide_width) * 0.62)
  1347. text_w = Emu(int(prs.slide_width) * 0.36)
  1348. if goals:
  1349. goal_names = [g['title'].split(':')[0] for g in goals[:4]]
  1350. goal_nums = [g.get('number', 0) for g in goals[:4]]
  1351. add_column_chart(s9, goal_names, goal_nums,
  1352. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1353. series_name='目标数量', color=C_ACCENT,
  1354. category_axis_title='目标', value_axis_title='数量')
  1355. # Deep insight: next week plan
  1356. insight_items = generate_deep_insights('weekly', 'weekly_plan', metrics)
  1357. _add_structured_insight(s9, insight_items,
  1358. text_left, Emu(content_top), text_w, Emu(5334000))
  1359. for slide in prs.slides:
  1360. _ensure_word_wrap_all(slide)
  1361. _delete_template_slides(prs)
  1362. prs.save(output_path)
  1363. print(f"Weekly report saved: {output_path}")
  1364. # ==============================================================================
  1365. # MONTHLY REPORT
  1366. # ==============================================================================
  1367. def build_monthly_report(data_file: str, year: int, month: int, output_path: str,
  1368. department='海外事业部', source='海外订单日报系统'):
  1369. master_path = get_master_template('monthly')
  1370. prs = Presentation(master_path)
  1371. content_top = _detect_content_top(prs.slides[1])
  1372. df, prev_df, yoy_df = load_monthly(data_file, year, month)
  1373. metrics = calc_monthly_metrics(df, prev_df, yoy_df)
  1374. period_str = f"{year}年{month}月"
  1375. # Page 1: Cover
  1376. slide = _duplicate_slide(prs, prs.slides[0])
  1377. _replace_all_placeholders(slide, {
  1378. '{report_title}': '海外订单月度数据报告',
  1379. '{report_type}': 'Monthly Overseas Order Data Report',
  1380. '{date}': period_str,
  1381. '{department}': department,
  1382. '{period}': period_str,
  1383. '{gen_time}': datetime.now().strftime('%Y-%m-%d %H:%M'),
  1384. })
  1385. _add_footer_if_missing(slide, f'数据来源:{source} | 1/11')
  1386. cover_kpis = [
  1387. ('合同总数', f"{metrics['total_contracts']:,}"),
  1388. ('车辆总数', f"{metrics['total_qty']:,}"),
  1389. ('目的国家', f"{metrics['countries']}+"),
  1390. ('负责团队', '9人'),
  1391. ]
  1392. for i, (lbl, val) in enumerate(cover_kpis, 1):
  1393. _replace_placeholder(slide, f'{{kpi{i}_label}}', lbl)
  1394. _replace_placeholder(slide, f'{{kpi{i}_value}}', val)
  1395. # Page 2: TOC
  1396. s_toc = _duplicate_slide(prs, prs.slides[2])
  1397. _add_footer_if_missing(s_toc, f'数据来源:{source} | 2/11')
  1398. _replace_all_placeholders(s_toc, {
  1399. '{chapter1_title}': '月度总览',
  1400. '{chapter1_desc}': '核心KPI一览:合同总数、车辆规模、新签与发运表现',
  1401. '{chapter2_title}': '订单状态分析',
  1402. '{chapter2_desc}': '订单阶段漏斗:从合同拟定到发运的全流程追踪',
  1403. '{chapter3_title}': '区域与趋势',
  1404. '{chapter3_desc}': '区域分布、国家排名与30日追踪趋势',
  1405. '{chapter4_title}': '团队与展望',
  1406. '{chapter4_desc}': '团队绩效、支持需求与下月工作规划',
  1407. })
  1408. nav_labels = ['月度总览', '订单状态', '区域趋势', '团队展望']
  1409. # Page 3: Monthly Overview
  1410. s3 = _duplicate_slide(prs, prs.slides[1])
  1411. _replace_all_placeholders(s3, {
  1412. '{report_title}': '海外订单月度数据报告',
  1413. '{date}': period_str,
  1414. '{page_title}': f"{month}月核心指标:累计追踪{metrics['total_contracts']:,}单,覆盖{metrics['total_qty']:,}台车辆",
  1415. '{source}': source,
  1416. '{period}': '3/11',
  1417. '{page_num}': '',
  1418. })
  1419. _add_nav_tabs(s3, nav_labels, active_index=0, slide_width=prs.slide_width)
  1420. kpis = [
  1421. {'label': '合同总数', 'value': f"{metrics['total_contracts']:,}", 'unit': '单',
  1422. 'change': f'日均{metrics["avg_daily_orders"]:.0f}单', 'sub': '覆盖全月'},
  1423. {'label': '车辆总数', 'value': f"{metrics['total_qty']:,}", 'unit': '台',
  1424. 'change': f"{metrics['shipped_qty']:,}台已发运", 'sub': '交付持续推进'},
  1425. {'label': f'{month}月新签', 'value': metrics['new_contracts'], 'unit': '单',
  1426. 'change': f"{metrics['new_qty']:,}台", 'sub': '新签势头良好'},
  1427. {'label': '已发运', 'value': metrics['shipped_orders'], 'unit': '单',
  1428. 'change': f"{metrics['shipped_qty']:,}台", 'sub': '交付稳步推进'},
  1429. {'label': '目的国', 'value': f"{metrics['countries']}+", 'unit': '个',
  1430. 'change': '全球市场布局', 'sub': '持续深化'},
  1431. {'label': '待处理需求', 'value': metrics['support_count'], 'unit': '单',
  1432. 'change': f"{metrics['support_pct']}%订单涉及", 'sub': '需跨部门协调'},
  1433. ]
  1434. _add_kpi_cards(s3, kpis, start_y=Emu(content_top))
  1435. # Monthly overview: KPI cards only, no bottom insight text to avoid overlap with cards
  1436. # Page 4: Status Funnel
  1437. s4 = _duplicate_slide(prs, prs.slides[1])
  1438. _replace_all_placeholders(s4, {
  1439. '{report_title}': '海外订单月度数据报告',
  1440. '{date}': period_str,
  1441. '{page_title}': '订单阶段漏斗:合同拟定与生产中订单占主导地位',
  1442. '{source}': source,
  1443. '{period}': '4/11',
  1444. '{page_num}': '',
  1445. })
  1446. _add_nav_tabs(s4, nav_labels, active_index=1, slide_width=prs.slide_width)
  1447. funnel = metrics.get('status_funnel', {})
  1448. if funnel:
  1449. names = list(funnel.keys())
  1450. orders = [v['orders'] for v in funnel.values()]
  1451. chart_w = Emu(int(prs.slide_width) * 0.55)
  1452. text_left = Emu(int(prs.slide_width) * 0.62)
  1453. text_w = Emu(int(prs.slide_width) * 0.36)
  1454. add_funnel_chart(s4, names, orders,
  1455. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  1456. show_data_labels=True, show_percent=True)
  1457. # Deep insight: monthly funnel
  1458. insight_items = generate_deep_insights('monthly', 'monthly_funnel', metrics)
  1459. _add_structured_insight(s4, insight_items,
  1460. text_left, Emu(content_top), text_w, Emu(5334000))
  1461. # Page 5: Region Distribution
  1462. s5 = _duplicate_slide(prs, prs.slides[1])
  1463. _replace_all_placeholders(s5, {
  1464. '{report_title}': '海外订单月度数据报告',
  1465. '{date}': period_str,
  1466. '{page_title}': '区域分布:拉美、东南亚、非洲三大市场并驾齐驱',
  1467. '{source}': source,
  1468. '{period}': '5/11',
  1469. '{page_num}': '',
  1470. })
  1471. _add_nav_tabs(s5, nav_labels, active_index=2, slide_width=prs.slide_width)
  1472. rdist = metrics.get('region_dist', {})
  1473. if rdist:
  1474. regions = list(rdist.keys())
  1475. qtys = [v['qty'] for v in rdist.values()]
  1476. chart_w = Emu(int(prs.slide_width) * 0.55)
  1477. text_left = Emu(int(prs.slide_width) * 0.62)
  1478. text_w = Emu(int(prs.slide_width) * 0.36)
  1479. add_doughnut_chart(s5, regions, qtys,
  1480. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  1481. show_legend=True, show_data_labels=True, show_percent=True,
  1482. ring_ratio=0.6)
  1483. # Deep insight: monthly region
  1484. insight_items = generate_deep_insights('monthly', 'monthly_region', metrics)
  1485. _add_structured_insight(s5, insight_items,
  1486. text_left, Emu(content_top), text_w, Emu(5334000))
  1487. # Page 6: TOP10 Countries
  1488. s6 = _duplicate_slide(prs, prs.slides[1])
  1489. top10 = metrics.get('top_countries', {})
  1490. top_country = list(top10.keys())[0] if top10 else ''
  1491. top_qty = list(top10.values())[0]['qty'] if top10 else 0
  1492. top10_title = f'Top 10目的国:{top_country}{top_qty:,}台领跑' if top_country else 'Top 10目的国:重点市场领跑'
  1493. _replace_all_placeholders(s6, {
  1494. '{report_title}': '海外订单月度数据报告',
  1495. '{date}': period_str,
  1496. '{page_title}': top10_title,
  1497. '{source}': source,
  1498. '{period}': '6/11',
  1499. '{page_num}': '',
  1500. })
  1501. _add_nav_tabs(s6, nav_labels, active_index=2, slide_width=prs.slide_width)
  1502. top10 = metrics.get('top_countries', {})
  1503. if top10:
  1504. countries = list(top10.keys())
  1505. qtys = [v['qty'] for v in top10.values()]
  1506. chart_w = Emu(int(prs.slide_width) * 0.55)
  1507. text_left = Emu(int(prs.slide_width) * 0.62)
  1508. text_w = Emu(int(prs.slide_width) * 0.36)
  1509. add_horizontal_bar_chart(s6, countries, qtys,
  1510. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1511. series_name='订单量(台)', color=C_ACCENT, reverse_order=True,
  1512. value_axis_title='订单量(台)')
  1513. # Deep insight: monthly top countries
  1514. insight_items = generate_deep_insights('monthly', 'monthly_country', metrics)
  1515. _add_structured_insight(s6, insight_items,
  1516. text_left, Emu(content_top), text_w, Emu(4826000))
  1517. # Page 7: 30-Day Trend
  1518. s7 = _duplicate_slide(prs, prs.slides[1])
  1519. _replace_all_placeholders(s7, {
  1520. '{report_title}': '海外订单月度数据报告',
  1521. '{date}': period_str,
  1522. '{page_title}': '30日追踪趋势:下旬订单活跃度显著提升',
  1523. '{source}': source,
  1524. '{period}': '7/11',
  1525. '{page_num}': '',
  1526. })
  1527. _add_nav_tabs(s7, nav_labels, active_index=2, slide_width=prs.slide_width)
  1528. trend = metrics.get('daily_trend', {})
  1529. chart_w = Emu(int(prs.slide_width) * 0.55)
  1530. text_left = Emu(int(prs.slide_width) * 0.62)
  1531. text_w = Emu(int(prs.slide_width) * 0.36)
  1532. if trend:
  1533. dates = list(trend.keys())
  1534. vals = list(trend.values())
  1535. add_line_chart(s7, dates, vals,
  1536. Emu(762000), Emu(content_top), chart_w, Emu(5334000),
  1537. series_name='订单量', color=C_ACCENT,
  1538. category_axis_title='日期(MM/DD)', value_axis_title='订单数')
  1539. tbp = metrics.get('trend_by_period', {})
  1540. late_change = tbp.get('late_change_pct', 0)
  1541. late_change_str = _format_pct(late_change, with_sign=True) if late_change is not None else '—'
  1542. # Deep insight: monthly trend
  1543. insight_items = generate_deep_insights('monthly', 'monthly_trend', metrics)
  1544. _add_structured_insight(s7, insight_items,
  1545. text_left, Emu(content_top), text_w, Emu(5334000))
  1546. # Page 8: Team Performance
  1547. s8 = _duplicate_slide(prs, prs.slides[1])
  1548. _replace_all_placeholders(s8, {
  1549. '{report_title}': '海外订单月度数据报告',
  1550. '{date}': period_str,
  1551. '{page_title}': '团队绩效:9位负责人均匀分布,多人领跑',
  1552. '{source}': source,
  1553. '{period}': '8/11',
  1554. '{page_num}': '',
  1555. })
  1556. _add_nav_tabs(s8, nav_labels, active_index=3, slide_width=prs.slide_width)
  1557. team = metrics.get('team', {})
  1558. if team:
  1559. names = list(team.keys())
  1560. orders = [v['orders'] for v in team.values()]
  1561. qtys = [v['qty'] for v in team.values()]
  1562. chart_w = Emu(int(prs.slide_width) * 0.55)
  1563. text_left = Emu(int(prs.slide_width) * 0.62)
  1564. text_w = Emu(int(prs.slide_width) * 0.36)
  1565. # Horizontal bar chart for orders + secondary series for qty
  1566. add_grouped_bar_chart(s8, names,
  1567. [('订单数', orders), ('车辆数', qtys)],
  1568. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1569. colors=[C_ACCENT, C_ORANGE],
  1570. show_legend=True, show_data_labels=True,
  1571. category_axis_title='负责人', value_axis_title='数量')
  1572. # Deep insight: monthly team
  1573. insight_items = generate_deep_insights('monthly', 'monthly_team', metrics)
  1574. _add_structured_insight(s8, insight_items,
  1575. text_left, Emu(content_top), text_w, Emu(4826000))
  1576. # Page 9: Support Analysis
  1577. s9 = _duplicate_slide(prs, prs.slides[1])
  1578. _replace_all_placeholders(s9, {
  1579. '{report_title}': '海外订单月度数据报告',
  1580. '{date}': period_str,
  1581. '{page_title}': '支持需求分析:财务、售后、法务为三大核心诉求',
  1582. '{source}': source,
  1583. '{period}': '9/11',
  1584. '{page_num}': '',
  1585. })
  1586. _add_nav_tabs(s9, nav_labels, active_index=3, slide_width=prs.slide_width)
  1587. sc = metrics.get('support_categories', {})
  1588. if sc:
  1589. cats = list(sc.keys())
  1590. vals = list(sc.values())
  1591. add_horizontal_bar_chart(s9, cats, vals,
  1592. Emu(762000), Emu(content_top), Emu(8636000), Emu(5334000),
  1593. series_name='需求数', color=C_ACCENT, reverse_order=True,
  1594. value_axis_title='需求数')
  1595. top_cat = max(sc.items(), key=lambda x: x[1])
  1596. # Deep insight: monthly support
  1597. insight_items = generate_deep_insights('monthly', 'monthly_support', metrics)
  1598. _add_structured_insight(s9, insight_items,
  1599. Emu(9779000), Emu(content_top), Emu(5715000), Emu(5334000))
  1600. # Page 10: Next Month Plan
  1601. s10 = _duplicate_slide(prs, prs.slides[1])
  1602. _replace_all_placeholders(s10, {
  1603. '{report_title}': '海外订单月度数据报告',
  1604. '{date}': period_str,
  1605. '{page_title}': f'{month+1 if month < 12 else 1}月展望:预测交付{metrics["forecast_next"]}台,重点关注交付转化',
  1606. '{source}': source,
  1607. '{period}': '10/11',
  1608. '{page_num}': '',
  1609. })
  1610. _add_nav_tabs(s10, nav_labels, active_index=3, slide_width=prs.slide_width)
  1611. # Left chart: next month goals as column chart
  1612. goals = metrics.get('next_month_goals', [])
  1613. chart_w = Emu(int(prs.slide_width) * 0.55)
  1614. text_left = Emu(int(prs.slide_width) * 0.62)
  1615. text_w = Emu(int(prs.slide_width) * 0.36)
  1616. if goals:
  1617. goal_names = [g['title'].split(':')[0] for g in goals[:5]]
  1618. goal_nums = [g.get('number', 0) for g in goals[:5]]
  1619. add_column_chart(s10, goal_names, goal_nums,
  1620. Emu(762000), Emu(content_top), chart_w, Emu(4826000),
  1621. series_name='目标数量', color=C_ACCENT,
  1622. category_axis_title='目标', value_axis_title='数量')
  1623. # Deep insight: monthly plan
  1624. insight_items = generate_deep_insights('monthly', 'monthly_plan', metrics)
  1625. _add_structured_insight(s10, insight_items,
  1626. text_left, Emu(content_top), text_w, Emu(5334000))
  1627. # Page 11: End
  1628. s_end = _duplicate_slide(prs, prs.slides[3])
  1629. _add_footer_if_missing(s_end, f'数据来源:{source} | 11/11')
  1630. _replace_all_placeholders(s_end, {
  1631. '{report_title}': '海外订单月度数据报告',
  1632. '{date}': period_str,
  1633. '{department}': department,
  1634. })
  1635. end_kpis = [
  1636. ('合同总数', f"{metrics['total_contracts']:,}"),
  1637. ('车辆总数', f"{metrics['total_qty']:,}"),
  1638. ('目的国家', f"{metrics['countries']}+"),
  1639. ('团队', '9人'),
  1640. ]
  1641. for i, (lbl, val) in enumerate(end_kpis, 1):
  1642. _replace_placeholder(s_end, f'{{kpi{i}_label}}', lbl)
  1643. _replace_placeholder(s_end, f'{{kpi{i}_value}}', val)
  1644. for slide in prs.slides:
  1645. _ensure_word_wrap_all(slide)
  1646. _delete_template_slides(prs)
  1647. prs.save(output_path)
  1648. print(f"Monthly report saved: {output_path}")
  1649. # ==============================================================================
  1650. # CLI
  1651. # ==============================================================================
  1652. if __name__ == '__main__':
  1653. import sys
  1654. if len(sys.argv) >= 4:
  1655. cmd = sys.argv[1]
  1656. data_file = sys.argv[2]
  1657. output = sys.argv[3]
  1658. if cmd == 'daily':
  1659. d = datetime.strptime(sys.argv[4], '%Y-%m-%d')
  1660. build_daily_report(data_file, d, output)
  1661. elif cmd == 'weekly':
  1662. build_weekly_report(data_file, int(sys.argv[4]), int(sys.argv[5]), output)
  1663. elif cmd == 'monthly':
  1664. build_monthly_report(data_file, int(sys.argv[4]), int(sys.argv[5]), output)