5 İşlemeler 88d654d51e ... aec32b2e18

Yazar SHA1 Mesaj Tarih
  kyle aec32b2e18 v0.2.0 2 hafta önce
  kyle 1f2b943df1 完善模板生成问题 2 hafta önce
  kyle 643448a00d 精简代码 2 hafta önce
  kyle 71e2503eb7 新增模板导入功能 2 hafta önce
  kyle e149b4c408 新添数据分析师角色,增强数据分析能力 3 hafta önce

+ 107 - 16
generate-data-report-ppt/SKILL.md

@@ -42,8 +42,7 @@ generate-data-report-ppt/
 │   ├── page_layouts.py             # 动态页面布局引擎(新增)
 │   ├── quality_rules.py            # 质量检查规则定义(新增)
 │   ├── quality_inspector.py        # 质量自检与自动修复引擎(新增)
-│   ├── deep_insights.py            # 结构化深度洞察生成(周报/月报)
-│   └── ppt_builder.py              # PPT 组装编排器(新增 build_report / quality_assured_build)
+│   ├── ppt_builder.py              # PPT 组装编排器(build_report / quality_assured_build)
 ├── references/
 │   ├── data-schema.md              # Excel 字段映射与校验规则
 │   ├── report-structures.md        # 日报/周报/月报页面结构
@@ -78,6 +77,8 @@ generate-data-report-ppt/
 - 每套主题包含:主色、辅色、强调色、背景色、文字色、系列色盘
 - 支持自定义配色覆盖
 - `theme_to_rgb_colors()` 一键转换为 pptx RGBColor 对象
+- 新增 `extract_theme_from_template()` 从用户上传的模板母版自动提取主题色和字体
+- 新增 `ThemePreset.FROM_TEMPLATE` 使用模板自带配色方案
 
 ### 智能分析(agent_analyzer.py)
 - 自动识别可量化的数值指标
@@ -96,6 +97,14 @@ generate-data-report-ppt/
 - `calculate_content_area()` 计算可用内容区域
 - `calculate_fill_ratio()` 计算页面内容填充率
 - `ensure_safe_position()` 确保元素在页面安全区域内
+- 新增 `LayoutContext`:封装模板实际尺寸(宽/高/边距/内容区),支持 16:9、4:3 等任意尺寸模板
+
+### 自定义模板解析(template_parser.py)
+- 解析任意 `.pptx` 模板,自动识别母版页类型(封面/内容/目录/尾页)
+- 提取占位符列表,支持别名映射(如 `{report_title}` / `{标题}`)
+- 自动检测主题色、字体、幻灯片尺寸、安全边距、内容区域
+- 输出 `TemplateProfile`,供 `ppt_builder.py` 动态选择母版页和适配布局
+- 内置模板与用户模板走同一套解析流程,保证接口一致
 
 ### 质量自检(quality_rules.py + quality_inspector.py)
 
@@ -120,31 +129,27 @@ generate-data-report-ppt/
 - 次要问题:-3 分/页
 - 加权归一化到 100 分制
 
-## 报告类型(原有,保持兼容
+## 报告类型(配置驱动
 
 ### 日报
-- 结构:封面 → 核心指标概览 → 近10天趋势 → 订单状态分布 → 负责人分布 → 目的国家 TOP8 → 异常告警 → 今日要点
-- 页数:8
+- 结构:封面 → KPI总览 → 趋势图 → 分布/排行 → 异常告警 → 总结
+- 页数:配置驱动
 ### 周报
-- 结构:封面 → 周汇总 → 7日趋势 → 环比分析 → 区域分布 → 国家排行 → 团队追踪 → 问题与建议 → 下周计划
-- 页数:9
+- 结构:封面 → 周汇总 → 7日趋势 → 环比分析 → 分布/排行 → 总结
+- 页数:配置驱动
 ### 月报
-- 结构:封面 → 目录 → 月度总览 → 订单状态漏斗 → 区域分布 → TOP10 目的国 → 30日追踪趋势 → 团队绩效 → 支持需求分析 → 下月规划 → 尾页
-- 页数:11
+- 结构:封面 → 目录 → 月度总览 → 趋势/分布 → 排行/绩效 → 预测规划 → 尾页
+- 页数:配置驱动
 
 ## 执行示例
 
 ```python
-from scripts.ppt_builder import build_daily_report, build_report, quality_assured_build
-from scripts.report_config import ReportConfig, PageDef, MetricDef
-
-# === 原有方式(保持兼容)===
-build_daily_report('data.xlsx', datetime(2026, 4, 10), 'daily.pptx')
+from scripts.ppt_builder import build_report, quality_assured_build
+from scripts.report_config import ReportConfig, PageDef, MetricDef, PeriodType
 
-# === 新通用方式 ===
 config = ReportConfig(
     title='销售数据报告',
-    period_type='monthly',
+    period_type=PeriodType.MONTHLY,
     source_label='销售部',
     theme='business_classic',
     quality_threshold=85,
@@ -154,6 +159,18 @@ config = ReportConfig(
 
 build_report('any_data.xlsx', config, 'output.pptx')
 
+# === 使用自定义模板 ===
+config = ReportConfig(
+    title='销售数据报告',
+    period_type='monthly',
+    source_label='销售部',
+    template_path='my-template.pptx',   # 用户上传的模板
+    use_template_theme=True,             # 自动应用模板提取的配色
+    quality_threshold=85,
+    max_fix_iterations=3,
+)
+build_report('any_data.xlsx', config, 'output_custom.pptx')
+
 # === 带质量保证的方式(推荐)===
 prs, issues = quality_assured_build('any_data.xlsx', config, 'output_qa.pptx')
 ```
@@ -176,3 +193,77 @@ Data profiling serves the confirmed business intent. It should map the confirmed
 For visual quality, treat master PPTX files as style assets, not rigid page contracts. If a template placeholder cannot be populated, remove the whole placeholder component. If a KPI grid consumes the available vertical space, do not add bottom insight text; use a later analysis page or a different layout instead.
 
 For analytical quality, load `references/professional-data-analyst-playbook.md` before generating or reviewing report narratives. A page that only restates totals, rankings, or category names without comparison, diagnosis, implication, or action is not acceptable even if layout quality passes.
+
+## 自定义模板规范
+
+用户可提供自定义 `.pptx` 模板,skill 自动解析并按其样式生成报告。
+
+### 母版页结构(建议)
+| 母版页 | 建议包含的占位符 | 用途 |
+|--------|-----------------|------|
+| 封面页 | `{report_title}`, `{date}`, `{department}` | 报告封面 |
+| 目录页 | `{chapter1_title}`, `{chapter1_desc}`, ... | 目录/导航页 |
+| 内容页 | `{page_title}`, `{source}`, `{period}` | 正文页(图表、洞察) |
+| 尾页 | `{report_title}` | 结束页 |
+
+### 占位符规则
+- 使用 `{}` 包裹,如 `{report_title}`
+- 不强制要求全部占位符,缺少的会自动跳过或智能补充
+- 支持中文别名:`{标题}`, `{日期}`, `{部门}`, `{页面标题}`, `{数据来源}`, `{页码}` 等
+
+### 样式设计建议
+- **主题色**:在母版中设置主题颜色,agent 会自动提取并应用到图表、卡片、文本
+- **字体**:在母版中分别设置标题和正文字体,agent 会识别并统一应用
+- **背景**:纯色、渐变或图片背景均可,复制时会完整保留
+- **尺寸**:支持 16:9、4:3 等任意尺寸,布局引擎自动适配
+- **页眉/页脚**:已有的页眉页脚图形会保留,agent 自动检测避免重复添加
+
+### 配色优先级
+1. 用户显式配置 `config.theme`(最高优先级)
+2. 模板提取的配色(`use_template_theme=True` 时生效)
+3. 默认商务经典主题(兜底)
+
+## 关键缺陷与修复记录
+
+> **封面页速查** — quality_inspector 自动检测以下问题(V006/C012),无需手动排查:
+> 1. `_duplicate_slide()` 不用 `source_slide.slide_layout` → 背景/Logo 丢失 ✅ 代码已修复
+> 2. `keep_shapes=False` 用于封面 → 占位符被删除 ✅ 代码已修复
+> 3. 占位符文字浅色但位置在白色背景区域 → **V006 自动检测+修复**
+> 4. 封面占位符仍为模板默认文字 → **C012 自动检测,阻断输出**
+
+### `_build_cover_page()` 双阶段占位符填充 — idx 兜底机制 (2026-05-25)
+
+**症状**: 模板封面无 `{report_title}` 等文本标记时,封面显示空白。
+
+**修复**: 两阶段策略:
+- **Pass 1**: `_replace_all_placeholders()` 文本模式匹配(兼容内置模板)
+- **Pass 2**: `placeholder_format.idx` 兜底直填(兼容 Wuling 等无标记模板)
+
+```python
+# idx 映射:0=TITLE, 10=SUBTITLE, 21/22=BODY quarter-size
+if ph.idx == 0:   _set_para_text(p, config.title, C_PRIMARY, Pt(36))
+elif ph.idx == 21: _set_para_text(p, date_text, C_PRIMARY, Pt(18))
+elif ph.idx == 22: _set_para_text(p, dept_text, C_TEXT_GRAY, Pt(12))
+```
+
+**⚠️ 文字颜色始终用深色**:很多模板封面只有上半部有彩色 banner,title 在白色区域。
+
+### `_duplicate_slide()` — 核心修复 (2026-05-25)
+
+两条关键修复,已应用到全代码库:
+
+| 修复 | 问题 | 方案 |
+|------|------|------|
+| 用 `source_slide.slide_layout` 代替 `blank_layout` | 切到错误 Master,丢失背景/Logo | `new_slide = prs.slides.add_slide(source_layout)` |
+| 增加 `keep_shapes` 参数 | 封面/目录/尾页占位符被删除 | `keep_shapes=True` 保留 layout 占位符 |
+
+OOXML 三层继承链:`Theme → Slide Master(背景+Logo) → Slide Layout(占位符) → Slide(内容)`
+
+### python-pptx 装饰图形陷阱 (2026-05-25)
+
+`add_slide(layout)` 只创建占位符,**不复制** layout 中的非占位符装饰图形(渐变矩形、Logo 等)。
+这些图形存在于 layout XML 的 `<p:spTree>` 中,PowerPoint 打开时从 layout 继承渲染,
+但 python-pptx 不保证引用正确 → 装饰图形可能丢失。
+
+**工具函数**: `copy_layout_decorative_shapes(slide, layout)` — 从 layout XML deepcopy
+所有无 `<p:ph>` 的 `<p:sp>` 到 slide XML。

+ 7 - 5
generate-data-report-ppt/references/chart-specs.md

@@ -16,13 +16,15 @@
 
 | 用途 | 色值 | 说明 |
 |------|------|------|
-| 系列1主色 | `#5B9BD5` | 蓝色,主要数据系列 |
+| 系列1主色 | `#1E3A5F` | 深蓝,主要数据系列(对应 theme primary) |
 | 系列2对比 | `#ED7D31` | 橙色,对比/参考系列 |
-| 系列3辅助 | `#A5A5A5` | 灰色,辅助线或第三系列 |
-| 正增长 | `#70AD47` | 绿色,用于涨幅标记 |
-| 负增长 | `#C5504B` | 红色,用于跌幅标记 |
+| 系列3辅助 | `#64748B` | 灰蓝,辅助线或第三系列(对应 theme secondary) |
+| 正增长 | `#10B981` | 绿色,用于涨幅标记(对应 theme accent) |
+| 负增长 | `#EF4444` | 红色,用于跌幅标记(对应 theme accentNeg) |
 | 警告 | `#ED7D31` | 橙色,异常告警 |
 
+> **注意**:上表为默认配色。报告生成时 `chart_factory.py` 接受调用方传入的 `theme_colors` 参数,实际颜色由三级解析(用户主题 > 模板主题 > 默认)决定。多系列图表(如环形图/饼图)使用 `DEFAULT_COLORS` 色板:`#1E3A5F` → `#10B981` → `#ED7D31` → `#64748B` → `#EF4444`。
+
 ## 通用图表样式
 
 1. **标题**:图表本身不设置标题(页面顶部已有文本框标题)
@@ -41,7 +43,7 @@ from pptx.enum.chart import XL_CHART_TYPE
 
 chart_data = ChartData()
 chart_data.categories = ['亚洲', '非洲', '拉美', '中东', '欧洲']
-chart_data.add_series('订单量', (2160, 1840, 1743, 878, 247))
+chart_data.add_series('数值', (2160, 1840, 1743, 878, 247))
 
 chart = slide.shapes.add_chart(
     XL_CHART_TYPE.COLUMN_CLUSTERED,

+ 428 - 171
generate-data-report-ppt/references/professional-data-analyst-playbook.md

@@ -1,31 +1,83 @@
-# Professional Data Analyst Playbook
+# 专业数据分析师手册
 
-Use this reference whenever generating report recommendations, page narratives, chart interpretations, executive summaries, forecast pages, or quality review feedback. The agent must behave like a professional data analyst, not a template filler.
+在生成报告建议、页面叙述、图表解读、执行摘要、预测页面或质量审阅反馈时,请使用本参考。智能体必须像专业数据分析师一样行事,而不是模板填充工具。
 
-## Analyst Role
+## 分析师角色
 
-The agent is responsible for turning data into decision-ready analysis:
+智能体的职责是将数据转化为可供决策的分析:
 
-- Identify business questions behind the report, not only visible columns.
-- Translate metrics into management implications.
-- Compare current performance with targets, prior period, peers, structure, and expected ranges whenever data permits.
-- Explain why a number matters, what changed, what likely caused it, and what action should follow.
-- Make uncertainty explicit. If evidence is insufficient, state the missing evidence and the next data needed.
-- Avoid generic phrases such as "总体表现良好", "需进一步关注", "持续优化", "建议加强管理" unless backed by specific data and action.
+- 识别报告背后的业务问题,而不仅是可见的字段。
+- 将指标转化为管理启示。
+- 只要数据允许,将当前表现与目标、上期、同行、结构和预期范围进行比较。
+- 解释一个数字为何重要、什么发生了变化、可能的原因是什么,以及后续应采取什么行动。
+- 明确不确定性。如果证据不足,说明缺失的证据以及下一步需要的数据。
+- 除非有具体数据和行动支撑,否则避免使用"总体表现良好"、"需进一步关注"、"持续优化"、"建议加强管理"等泛泛之谈。
 
-Every analysis page must answer at least three of these five questions:
+每个分析页面必须至少回答以下五个问题中的三个:
 
-1. What happened?
-2. How large is the change or gap?
-3. Why might it have happened?
-4. What risk or opportunity does it imply?
-5. What should the audience do next?
+1. 发生了什么?
+2. 变化或缺口有多大?
+3. 为什么会发生?
+4. 它暗示了什么风险或机会?
+5. 受众下一步应该做什么?
 
-## Analyst Keywords
+## 分析思维框架
 
-Use these keywords to trigger deeper analytical thinking. Do not merely paste them into slides; use them to structure reasoning.
+分析不是罗列数字,而是用系统化的思维模型从数据中提取洞察。以下五种基础分析方法必须根据页面类型灵活组合使用。
 
-### Metric Diagnosis
+### 对比分析法
+
+对比不是简单比大小,而是构建有意义的参照系:
+
+- **与目标对比**:达成率多少?缺口在哪几个维度?缺口是持续存在还是首次出现?
+- **与历史对比**:环比/同比变化幅度是否突破历史波动区间?是否创近 N 期新高/新低?
+- **与结构对比**:该类别在整体中的占比是否异常?与其他类别的相对位置是否变化?
+- **与统计基准对比**:当前值处于历史分位数的什么位置(如前 10% / 中位数 / 后 10%)?
+
+对比时必须同时给出**绝对差**和**相对差**:"增加 50 台(+12%)"比"大幅增加"更有信息价值。
+
+### 细分分析法
+
+当总体指标异常时,按维度拆解定位问题来源。拆解顺序:
+
+1. **时间维度**:按日/周/旬看节奏分布,判断是单点爆发还是持续趋势。
+2. **结构维度**:按区域/品类/客户等级/渠道看子群体贡献,定位"谁拖累了整体"或"谁拉动了整体"。
+3. **过程维度**:按漏斗阶段/审批环节/交付节点看阻塞位置。
+
+细分分析的核心公式:**总体变化 = Σ(各细分项变化)**。必须量化每个细分项对总体的贡献度,而非仅说"某区域增长较快"。
+
+### 漏斗分析法
+
+漏斗不是简单的阶段柱状图,而是三维诊断:
+
+- **存量维度**:哪个阶段的在途量最大?是否存在老化风险?
+- **流量维度**:各阶段的新增量是否均衡?是否有阶段"只进不出"?
+- **转化维度**:阶段间转化率是多少?哪个转化点最弱?与历史同期相比是恶化还是改善?
+
+漏斗分析必须计算**端到端转化率**和**阶段衰减系数**:如果 A→B 转化率从 60% 降至 45%,要量化这 15 个百分点的下降对最终产出的影响。
+
+### 归因分析法
+
+区分"结构变化"和"因素变化"对总量指标的影响:
+
+- **结构效应**:各组成部分的占比发生变化(如高客单价品类占比提升拉动整体客单价)。
+- **因素效应**:各组成部分自身的指标值发生变化(如每个品类自身的客单价都提升了)。
+
+归因分析必须给出可量化的贡献拆分:"整体转化率提升 3.2 个百分点,其中 A 渠道占比提升贡献 1.8 个百分点,B 渠道自身转化率改善贡献 1.4 个百分点"。
+
+### 相关与驱动分析法
+
+识别指标之间的领先-滞后关系和驱动链条:
+
+- **领先指标**:过程指标(如询盘量、试用申请数)通常领先于结果指标(如签约量、销售额)。
+- **一致性检验**:结果指标增长时,领先指标是否同步增长?如果不同步,预判结果指标的可持续性。
+- **驱动链条**:建立"输入 → 过程 → 输出 → 结果"的指标链,分析瓶颈出现在哪一环。
+
+## 分析师关键词
+
+使用这些关键词来触发更深入的分析思维。不要仅仅将它们粘贴到幻灯片中;用它们来构建推理。
+
+### 指标诊断
 
 - 环比、同比、较上期、较同期、较目标
 - 达成率、缺口、超额、偏离度、贡献率
@@ -34,7 +86,7 @@ Use these keywords to trigger deeper analytical thinking. Do not merely paste th
 - 标准差、变异系数、离散度、集中度、长尾
 - 异常值、离群点、结构突变、拐点、趋势斜率
 
-### Business Interpretation
+### 业务解读
 
 - 增长驱动、拖累因素、核心贡献、边际贡献
 - 结构升级、结构失衡、结构迁移、结构性机会
@@ -43,7 +95,7 @@ Use these keywords to trigger deeper analytical thinking. Do not merely paste th
 - 资源利用、产能约束、履约压力、库存风险
 - 需求强度、交付节奏、回款节奏、供应约束
 
-### Risk And Opportunity
+### 风险与机会
 
 - 短期风险、中期压力、长期隐患
 - 集中度风险、单点依赖、尾部拖累、断层
@@ -51,7 +103,7 @@ Use these keywords to trigger deeper analytical thinking. Do not merely paste th
 - 预警阈值、触发条件、风险敞口、影响范围
 - 保底情景、基准情景、挑战情景、压力测试
 
-### Action Language
+### 行动语言
 
 - 优先级、责任人、时间节点、复盘频率
 - 分层运营、重点跟进、专项排查、闭环机制
@@ -59,9 +111,9 @@ Use these keywords to trigger deeper analytical thinking. Do not merely paste th
 - 建立看板、设置阈值、跟踪转化、校准预测
 - 立即处理、下周复盘、月末验收、滚动更新
 
-## Required Insight Pattern
+## 必备洞察结构
 
-Each insight block should follow this structure:
+每个洞察块应遵循以下结构:
 
 ```text
 结论: 用一句话讲清楚业务判断。
@@ -71,243 +123,448 @@ Each insight block should follow this structure:
 动作: 给出具体下一步,最好包含对象、优先级和时间。
 ```
 
-Short form for PPT:
+PPT 精简版:
 
 ```text
 【判断】...;【证据】...;【原因】...;【影响】...;【动作】...
 ```
 
-Use compact prose on slides, but make the logic complete.
+幻灯片上使用紧凑的文笔,但要保证逻辑完整。
 
-## Page-Level Standards
+## 页面级标准
 
-### KPI Overview
+### KPI 概览
 
-Do not simply list KPI values. Analyze:
+不要简单罗列 KPI 数值。分析:
 
-- Which KPI is the primary result metric?
-- Which metrics are leading indicators and which are lagging indicators?
-- Are result and process indicators moving consistently?
-- Which metric has the largest gap, fastest growth, or highest operational risk?
-- If values are high but process indicators are weak, call out sustainability risk.
+- 哪个 KPI 是主要结果指标?
+- 哪些指标是领先指标,哪些是滞后指标?
+- 结果指标和过程指标是否一致变动?
+- 哪个指标的缺口最大、增长最快或运营风险最高?
+- 如果数值高但过程指标弱,指出可持续性风险。
 
-Minimum output:
+最低输出要求:
 
-- 1 paragraph for overall performance judgment.
-- 1 paragraph for key driver or drag.
-- 1 paragraph for management action or monitoring rule.
+- 1 段整体表现判断。
+- 1 段关键驱动或拖累因素。
+- 1 段管理行动或监控规则。
 
-### Trend Page
+### 趋势页
 
-Analyze trend shape, not just direction:
+分析趋势形态,而不仅是方向:
 
-- Identify acceleration, deceleration, plateau, turning point, volatility, peak, trough.
-- Compare early/middle/late period if exact prior period is unavailable.
-- Explain whether the trend is structural, seasonal, event-driven, or data-quality-driven.
-- Translate trend into forecast implication.
+- 识别加速、减速、平台期、转折点、波动、峰值、谷值。
+- 如果无法获取精确的上期数据,比较早/中/后期。
+- 解释趋势是结构性的、季节性的、事件驱动的还是数据质量驱动的。
+- 将趋势转化为预测启示。
 
-Useful terms:
+常用术语:
 
 - 趋势斜率、拐点、峰谷差、连续增长、连续回落
 - 上旬/中旬/下旬对比、阶段性修复、波动放大
 - 趋势延续性、预测可信度、节奏错配
 
-### Distribution Page
+### 分布页
 
-Analyze structure:
+分析结构:
 
-- Head concentration: Top 1 / Top 3 / Top 5 contribution.
-- Tail distribution: number of low-contribution categories and their combined share.
-- Balance: whether the distribution is healthy, overly concentrated, or fragmented.
-- Operational implication: where to allocate resources.
+- 头部集中度:Top 1 / Top 3 / Top 5 贡献。
+- 尾部分布:低贡献类别的数量及其合计占比。
+- 均衡性:分布是健康、过度集中还是过于分散。
+- 运营启示:资源应如何分配。
 
-Useful terms:
+常用术语:
 
 - 头部集中、长尾分散、结构失衡、结构迁移
 - 贡献梯队、帕累托结构、尾部低效、资源错配
 
-### Ranking Page
+### 排名页
 
-Ranking is not a list. Analyze:
+排名不是列表。分析:
 
-- Gap between rank 1 and rank 2.
-- Gap between top tier and bottom tier.
-- Whether leaders are outliers or part of a stable first tier.
-- What action differs by tier: protect leaders, grow second tier, fix tail.
+- 第 1 名与第 2 名的差距。
+- 头部梯队与尾部梯队的差距。
+- 领先者是异常值还是稳定的第一梯队成员。
+- 不同梯队应采取何种不同行动:保护领先者、培育第二梯队、修复尾部。
 
-Useful terms:
+常用术语:
 
 - 第一梯队、第二梯队、尾部梯队、断层
 - 榜首优势、追赶空间、低位修复、标杆复制
 
-### Funnel Or Stage Page
+### 漏斗或阶段页
 
-Analyze conversion and blockage:
+分析转化与阻塞:
 
-- Largest stock stage.
-- Weakest conversion point.
-- Average cycle time or aging if available.
-- Impact of blockage on revenue, delivery, or customer experience.
-- Priority actions by stage.
+- 最大的存量阶段。
+- 最弱的转化点。
+- 平均周期时长或账龄(如有数据)。
+- 阻塞对收入、交付或客户体验的影响。
+- 按阶段划分的优先行动。
 
-Useful terms:
+常用术语:
 
 - 阶段阻塞、转化断点、漏斗泄漏、推进效率
 - 存量堆积、老化风险、闭环周期、交付压力
 
-### Team Or Owner Page
+### 团队或负责人页
 
-Analyze workload, effectiveness, and risk:
+分析工作量、效率和风险:
 
-- Workload distribution and concentration.
-- Output per person or per team if denominator exists.
-- Identify over-loaded owners and under-utilized owners.
-- Separate high volume from high efficiency.
+- 工作量分布和集中度。
+- 人均产出或团队产出(如有分母数据)。
+- 识别超负荷的负责人和低负荷的负责人。
+- 区分高工作量与高效率。
 
-Useful terms:
+常用术语:
 
 - 人均产出、负载均衡、单点依赖、能力梯队
 - 高负载风险、协同效率、资源重分配
 
-### Forecast Or Plan Page
+### 预测或计划页
 
-Forecast pages must include:
+预测页必须包含:
 
-- Forecast value or target value.
-- Baseline evidence from actual performance.
-- Key assumptions.
-- Gap between current run rate and forecast.
-- Scenario view: conservative / base / stretch if possible.
-- Risk response if forecast is not supported by current data.
+- 预测值或目标值。
+- 基于实际表现的基准证据。
+- 关键假设。
+- 当前运行速率与预测的差距。
+- 情景视角:如可能,提供保守/基准/乐观情景。
+- 如果预测不被当前数据支持,提供风险应对。
 
-Useful terms:
+常用术语:
 
 - 预测区间、目标缺口、运行速率、目标可行性
 - 关键假设、情景分析、压力测试、偏差校准
 
-### Summary Page
+### 总结页
+
+不要重复前面的页面。进行综合:
+
+- 按业务影响排序的前 3 大发现。
+- 主要风险及其触发条件。
+- 主要机会及预期上行空间。
+- 下一步运营节奏:每日/每周/每月应跟踪什么。
+
+## 交叉分析与多维拆解
+
+当单一维度的分析无法解释数据现象时,必须进行多维度交叉分析。
+
+### 二维交叉分析
+
+将两个维度交叉,寻找高价值或高风险的组合:
+
+- **时间 × 结构**:哪个区域在下半月出现了断崖式下跌?哪个品类在旺季反而表现平淡?
+- **结构 × 结构**:高价值客户集中在哪些区域?低效 SKU 集中在哪些渠道?
+- **过程 × 结构**:哪个审批环节在哪个区域阻塞最严重?
+
+交叉分析的输出必须包含具体的组合名称和数据,避免"部分区域部分时段表现不佳"这类模糊描述。
+
+### 象限分析
+
+选取两个关键指标构建四象限,对分类对象进行差异化策略制定:
+
+| 象限 | 指标组合示例 | 策略 |
+|------|-------------|------|
+| 高量高效 | 高订单量 + 高转化率 | 保护、复制、扩大投入 |
+| 高量低效 | 高订单量 + 低转化率 | 诊断流程瓶颈、优化转化 |
+| 低量高效 | 低订单量 + 高转化率 | 加大流量/资源投入、测试放量 |
+| 低量低效 | 低订单量 + 低转化率 | 评估存续价值、考虑淘汰或重组 |
+
+使用象限分析时必须标注划分阈值(如中位数、目标值、历史均值),并给出每个象限的具体对象名称和数量。
+
+### ABC/帕累托分析
+
+按贡献度将对象分为 A/B/C 三类,差异化配置管理资源:
+
+- **A 类(前 20%,贡献约 80%)**:重点监控、资源优先、风险零容忍。
+- **B 类(中间 30%,贡献约 15%)**:潜力培育、针对性提升。
+- **C 类(后 50%,贡献约 5%)**:标准化管理、考虑精简或合并。
+
+ABC 分析必须给出具体的分界阈值和各类别的数量/贡献值,避免仅凭感觉分类。
+
+### 同期群(Cohort)思维
+
+按同一批次或同一时期进入的对象进行分组追踪:
+
+- **时间 cohort**:本月新增客户与上月新增客户的同期转化率对比。
+- **来源 cohort**:不同渠道引入的客户在后续 N 期的留存/转化差异。
+- **行为 cohort**:首次购买不同品类的客户的复购周期差异。
+
+Cohort 分析的核心是**控制初始条件差异**,识别真实的时间效应或来源效应。
+
+## 分析深度检查清单
+
+撰写幻灯片前,检查:
+
+- 页面是否包含至少一个具体数字?
+- 是否包含至少一次比较?
+- 是否解释了原因或合理机制?
+- 是否提及对业务决策的影响?
+- 是否推荐了具体行动?
+
+如果有任何答案为否,请修改分析。
+
+## 比较层次
+
+使用最强的可用比较:
+
+1. 目标或预算。
+2. 上期。
+3. 去年同期。
+4. 细分基准、团队基准、区域基准、品类基准。
+5. 内部结构:头部 vs 尾部、高 vs 低、早期 vs 晚期。
+6. 统计基准:均值、中位数、百分位数、标准差。
+7. 如果以上皆无,明确说明该页面为基线视图,并建议添加下一个比较维度。
+
+## 指标拆解与归因框架
+
+面对总量指标变化时,必须使用系统化的拆解方法量化各因素的贡献。
+
+### 乘法公式拆解
+
+适用于公式为 Y = A × B × C 的指标,如:
+
+- 销售额 = 客户数 × 转化率 × 客单价
+- 履约量 = 在途订单 × 及时交付率
+
+**因素贡献度计算(链式替代法)**:
+
+设基期 Y₀ = A₀ × B₀ × C₀,报告期 Y₁ = A₁ × B₁ × C₁。
+
+- A 因素贡献 = (A₁ - A₀) × B₀ × C₀
+- B 因素贡献 = A₁ × (B₁ - B₀) × C₀
+- C 因素贡献 = A₁ × B₁ × (C₁ - C₀)
+
+分析时必须给出每个因素对总变化量的具体贡献额和贡献占比,而非仅说"多因素影响"。
+
+### 加法公式拆解
+
+适用于公式为 Y = ΣXi 的指标,如:
+
+- 总需求 = 亚太需求 + 欧洲需求 + 美洲需求
+- 总订单 = 线上订单 + 线下订单
+
+**贡献度计算**:
+
+- 各组成部分的**绝对贡献** = Xi₁ - Xi₀
+- 各组成部分的**相对贡献率** = (Xi₁ - Xi₀) / (Y₁ - Y₀) × 100%
+
+分析要点:
+- 识别"拉动型"子项(自身增长且贡献正向)。
+- 识别"拖累型"子项(自身下滑或增速低于整体)。
+- 识别"结构迁移"(子项占比变化对整体增速的影响)。
+
+### 结构-因素双分解
+
+当整体指标受"结构占比"和"因素水平"双重影响时使用:
+
+**整体变化 = 结构效应 + 因素效应**
+
+以整体客单价为例:
+- **结构效应**:各品类销售占比变化带来的影响(即使各品类自身客单价不变)。
+- **因素效应**:各品类自身客单价变化带来的影响(即使销售占比不变)。
+
+公式:
+- 结构效应 = Σ[(Pi₁ - Pi₀) × Ai₀]
+- 因素效应 = Σ[Pi₁ × (Ai₁ - Ai₀)]
+
+其中 Pi 为第 i 个品类的占比,Ai 为第 i 个品类的客单价。
+
+### 贡献度陈述规范
+
+正确的贡献度陈述必须包含三个要素:
+
+1. **变化方向**:该因素是拉动整体上升还是拖累整体下降。
+2. **贡献量级**:该因素对整体变化的具体数值贡献。
+3. **贡献占比**:该因素在总变化中的占比(当总变化不为零时)。
+
+示例:"整体销售额增长 120 万元,其中客户数增加贡献 80 万元(占 67%),客单价提升贡献 50 万元(占 42%),转化率下降抵消 10 万元(占 -8%)。"
+
+## 原因假设库
+
+谨慎使用假设。除非有直接数据支持,否则将其标记为假设。
+
+### 增长
+
+- 需求扩张。
+- 新客户/订单流入。
+- 高绩效区域或产品结构变化。
+- 转化率提升或处理速度加快。
+- 交付产能释放。
+- 活动、季节性或政策效应。
+
+### 下降
+
+- 需求减弱。
+- 数据截止或报告滞后。
+- 阶段阻塞或审批延迟。
+- 客户付款延迟。
+- 供应、物流、生产、库存或人员约束。
+- 上期高基数效应。
+
+### 集中
+
+- 大客户依赖。
+- 区域市场偏斜。
+- 产品结构集中。
+- 资源配置偏向。
+- 销售负责人或渠道依赖。
+
+### 波动
+
+- 样本量小。
+- 一次性大订单/事件。
+- 日历效应。
+- 批量数据录入。
+- 不规律履约计划。
+
+## 根因验证方法
+
+提出假设后,必须通过数据验证而非主观确认。遵循"提出假设 → 寻找证据 → 排除不成立 → 确认最可能"的流程。
+
+### 假设验证流程
+
+1. **提出可检验假设**:将模糊猜测转化为可验证的预测。例如,将"可能是大客户的影响"转化为"如果大客户是主因,那么 Top5 客户贡献度应显著高于历史同期"。
+2. **设计验证数据**:明确需要查看哪些维度的数据来验证或证伪假设。
+3. **执行检验**:计算预测值与实际数据的吻合度。
+4. **排除与确认**:若数据不支持,则排除该假设;若支持,则记录为"数据支持的解释"。
+
+### 交叉验证原则
+
+如果一个假设成立,它在多个维度上都应该表现出一致性:
+
+- **时间一致性**:该因素在相邻时间段内是否持续产生影响?还是仅单点异常?
+- **结构一致性**:该因素在多个子群体中是否都表现出影响?还是仅局限于个别对象?
+- **逻辑一致性**:该因素与前后环节指标的变化方向是否一致?
+
+若某假设仅在一个维度上"说得通"但在其他维度上出现矛盾,则该假设可信度低。
+
+### 反事实思维
+
+评估某因素的真实影响时,思考"如果没有该因素,结果会怎样?":
+
+- 如果剔除某一次性大单,剩余订单的趋势如何?
+- 如果某区域保持上期增速而非本期增速,整体增速会差多少?
+- 如果某政策未出台,指标变化方向是否依然成立?
+
+反事实分析的结果必须明确标注为"估算"或"敏感性测试",避免当作确定事实陈述。
+
+### 排除法与证伪
 
-Do not restate previous pages. Synthesize:
+优先尝试证伪而非证实:
 
-- Top 3 findings by business impact.
-- Main risk and its trigger condition.
-- Main opportunity and expected upside.
-- Next operating cadence: what to track daily/weekly/monthly.
+- 若怀疑"需求减弱",检查领先指标(如新询盘量、官网访问量)是否真的同步下降。
+- 若怀疑"供应链约束",检查交付周期、库存周转、缺货率等是否异常。
+- 若怀疑"数据问题",检查时间戳分布、录入批次、系统切换记录等。
 
-## Analysis Depth Checklist
+当多个假设中仅有一个无法被证伪时,将其标记为"最可能解释",但仍需注明不确定性。
 
-Before writing a slide, check:
+## 数据可信度与统计思维
 
-- Does the page contain at least one concrete number?
-- Does it contain at least one comparison?
-- Does it explain a cause or plausible mechanism?
-- Does it mention impact on business decisions?
-- Does it recommend a specific action?
+分析结论的可信度取决于数据质量和统计基础。必须在分析中主动评估并披露数据限制。
 
-If any answer is no, revise the analysis.
+### 样本量与统计代表性
 
-## Comparison Hierarchy
+- **大样本(N ≥ 30)**:统计规律相对稳定,可直接计算均值、占比、增长率。
+- **中等样本(10 ≤ N < 30)**:结论需谨慎,避免过度解读极端值。
+- **小样本(N < 10)**:个体波动对整体影响巨大,必须标注"样本量较小,结论仅供参考"。
 
-Use the strongest available comparison:
+当进行细分分析时,各子群体的样本量都需要单独评估。一个总体 N=1000 的维度下,某个子群体可能只有 N=5。
 
-1. Target or budget.
-2. Previous period.
-3. Same period last year.
-4. Segment benchmark, team benchmark, region benchmark, category benchmark.
-5. Internal structure: Top vs tail, high vs low, early vs late period.
-6. Statistical baseline: mean, median, percentile, standard deviation.
-7. If none exists, explicitly say the page is a baseline view and propose the next comparison field to add.
+### 异常检测与处理
 
-## Cause Hypothesis Library
+识别异常值时,结合统计方法和业务规则:
 
-Use hypotheses cautiously. Mark them as hypotheses unless directly supported by data.
+- **统计方法**:IQR 法则(超出 1.5×IQR 范围)或 Z-Score(绝对值 > 3)。
+- **业务规则**:超出合理业务范围的值(如转化率为负或大于 100%,交付周期为负)。
+- **时间连续性**:单点突变而前后平稳的数值更可能是异常值。
 
-### Growth
+处理原则:
+- **数据错误**:明确标注并建议修正,分析时可剔除并说明。
+- **业务异常**(如一次性大单):保留在分析中,但单独说明其对整体指标的影响。
+- **结构变化**:不是异常值,而是新的分布状态,需要重新建立基准。
 
-- Demand expansion.
-- New customer/order inflow.
-- High-performing region or product mix shift.
-- Improved conversion or faster processing.
-- Delivery capacity release.
-- Campaign, seasonality, or policy effect.
+### 统计显著性 vs 业务显著性
 
-### Decline
+- **统计显著性**:变化是否超出了随机波动的范围?小基数下 50% 的增长可能统计不显著。
+- **业务显著性**:变化是否对业务产生了实质影响?大基数下 2% 的下降可能意味着巨额损失。
 
-- Demand weakening.
-- Data cut-off or reporting lag.
-- Stage blockage or approval delay.
-- Customer payment delay.
-- Supply, logistics, production, inventory, or staffing constraint.
-- High base effect from prior period.
+分析时必须同时考虑两者:
+- 统计不显著但业务显著 → 标注"波动较大,需持续观察"。
+- 统计显著但业务不显著 → 标注"变化稳健但绝对影响有限"。
+- 两者皆显著 → 核心发现,优先呈现。
 
-### Concentration
+### 数据质量信号
 
-- Key account dependence.
-- Regional market skew.
-- Product mix concentration.
-- Resource allocation bias.
-- Sales owner or channel dependence.
+在分析前快速扫描以下信号,若存在则需在报告中披露:
 
-### Volatility
+- **缺失率**:关键字段缺失率 > 10% 时,分析结论可能偏斜。
+- **重复值**:ID 列重复率异常高时,检查是否存在重复录入。
+- **逻辑不一致**:如"下单日期"晚于"交付日期",或"转化率"的分母小于分子。
+- **时间断档**:数据是否存在未覆盖的日期区间?月末/年末是否容易缺失?
+- **基数突变**:分母突然大幅变化(如客户数从 1000 骤降至 50),导致比率指标失真。
 
-- Small sample size.
-- One-off large order/event.
-- Calendar effect.
-- Batch data entry.
-- Irregular fulfillment schedule.
+数据质量问题必须在报告的"数据说明"或"局限性"部分披露,不能隐瞒。
 
-## Writing Rules
+## 写作规则
 
-Use precise, executive-ready Chinese:
+使用精确、适合高管阅读的中文:
 
-- Prefer: "本月订单量较上期增加 18%,其中 Top3 国家贡献 62% 增量,说明增长主要由头部市场拉动。"
-- Avoid: "本月订单表现较好,后续需持续关注。"
+- 推荐写法:"本月订单量较上期增加 18%,其中 Top3 国家贡献 62% 增量,说明增长主要由头部市场拉动。"
+- 避免写法:"本月订单表现较好,后续需持续关注。"
 
-Use decision verbs:
+使用决策动词:
 
-- "优先处理", "拆解", "复核", "校准", "压降", "放大", "转化", "闭环", "预警", "复盘".
+- "优先处理"、"拆解"、"复核"、"校准"、"压降"、"放大"、"转化"、"闭环"、"预警"、"复盘"。
 
-Avoid empty verbs:
+避免空洞动词:
 
-- "加强", "优化", "提升", "关注", unless followed by object + metric + deadline.
+- "加强"、"优化"、"提升"、"关注",除非后面跟有对象 + 指标 + 截止时间。
 
-## Empty Analysis Anti-Patterns
+## 空洞分析反模式
 
-Reject and rewrite these:
+拒绝并重写以下内容:
 
-- Only describing chart appearance.
-- Only repeating the largest category.
-- Only listing all categories or countries.
-- Saying "数据较为均衡" without concentration metrics.
-- Saying "存在波动" without peak/trough/change range.
-- Saying "建议继续跟进" without owner, priority, metric, or timing.
-- Writing a long paragraph without any number.
+- 仅描述图表外观。
+- 仅重复最大的类别。
+- 仅列出所有类别或国家。
+- 没有集中度指标就说"数据较为均衡"。
+- 没有峰值/谷值/变化范围就说"存在波动"。
+- 没有负责人、优先级、指标或时间就说"建议继续跟进"。
+- 写一大段没有任何数字的文字。
+- 仅说"多因素影响"而不量化各因素的贡献度。
+- 仅说"结构优化"而不说明哪部分结构变化、贡献多少。
+- 归因时未排除其他竞争性假设。
 
-## Slide Density Guidance
+## 幻灯片密度指导
 
-Good analysis does not mean long text. A strong PPT page usually has:
+好的分析不意味着长篇大论。一张优秀的 PPT 页面通常包含:
 
-- 1 clear conclusion title.
-- 1 chart or KPI group.
-- 2-4 insight blocks.
-- 3-6 specific numbers across the page.
-- No raw category list longer than 5 items. Use Top N + "其余" summary.
+- 1 个清晰的结论标题。
+- 1 个图表或 KPI 组合。
+- 2-4 个洞察块。
+- 整个页面包含 3-6 个具体数字。
+- 不超过 5 项的原始类别列表。使用 Top N + "其余" 汇总。
 
-When a category list is too long:
+当类别列表过长时:
 
-- Show Top 5 only.
-- Add "其余 X 项合计 Y,占比 Z%".
-- Move full detail to appendix or table.
-- Never put a long category list inside a KPI value box.
+- 仅展示前 5 项。
+- 添加"其余 X 项合计 Y,占比 Z%"。
+- 将完整明细移至附录或表格。
+- 切勿将长类别列表放入 KPI 数值框内。
 
-## Quality Self-Review For Analysis
+## 分析质量自检
 
-Before final PPT output, inspect pages from page 4 onward especially carefully:
+在最终 PPT 输出前,仔细检查第 4 页及之后的页面:
 
-- Does each page contain a business judgment beyond summary?
-- Does each chart have a written interpretation?
-- Are risks and actions specific?
-- Are long category labels abbreviated or moved into a chart/table?
-- Are all claims traceable to visible numbers or source data?
+- 每个页面是否包含超越摘要的业务判断?
+- 每个图表是否有文字解读?
+- 风险和行动是否具体?
+- 长类别标签是否已缩写或移入图表/表格?
+- 所有论断是否可追溯至可见数字或源数据?
+- 每个归因结论是否排除了主要竞争性解释?
+- 涉及细分的结论是否检查了各子群体的样本量?
+- 是否存在数据质量问题未披露?
 
-If a page is mostly generic text, rebuild the page narrative before output.
+如果某个页面大多是泛泛而谈的文字,请在输出前重建页面叙述。

+ 38 - 1
generate-data-report-ppt/references/quality-standards.md

@@ -233,7 +233,44 @@
 
 ---
 
-## 九、质检流程集成
+## 九、封面页质量专项规则
+
+封面页是 PPT 的第一印象,且 python-pptx 在处理自定义模板时有多个已知陷阱。
+以下规则由 `quality_inspector._check_cover_quality()` 在生成后自动执行。
+
+### V006:封面文字颜色与背景冲突(critical,自动修复)
+
+**检测逻辑**:
+1. 扫描封面页所有 `is_placeholder=True` 的 shape
+2. 检测页面顶部的彩色 banner(y≈0 的填充矩形)底部位置
+3. 如果 placeholder 在 banner 下方(白色区域)且文字颜色 RGB 三通道均 ≥ 0xCC(近白色)→ 报 V006
+
+**自动修复**:将文字颜色改为 `theme_colors['primary']`(默认 #1E3A5F 深蓝)
+
+**典型场景**:Wuling 模板,蓝色渐变矩形覆盖 0→3441700 EMU,title placeholder 在 y=3818251。
+开发者设了 `C_WHITE` → 白字在白色背景上完全隐形。
+
+### C012:封面占位符仍为模板默认文字(critical,不可自动修复)
+
+**检测逻辑**:
+1. 检查封面 placeholder 文本是否包含模板默认文字:
+   - `"单击此处编辑母版标题样式"`
+   - `"单击此处添加标题"`
+   - `"单击此处编辑母版文本样式"`
+   - `"单击此处添加文本"`
+   - `"单击此处添加副标题"`
+
+**不可自动修复**:因为无法从上下文中获取正确的报告标题/日期/部门。
+需触发 rebuild 或返回错误让调用方处理。
+
+**典型场景**:模板占位符不含 `{report_title}` 等文本标记,
+`_replace_all_placeholders()` 找不到替换目标,占位符保持 PowerPoint 默认文字。
+`_build_cover_page` 的 Pass 2(idx 兜底)应在 build 阶段解决此问题;
+C012 是最后的防线,确保不会输出含模板默认文字的 PPT。
+
+---
+
+## 十、质检流程集成
 
 ### Agent 在生成 PPT 前必须读取本文档
 

+ 28 - 80
generate-data-report-ppt/references/report-structures.md

@@ -1,67 +1,41 @@
 # 报告页面结构规范
 
-三种报告类型的标准页面结构、分析维度和占位符规则
+> **注意**:日报/周报/月报的旧结构(含"在跟订单""订单状态 A-F""目的国家 TOP8""负责人分布""订单漏斗"等页面)已废弃,`build_daily_report()` / `build_weekly_report()` / `build_monthly_report()` 函数已删除。所有报告生成统一通过 `quality_assured_build()` / `build_report()` 接口,配置驱动
 
----
-
-## 日报(Daily Report)
-
-**分析维度**:与昨日对比、与上周同日对比
-
-| 页码 | 页面标题 | 内容元素 | 图表类型 |
-|------|---------|---------|---------|
-| 1 | 封面 | 标题、日期、部门、右侧KPI卡片 | 无 |
-| 2 | 今日核心指标概览 | 6个KPI卡片:在跟订单/订单总量/今日更新/下月预测/支持需求/已发运 | 无 |
-| 3 | 近N天订单趋势 | 柱状图(近10天订单量走势)+ 右侧趋势洞察文本 | COLUMN_CLUSTERED |
-| 4 | 订单状态分布 | 环形图(A-F阶段占比)+ 状态变化对比表格 | DOUGHNUT + TABLE |
-| 5 | 负责人订单分布 | 横向条形图(负责人 vs 订单数) | BAR_CLUSTERED |
-| 6 | 目的国家TOP8 | 横向条形图(国家 vs 订单量) | BAR_CLUSTERED |
-| 7 | 异常告警 | 三级告警卡片(严重/警告/关注)+ 支持需求分类统计表格 | TABLE |
-| 8 | 今日要点 | 3栏行动建议(重点推进/跨部门协调/关注订单) | 无 |
-
-**日报封面右侧KPI**:在跟订单笔数 / 订单总数量 / 今日已更新 / 支持需求数(4选3或4个)
-
----
+## 通用报告页面结构
 
-## 周报(Weekly Report)
-
-**分析维度**:周环比(WoW)、周同比(YoY)、7日移动平均
-
-| 页码 | 页面标题 | 内容元素 | 图表类型 |
-|------|---------|---------|---------|
-| 1 | 封面 | 标题、周期(如"4月第2周")、部门、右侧3个核心数字 | 无 |
-| 2 | 周汇总 | 6个指标卡片 + 周环比 + 日均值 | 无 |
-| 3 | 7日趋势图 | 折线图(订单量7日走势)+ 右侧本周关键数据面板 | LINE_MARKERS |
-| 4 | 环比分析 | 条形图(各环节环比变化A-F)+ 右侧文字解读 | BAR_CLUSTERED |
-| 5 | 区域分布 | 柱状图(大洲维度)+ 右侧区域占比文字 | COLUMN_CLUSTERED |
-| 6 | TOP国家排行 | 横向条形图(国家TOP15)+ 右侧TOP6亮点文字 | BAR_CLUSTERED |
-| 7 | 团队追踪 | 柱状图(负责人订单数)+ 右侧增长TOP3文字 | COLUMN_CLUSTERED |
-| 8 | 问题与建议 | 3个问题卡片(严重/中度/中度),含问题描述+建议措施+影响评估 | 无 |
-| 9 | 下周计划 | G1-G4目标卡片 + 本周核心总结 | 无 |
+通用构建器支持动态页面结构,通过 `ReportConfig.pages` 配置,无需硬编码。
 
-**周报导航标签**(由脚本动态绘制):周汇总 / 趋势图 / 环比分析 / 区域排行 / 问题建议 / 下周计划
+### 支持的页面类型
 
----
+| page_type | 用途 | 布局模板 |
+|-----------|------|---------|
+| `cover` | 封面页 | 固定封面布局 |
+| `toc` | 目录页 | 章节目录网格 |
+| `kpi_overview` | 核心指标概览 | KPI 卡片网格(3×2 / 自定义行列) |
+| `trend` | 趋势分析 | 左侧趋势图 + 右侧洞察文本 |
+| `distribution` | 分布分析 | 左侧图表 + 右侧洞察文本 |
+| `ranking` | 排行分析 | 左侧条形图 + 右侧排行说明 |
+| `summary` | 总结与建议 | 全宽洞察文本块 |
+| `forecast` | 预测与计划 | 左侧目标柱状图 + 右侧达成路径文本 |
+| `end` | 结束页 | 固定尾页布局 |
 
-## 月报(Monthly Report)
+### 分析维度
 
-**分析维度**:环比(MoM)、同比(YoY)、日均值、结构占比
+| 周期类型 | 对比维度 |
+|---------|---------|
+| DAILY | 与昨日对比、与上周同日对比 |
+| WEEKLY | 周环比(WoW)、7日移动平均 |
+| MONTHLY | 环比(MoM)、同比(YoY)、日均值 |
+| CUSTOM | 用户自定义时间范围对比 |
 
-| 页码 | 页面标题 | 内容元素 | 图表类型 |
-|------|---------|---------|---------|
-| 1 | 封面 | 标题、月份、右侧4个核心数字(合同/车辆/国家/团队) | 无 |
-| 2 | 目录 | 4大章节索引 | 无 |
-| 3 | 月度总览 | 6个KPI卡片 + 关键发现 | 无 |
-| 4 | 订单状态漏斗 | 条形图(A-F各阶段订单数/车辆数)+ 关键发现 | BAR_CLUSTERED |
-| 5 | 区域分布 | 环形图(大洲占比)+ 表格 + 区域洞察 | DOUGHNUT + TABLE |
-| 6 | TOP10目的国 | 条形图 + 表格 + 国家洞察 | BAR_CLUSTERED + TABLE |
-| 7 | 30日追踪趋势 | 折线图(30天订单量)+ 底部4栏(上旬/中旬/下旬日均+峰值日期) | LINE_MARKERS |
-| 8 | 团队绩效 | 条形图 + 表格 + 团队洞察 | BAR_CLUSTERED + TABLE |
-| 9 | 支持需求分析 | 条形图(需求类型分布)+ 右侧优化建议 | BAR_CLUSTERED |
-| 10 | 下月规划 | 左侧工作目标 + 右侧风险提示与应对 | 无 |
-| 11 | 尾页 | 感谢页 + 底部4个核心数字 | 无 |
+### 页面确认项
 
-**月报导航标签**(由脚本动态绘制):月度总览 / 订单状态 / 区域趋势 / 团队展望
+用户需确认每页的:
+1. 页面标题(如"月度销售额趋势")
+2. 结论标题(用于导航标签和洞察总结)
+3. 图表类型(BAR / LINE / PIE / DOUGHNUT / TABLE / AUTO)
+4. 布局模板(chart_left / two_column / full_width / kpi_grid)
 
 ---
 
@@ -82,29 +56,3 @@
 | `{page_num}` | 内容页底部 | 页码 |
 | `{kpiN_label}` / `{kpiN_value}` | 封面/尾页KPI卡片 | 第N个指标的标签和数值 |
 | `{chapterN_title}` / `{chapterN_desc}` | 目录页 | 第N章标题和描述 |
-
-
-## 通用报告页面结构(新增)
-
-通用构建器支持动态页面结构,通过 `ReportConfig.pages` 配置,无需硬编码。
-
-### 支持的页面类型
-
-| page_type | 用途 | 布局模板 |
-|-----------|------|---------|
-| `cover` | 封面页 | 固定封面布局 |
-| `toc` | 目录页 | 章节目录网格 |
-| `kpi_overview` | 核心指标概览 | KPI 卡片网格(3×2 / 自定义行列) |
-| `trend` | 趋势分析 | 左侧趋势图 + 右侧洞察文本 |
-| `distribution` | 分布分析 | 左侧图表 + 右侧洞察文本 |
-| `ranking` | 排行分析 | 左侧条形图 + 右侧排行说明 |
-| `summary` | 总结与建议 | 全宽洞察文本块 |
-| `end` | 结束页 | 固定尾页布局 |
-
-### 页面确认项
-
-用户需确认每页的:
-1. 页面标题(如"月度销售额趋势")
-2. 结论标题(用于导航标签和洞察总结)
-3. 图表类型(BAR / LINE / PIE / DOUGHNUT / TABLE / AUTO)
-4. 布局模板(chart_left / two_column / full_width / kpi_grid)

+ 12 - 10
generate-data-report-ppt/references/visual-style-guide.md

@@ -10,15 +10,17 @@
 
 | 角色 | 色值 | 用途 |
 |------|------|------|
-| 主色 | `#2E5B8B` | 顶部蓝线、分隔线、标题强调、页眉标题 |
-| 辅色 | `#5B9BD5` | 图表主色、CONTENTS标签、数字高亮 |
+| 主色 | `#1E3A5F` | 顶部蓝线、分隔线、标题强调、页眉标题(对应 theme primary) |
+| 辅色 | `#10B981` | 图表辅色、增长标记(对应 theme accent) |
 | 深色背景 | `#1F3A5C` | 封面左侧块、深色装饰 |
 | 白色 | `#FFFFFF` | 页眉背景、内容区背景、封面标题 |
 | 浅灰背景 | `#F2F2F2` | 目录页/尾页背景、底部来源条 |
-| 卡片浅蓝 | `#E7F0F7` | KPI卡片背景 |
-| 深灰文字 | `#333333` | 正文、主标题 |
-| 中灰文字 | `#666666` | 副标题、次要信息 |
-| 浅灰线条 | `#D9D9D9` | 分隔线、网格线 |
+| 卡片浅蓝 | `#E7F0F7` | KPI卡片背景(对应 theme card_bg) |
+| 深灰文字 | `#333333` | 正文、主标题(对应 theme text) |
+| 中灰文字 | `#666666` | 副标题、次要信息(对应 theme text_gray) |
+| 浅灰线条 | `#D9D9D9` | 分隔线、网格线(对应 theme grid) |
+
+> **注意**:上表为默认配色(对应 `business_classic` 主题)。实际颜色由三级解析(用户主题 → 模板主题 → 默认主题)决定,参见 `theme_manager.py`。
 
 ## 字体规范
 
@@ -27,13 +29,13 @@
 | 封面大标题 | 微软雅黑 | 44pt | Bold | 白色 |
 | 封面副标题 | 微软雅黑 | 32pt | Regular | 白色 |
 | 页面主标题 | 微软雅黑 | 24pt | Bold | `#333333` |
-| 页眉报告名 | 微软雅黑 | 20pt | Bold | `#2E5B8B` |
+| 页眉报告名 | 微软雅黑 | 20pt | Bold | 主题 primary 色 |
 | 页眉日期 | 微软雅黑 | 16pt | Regular | `#333333` |
 | 正文/洞察 | 微软雅黑 | 14-18pt | Regular | `#333333` |
-| KPI数值 | Arial | 28-36pt | Bold | `#2E5B8B` |
+| KPI数值 | Arial | 28-36pt | Bold | 主题 primary 色 |
 | KPI标签 | 微软雅黑 | 12-14pt | Regular | `#666666` |
 | 底部来源 | 微软雅黑 | 10pt | Regular | `#888888` |
-| 英文标签 | Arial | 14pt | Regular | `#5B9BD5` |
+| 英文标签 | Arial | 14pt | Regular | 主题 accent 色 |
 
 ## 布局间距
 
@@ -52,7 +54,7 @@
 
 - **KPI卡片**:圆角矩形(ROUNDED_RECTANGLE),填充 `#E7F0F7`,无边框
 - **告警卡片**:矩形,左侧带 50800 EMU 宽度的色条(严重=红色/警告=橙色/关注=蓝色)
-- **分隔线**:高度 0-50800 EMU 的矩形,填充 `#D9D9D9` 或 `#2E5B8B`
+- **分隔线**:高度 0-50800 EMU 的矩形,填充 `#D9D9D9` 或主题 primary 色
 
 
 ## 多主题配色方案(新增)

+ 11 - 6
generate-data-report-ppt/scripts/chart_factory.py

@@ -34,8 +34,10 @@ DEFAULT_COLORS = [
 ]
 
 
-def _apply_common_style(chart, show_legend=False, category_axis_title=None, value_axis_title=None):
+def _apply_common_style(chart, show_legend=False, category_axis_title=None, value_axis_title=None,
+                        theme_colors=None):
     """Apply common styling to a chart. Safe for all chart types."""
+    tc = theme_colors or {}
     chart.has_title = False
     
     # Legend handling
@@ -86,7 +88,7 @@ def _apply_common_style(chart, show_legend=False, category_axis_title=None, valu
                 pass
             try:
                 if val_axis.has_major_gridlines:
-                    val_axis.major_gridlines.format.line.color.rgb = C_GRID
+                    val_axis.major_gridlines.format.line.color.rgb = tc.get('grid', C_GRID)
                     val_axis.major_gridlines.format.line.width = Pt(0.75)
             except Exception:
                 pass
@@ -116,13 +118,16 @@ def _apply_common_style(chart, show_legend=False, category_axis_title=None, valu
 
 
 def _apply_data_labels(series, show_value=True, show_percent=False, font_size=Pt(10),
-                       position=XL_DATA_LABEL_POSITION.OUTSIDE_END, number_format=None):
+                       position=XL_DATA_LABEL_POSITION.OUTSIDE_END, number_format=None,
+                       theme_colors=None):
     """Add data labels to a series with safe formatting."""
+    tc = theme_colors or {}
+    text_color = tc.get('text', C_TEXT)
     series.has_data_labels = True
     for point in series.points:
         dl = point.data_label
         dl.font.size = font_size
-        dl.font.color.rgb = C_TEXT
+        dl.font.color.rgb = text_color
         dl.font.name = '微软雅黑'
         if show_percent and hasattr(dl, 'show_percent'):
             try:
@@ -315,7 +320,7 @@ def add_pie_chart(slide, categories, values, left, top, width, height,
             pct = val / total * 100 if total else 0
             # Compact label: fit inside slice; show value+percent, omit long category name
             if pct >= 15:
-                dl.text_frame.text = f'{val}\n({pct:.1f}%)'
+                dl.text_frame.text = f'{val}\n({pct:.1f}%)'
             else:
                 dl.text_frame.text = f'{pct:.1f}%'
             dl.show_value = False
@@ -381,7 +386,7 @@ def add_doughnut_chart(slide, categories, values, left, top, width, height,
             pct = val / total * 100 if total else 0
             # Compact label: fit inside slice; show value+percent, omit long category name
             if pct >= 15:
-                dl.text_frame.text = f'{val}\n({pct:.1f}%)'
+                dl.text_frame.text = f'{val}\n({pct:.1f}%)'
             else:
                 dl.text_frame.text = f'{pct:.1f}%'
             dl.show_value = False

+ 12 - 231
generate-data-report-ppt/scripts/data_loader.py

@@ -1,236 +1,14 @@
 """
-Excel data loader for daily/weekly/monthly report generation.
-Contains both legacy order-specific loaders and enhanced generic loaders.
+Universal Excel/CSV data loader for the data report generator.
+Supports auto-detection of file format, encoding, header rows, and data cleaning.
 """
 import pandas as pd
-from datetime import datetime, timedelta
 import re
-import warnings
 import os
-import io
 import csv
 
 # =====================================================================
-# LEGACY SECTION — Order-specific loaders (kept for backward compat)
-# =====================================================================
-
-FIELD_MAP = {
-    '序号': 'seq',
-    '目的国家': 'country',
-    '合同号': 'contract_no',
-    '用户名称/公司': 'customer',
-    '意向车型及数量': 'product_info',
-    '订单总数量': 'order_qty',
-    '负责人': 'owner',
-    '当前状态': 'status',
-    '拟定合同时间': 'contract_date',
-    '跟单天数': 'tracking_days',
-    '定金支付时间': 'deposit_date',
-    '订金认领时间': 'deposit_claim_date',
-    '订单生成时间': 'order_gen_date',
-    '价格评审时间': 'price_review_date',
-    '合同评审时间': 'contract_review_date',
-    '合同提交盖章申请时间': 'seal_apply_date',
-    '合同盖章时间': 'seal_date',
-    '车辆下线入库状态': 'inventory_status',
-    '尾款支付时间': 'final_pay_date',
-    '尾款认领时间': 'final_claim_date',
-    '智慧关务信息维护': 'customs_date',
-    '许可证办理时间': 'license_date',
-    '车辆发运时间': 'ship_date',
-    '预计开票时间': 'invoice_date',
-    '今日进度更新': 'progress_update',
-    '是否更新': 'is_updated',
-    '支持需求': 'support_request',
-    '4月交付': 'deliver_apr',
-    '5月预测': 'forecast_may',
-}
-
-STATUS_ORDER = ['A', 'B', 'C', 'D', 'E', 'F']
-STATUS_LABELS = {
-    'A': '合同拟定中',
-    'B': '已锁定合同待付订金',
-    'C': '已付订金待生产',
-    'D': '已生产待付尾款',
-    'E': '已付尾款待发运',
-    'F': '已发运',
-}
-
-
-def _normalize_status(val):
-    """Extract status code A-F from status string."""
-    if pd.isna(val):
-        return None
-    s = str(val).strip()
-    m = re.match(r'^([A-F])', s)
-    if m:
-        return m.group(1)
-    return None
-
-
-def _parse_date(val):
-    """Parse various date formats."""
-    if pd.isna(val):
-        return None
-    if isinstance(val, datetime):
-        return val
-    for fmt in ('%Y-%m-%d', '%Y/%m/%d', '%Y年%m月%d日'):
-        try:
-            return datetime.strptime(str(val).strip(), fmt)
-        except ValueError:
-            continue
-    return None
-
-
-def _sheet_name_for_date(date: datetime) -> str:
-    """Convert datetime to expected sheet name."""
-    return date.strftime('%Y年%m月%d日')
-
-
-def load_workbook_metadata(filepath: str) -> dict:
-    """Return workbook metadata: sheet names, date range."""
-    xl = pd.ExcelFile(filepath)
-    sheets = xl.sheet_names
-    dates = []
-    for s in sheets:
-        try:
-            d = datetime.strptime(s, '%Y年%m月%d日')
-            dates.append(d)
-        except ValueError:
-            continue
-    dates.sort()
-    return {
-        'sheets': sheets,
-        'date_range': (dates[0], dates[-1]) if dates else (None, None),
-        'total_days': len(dates),
-    }
-
-
-def load_daily(filepath: str, date: datetime) -> pd.DataFrame:
-    """Load single-day order data."""
-    sheet = _sheet_name_for_date(date)
-    df = pd.read_excel(filepath, sheet_name=sheet)
-    return _clean_dataframe(df)
-
-
-def load_date_range(filepath: str, start: datetime, end: datetime) -> pd.DataFrame:
-    """Load and concatenate data across a date range [start, end]."""
-    xl = pd.ExcelFile(filepath)
-    frames = []
-    current = start
-    while current <= end:
-        sheet = _sheet_name_for_date(current)
-        if sheet in xl.sheet_names:
-            df = pd.read_excel(filepath, sheet_name=sheet)
-            df['_data_date'] = current
-            frames.append(df)
-        current += timedelta(days=1)
-    if not frames:
-        raise ValueError(f"No data found between {start.date()} and {end.date()}")
-    combined = pd.concat(frames, ignore_index=True)
-    return _clean_dataframe(combined)
-
-
-def load_weekly(filepath: str, year: int, week_num: int, week_start_day=0) -> tuple:
-    """
-    Load data for a specific week.
-    Returns (current_week_df, prev_week_df).
-    week_start_day: 0=Monday, 6=Sunday
-    """
-    meta = load_workbook_metadata(filepath)
-    first_date, last_date = meta['date_range']
-    if first_date is None:
-        raise ValueError("No valid date sheets found")
-
-    jan4 = datetime(year, 1, 4)
-    jan4_monday = jan4 - timedelta(days=jan4.weekday())
-    target_monday = jan4_monday + timedelta(weeks=week_num - 1)
-    target_sunday = target_monday + timedelta(days=6)
-
-    start = max(target_monday, first_date)
-    end = min(target_sunday, last_date)
-
-    current = load_date_range(filepath, start, end)
-
-    prev_start = start - timedelta(days=7)
-    prev_end = end - timedelta(days=7)
-    if prev_start >= first_date:
-        previous = load_date_range(filepath, prev_start, prev_end)
-    else:
-        previous = pd.DataFrame(columns=current.columns)
-
-    return current, previous
-
-
-def load_monthly(filepath: str, year: int, month: int) -> tuple:
-    """
-    Load data for a specific month.
-    Returns (current_month_df, prev_month_df, yoy_month_df).
-    """
-    start = datetime(year, month, 1)
-    if month == 12:
-        end = datetime(year + 1, 1, 1) - timedelta(days=1)
-    else:
-        end = datetime(year, month + 1, 1) - timedelta(days=1)
-
-    current = load_date_range(filepath, start, end)
-
-    if month == 1:
-        prev_start = datetime(year - 1, 12, 1)
-        prev_end = datetime(year, 1, 1) - timedelta(days=1)
-    else:
-        prev_start = datetime(year, month - 1, 1)
-        prev_end = datetime(year, month, 1) - timedelta(days=1)
-
-    try:
-        previous = load_date_range(filepath, prev_start, prev_end)
-    except ValueError:
-        previous = pd.DataFrame(columns=current.columns)
-
-    yoy_start = datetime(year - 1, month, 1)
-    if month == 12:
-        yoy_end = datetime(year, 1, 1) - timedelta(days=1)
-    else:
-        yoy_end = datetime(year - 1, month + 1, 1) - timedelta(days=1)
-
-    try:
-        yoy = load_date_range(filepath, yoy_start, yoy_end)
-    except ValueError:
-        yoy = pd.DataFrame(columns=current.columns)
-
-    return current, previous, yoy
-
-
-def _clean_dataframe(df: pd.DataFrame) -> pd.DataFrame:
-    """Rename columns, parse dates, clean statuses (legacy)."""
-    rename_map = {k: v for k, v in FIELD_MAP.items() if k in df.columns}
-    df = df.rename(columns=rename_map)
-
-    if 'status' in df.columns:
-        df['status_code'] = df['status'].apply(_normalize_status)
-
-    if 'order_qty' in df.columns:
-        df['order_qty'] = pd.to_numeric(df['order_qty'], errors='coerce')
-
-    date_fields = ['contract_date', 'deposit_date', 'order_gen_date',
-                   'price_review_date', 'contract_review_date', 'seal_apply_date',
-                   'seal_date', 'final_pay_date', 'customs_date', 'license_date',
-                   'ship_date', 'invoice_date']
-    for field in date_fields:
-        if field in df.columns:
-            df[field] = df[field].apply(_parse_date)
-
-    if 'tracking_days' in df.columns:
-        df['tracking_days'] = pd.to_numeric(df['tracking_days'], errors='coerce')
-
-    if 'is_updated' in df.columns:
-        df['is_updated_flag'] = df['is_updated'].astype(str).str.strip() == '是'
-
-    return df
-
-
-# =====================================================================
-# GENERIC LOADING SECTION — Universal loaders for any Excel data
+# GENERIC LOADING — Universal loaders for any Excel/CSV data
 # =====================================================================
 
 # Summary row keywords (Chinese and English) to auto-detect and skip
@@ -447,6 +225,15 @@ def _clean_generic_dataframe(df: pd.DataFrame, skip_summary_rows=True) -> pd.Dat
 
     for col in df.columns:
         if df[col].dtype == 'object':
+            # Try numeric first to avoid small integers being mis-parsed as dates
+            try:
+                numeric = pd.to_numeric(df[col], errors='coerce')
+                if numeric.notna().sum() > len(df) * 0.7:
+                    df[col] = numeric
+                    continue
+            except Exception:
+                pass
+            # Then try datetime
             try:
                 try:
                     parsed = pd.to_datetime(df[col], errors='coerce', format='mixed')
@@ -457,12 +244,6 @@ def _clean_generic_dataframe(df: pd.DataFrame, skip_summary_rows=True) -> pd.Dat
                     continue
             except Exception:
                 pass
-            try:
-                numeric = pd.to_numeric(df[col], errors='coerce')
-                if numeric.notna().sum() > len(df) * 0.7:
-                    df[col] = numeric
-            except Exception:
-                pass
 
     return df
 

+ 0 - 1679
generate-data-report-ppt/scripts/deep_insights.py

@@ -1,1679 +0,0 @@
-"""
-Deep-analysis insight generators for weekly and monthly reports.
-Each function returns list[dict] with {'title': str, 'content': str}.
-"""
-
-
-# =============================================================================
-# WEEKLY INSIGHTS
-# =============================================================================
-
-def weekly_insights(page_type: str, metrics: dict, context: dict) -> list[dict]:
-    """Dispatch to specific weekly page insight functions."""
-    dispatch = {
-        'weekly_summary': _insight_weekly_summary,
-        'weekly_trend': _insight_weekly_trend,
-        'weekly_wow': _insight_weekly_wow,
-        'weekly_region': _insight_weekly_region,
-        'weekly_country': _insight_weekly_country,
-        'weekly_team': _insight_weekly_team,
-        'weekly_issue': _insight_weekly_issue,
-        'weekly_plan': _insight_weekly_plan,
-    }
-    fn = dispatch.get(page_type)
-    return fn(metrics, context) if fn else []
-
-
-def _insight_weekly_summary(metrics: dict, context: dict) -> list[dict]:
-    """Page 2: 周内节奏分析、月度进度、关键驱动因素"""
-    items = []
-    daily_trend = metrics.get('daily_trend', {})
-    total_qty = metrics.get('total_qty', 0)
-    tracking_orders = metrics.get('tracking_orders', 0)
-    prev_tracking_orders = metrics.get('prev_tracking_orders', 0)
-    top_countries = metrics.get('top_countries', {})
-    region_dist = metrics.get('region_dist', {})
-
-    # ① 周内节奏分析
-    if daily_trend:
-        dates = list(daily_trend.keys())
-        vals = list(daily_trend.values())
-        peak_idx = max(range(len(vals)), key=lambda i: vals[i])
-        low_idx = min(range(len(vals)), key=lambda i: vals[i])
-        peak_date, peak_val = dates[peak_idx], vals[peak_idx]
-        low_date, low_val = dates[low_idx], vals[low_idx]
-        prev_avg = metrics.get('prev_avg_daily_orders', 0)
-        above_avg = sum(1 for v in vals if prev_avg and v > prev_avg)
-        rhythm = (
-            f'本周订单呈"{peak_date}冲高、{low_date}回落"特征,峰值{peak_val}单、低谷{low_val}单,'
-            f'波动幅度{_safe_div(peak_val, low_val) if low_val else 0:.1f}倍。'
-            f'{"高于" if above_avg >= len(vals)//2 else "低于"}上周均值天数占比{above_avg}/{len(vals)},'
-            f'整体节奏{"前移" if peak_idx <= len(vals)//2 else "后移"}。'
-            f'建议分析低谷日是否受节假日或客户账期影响,优化排产与沟通节奏。'
-        )
-    else:
-        rhythm = (
-            f'本周累计订单{tracking_orders}单、{total_qty}台,'
-            f'日均{metrics.get("avg_daily_orders", 0)}单。'
-            f'由于缺乏分日数据,暂无法识别周内节奏特征。'
-            f'建议完善日报 granularity,以便识别周几效应并优化资源投放节奏。'
-        )
-    items.append({'title': '📅 周内节奏分析', 'content': rhythm})
-
-    # ② 与上周对比偏移
-    wow_pct = _pct_change(tracking_orders, prev_tracking_orders)
-    if prev_tracking_orders:
-        shift = (
-            f'本周{tracking_orders}单较上周{prev_tracking_orders}单{_fmt_chg_dir(wow_pct)}{_fmt_pct(wow_pct)},'
-            f'{"呈加速态势" if (wow_pct or 0) > 10 else "增长放缓" if (wow_pct or 0) > 0 else "出现回落"}。'
-            f'若趋势持续,下月累计将{"超额" if (wow_pct or 0) > 0 else "缺口"}约{abs((wow_pct or 0)):.0f}%月度目标。'
-            f'建议结合pipeline深度评估:增长来自存量转化还是新增签约,两种来源的可持续性差异显著。'
-        )
-    else:
-        shift = (
-            f'本周订单量{tracking_orders}单,缺乏上周同期数据作为基准。'
-            f'建议尽快建立周环比追踪机制,识别趋势方向。当前可依据日均{metrics.get("avg_daily_orders", 0)}单推算月度节奏,'
-            f'若保持稳定,整月预计约{metrics.get("avg_daily_orders", 0) * 30:.0f}单。'
-        )
-    items.append({'title': '📈 周环比趋势偏移', 'content': shift})
-
-    # ③ 月度进度推演
-    monthly_target = context.get('monthly_target', 0)
-    if monthly_target and total_qty:
-        progress_pct = round(total_qty / monthly_target * 100, 1)
-        week_of_month = context.get('week_of_month', 1)
-        expected = week_of_month / 4 * 100
-        gap = progress_pct - expected
-        progress = (
-            f'本周完成{total_qty}台,占月度目标{monthly_target}台的{progress_pct}%,'
-            f'按"四周均衡"预期应达{expected:.1f}%,{"领先" if gap >= 0 else "落后"}{abs(gap):.1f}个百分点。'
-            f'{"若保持当前 pace,月度有望超额完成。" if gap >= 0 else "剩余周需加速追赶,建议启动冲刺机制。"}'
-            f'重点关注最后一周产能与舱位是否匹配冲刺需求。'
-        )
-    else:
-        progress = (
-            f'本周完成{total_qty}台,因未设定月度目标或缺乏台数数据,暂无法计算进度占比。'
-            f'建议业务部门明确月度销量目标,以便周报自动生成目标达成率并触发预警。'
-            f'当前可先以本周为基准,要求后续每周环比增长不低于5%作为软约束。'
-        )
-    items.append({'title': '🎯 月度进度推演', 'content': progress})
-
-    # ④ 关键驱动因素(国家维度)
-    if top_countries:
-        top1_country = list(top_countries.keys())[0]
-        first_val = list(top_countries.values())[0]
-        top1_qty = first_val if isinstance(first_val, int) else first_val.get('qty', 0)
-        top3_qty = 0
-        for v in list(top_countries.values())[:3]:
-            top3_qty += v if isinstance(v, int) else v.get('qty', 0)
-        concentration = round(top3_qty / total_qty * 100, 1) if total_qty else 0
-        driver = (
-            f'本周Top 3国家贡献{top3_qty}台(占比{concentration}%),其中{top1_country}以{top1_qty}台领跑。'
-            f'{"大客户驱动特征明显,单国依赖度较高" if concentration > 40 else "国家分布相对均衡,抗波动能力较强"}。'
-            f'若Top 1国家订单来自单一客户,建议评估该客户下月复购概率;'
-            f'若来自多客户分散订单,则说明该国市场渗透进入良性循环。'
-        )
-    else:
-        driver = (
-            f'本周累计{total_qty}台,暂无国家维度分解数据。'
-            f'建议完善订单数据中国家字段,以便识别关键驱动市场并制定差异化资源投入策略。'
-            f'当前阶段可通过负责人访谈定性判断:本周增长主要来自老客户返单还是新市场突破。'
-        )
-    items.append({'title': '💡 关键驱动因素', 'content': driver})
-
-    # ⑤ 区域引擎识别
-    if region_dist:
-        regions = sorted(region_dist.items(), key=lambda x: x[1].get('qty', 0), reverse=True)
-        r1_name, r1_data = regions[0]
-        r1_qty = r1_data.get('qty', 0)
-        r1_pct = r1_data.get('pct', 0)
-        top_c = [c["country"] for c in r1_data.get("top_countries", [])[:2]]
-        region_text = (
-            f'{r1_name}本周贡献{r1_qty}台({r1_pct}%),为当前第一大区域引擎。'
-            f'其Top国家为{"、".join(top_c)}。'
-            f'建议对该区域实施"深耕+复制"策略:在成熟国家推进增值服务提升客单价,'
-            f'同时将成功经验复制至同区域二线国家,最大化区域协同效应。'
-        )
-    else:
-        region_text = (
-            f'本周订单{tracking_orders}单,缺乏区域维度数据。'
-            f'建议建立国家→区域映射表,以便自动识别区域引擎并评估资源投入ROI。'
-            f'当前可通过订单号前缀或客户归属地手动标注区域,作为过渡方案。'
-        )
-    items.append({'title': '🌍 区域引擎识别', 'content': region_text})
-
-    # ⑥ 结构健康度速览
-    avg_qty = metrics.get('avg_qty_per_order', 0)
-    prev_avg_qty = metrics.get('prev_avg_qty', 0) or context.get('prev_avg_qty', 0)
-    avg_chg = _pct_change(avg_qty, prev_avg_qty)
-    health = (
-        f'本周单均台数{avg_qty}台,{"较上周" + _fmt_chg_dir(avg_chg) + _fmt_pct(avg_chg) if prev_avg_qty else "基准待建立"}。'
-        f'{"大单占比提升,客户结构优化" if (avg_chg or 0) > 0 else "中小单为主,需关注大客户转化" if avg_qty else "数据缺失"}。'
-        f'结合{metrics.get("countries", 0)}个目的国分析,'
-        f'{"市场覆盖广但单国深度不足" if metrics.get("countries", 0) > 15 and avg_qty < 20 else ""}'
-        f'建议下周重点跟进单均台数大于50台的大单,锁定其对月度目标的贡献。'
-    )
-    items.append({'title': '⚖️ 结构健康度速览', 'content': health})
-
-    return items
-
-
-
-def _insight_weekly_trend(metrics: dict, context: dict) -> list[dict]:
-    """Page 3: 周内波动归因、与上周结构性差异、趋势持续性"""
-    items = []
-    daily_trend = metrics.get('daily_trend', {})
-    prev_daily_trend = context.get('prev_daily_trend', {})
-    tracking_orders = metrics.get('tracking_orders', 0)
-    prev_tracking_orders = metrics.get('prev_tracking_orders', 0)
-    forecast_next = metrics.get('forecast_next', 0)
-    total_qty = metrics.get('total_qty', 0)
-
-    # ① 周内波动归因
-    if daily_trend:
-        dates = list(daily_trend.keys())
-        vals = list(daily_trend.values())
-        avg_val = sum(vals) / len(vals)
-        low_days = [d for d, v in daily_trend.items() if v < avg_val * 0.7]
-        high_days = [d for d, v in daily_trend.items() if v > avg_val * 1.3]
-        fluctuation = (
-            f'本周日均{avg_val:.0f}单,{"、".join(high_days) if high_days else "无"}为显著高于均值的高活跃日,'
-            f'{"、".join(low_days) if low_days else "无"}为低于均值0.7倍的低谷日。'
-            f'低谷日需排查是否为节假日(当地法定假期)、客户月度/季度账期结算日,或系统批量录入延迟。'
-            f'建议建立"低谷日归因登记簿",记录每次低谷的外部因素,积累预测模型训练数据。'
-        )
-    else:
-        fluctuation = (
-            f'本周累计{tracking_orders}单,缺乏分日趋势数据。'
-            f'建议每日统计新增订单数,以便识别周内波动模式。当前可通过负责人日报定性判断:'
-            f'本周是否有1-2天明显低于其他工作日,若有,需排查外部干扰因素并提前制定应对措施。'
-        )
-    items.append({'title': '🔍 周内波动归因', 'content': fluctuation})
-
-    # ② 与上周结构性差异
-    if daily_trend and prev_daily_trend:
-        prev_vals = list(prev_daily_trend.values())
-        curr_vals = list(daily_trend.values())
-        curr_shape = "前高后低" if sum(curr_vals[:len(curr_vals)//2]) > sum(curr_vals[len(curr_vals)//2:]) else "前低后高"
-        prev_shape = "前高后低" if sum(prev_vals[:len(prev_vals)//2]) > sum(prev_vals[len(prev_vals)//2:]) else "前低后高"
-        structural = (
-            f'本周走势形态为"{curr_shape}",上周为"{prev_shape}",{"形态一致" if curr_shape == prev_shape else "形态发生反转"}。'
-            f'{"说明客户下单节奏稳定,周末效应或账期规律持续生效。" if curr_shape == prev_shape else "说明外部条件发生变化,可能受节假日错位或大单时点影响。"}'
-            f'建议对比两周的Top 3客户下单日期,判断结构差异来自系统性因素还是偶然大单。'
-        )
-    else:
-        structural = (
-            f'本周订单{tracking_orders}单,'
-            f'{"较上周" + _fmt_chg_dir(_pct_change(tracking_orders, prev_tracking_orders)) + _fmt_pct(_pct_change(tracking_orders, prev_tracking_orders)) if prev_tracking_orders else "缺乏上周分日数据"}。'
-            f'建议保留每周分日数据,以便进行走势形态对比(前高后低 vs 前低后高)。'
-            f'形态一致性是判断趋势可持续性的重要信号。'
-        )
-    items.append({'title': '📊 与上周结构性差异', 'content': structural})
-
-    # ③ 趋势持续性判断
-    wow_pct = _pct_change(tracking_orders, prev_tracking_orders)
-    if forecast_next:
-        sustain = (
-            f'Pipeline预测下月交付{forecast_next}台,结合本周{tracking_orders}单(wow {_fmt_pct(wow_pct)}),'
-            f'{"趋势向上且pipeline充足,持续性较强" if (wow_pct or 0) > 5 and forecast_next > total_qty * 0.8 else ""}'
-            f'{"趋势向好但pipeline不足,需警惕后继乏力" if (wow_pct or 0) > 5 and forecast_next <= total_qty * 0.5 else ""}'
-            f'{"趋势承压,pipeline也偏保守,双重承压" if (wow_pct or 0) < 0 else ""}'
-            f'。建议基于A+B阶段存量订单数评估真实转化潜力,'
-            f'若早期pipeline充足,则本周增长具备延续性;若依赖存量冲刺,则下周可能回落。'
-        )
-    else:
-        sustain = (
-            f'本周订单{tracking_orders}单(wow {_fmt_pct(wow_pct)}),因缺乏pipeline预测数据,'
-            f'趋势持续性需通过阶段结构间接判断。'
-            f'若A+B阶段订单占比大于40%,说明前期储备充足,下周有望维持;'
-            f'若E+F阶段占比过高,则本周增长可能来自集中交付的一次性脉冲,持续性存疑。'
-        )
-    items.append({'title': '🔮 趋势持续性判断', 'content': sustain})
-
-    # ④ 外部因素关联
-    support_cats = metrics.get('support_categories', {})
-    if support_cats:
-        top_cat = max(support_cats.items(), key=lambda x: x[1])
-        external = (
-            f'本周支持需求中"{top_cat[0]}"类最多({top_cat[1]}项),'
-            f'{"多为物流/船期问题,说明交付端承压,可能影响客户下单信心" if "物流" in top_cat[0] or "船期" in top_cat[0] else ""}'
-            f'{"多为财务/收款问题,说明资金端存在卡点,可能拖慢合同锁定节奏" if "财务" in top_cat[0] or "收款" in top_cat[0] else ""}'
-            f'。建议将该类问题的根因归类为流程/政策/外部三类,'
-            f'流程类内部优化,政策类提前预警,外部类建立替代方案(如备用船期/汇率对冲)。'
-        )
-    else:
-        external = (
-            f'本周暂无支持需求数据。建议主动收集:汇率波动、目的国进口政策调整、船期延误等外部事件,'
-            f'并评估其对本周订单节奏的干扰程度。'
-            f'建立"外部因素-订单波动"关联看板,可显著提升趋势归因的准确性。'
-        )
-    items.append({'title': '🌐 外部因素关联', 'content': external})
-
-    # ⑤ 异常点识别
-    if daily_trend:
-        vals = list(daily_trend.values())
-        mean_v = sum(vals) / len(vals)
-        std = (sum((v - mean_v) ** 2 for v in vals) / len(vals)) ** 0.5
-        outliers = [(d, v) for d, v in daily_trend.items() if abs(v - mean_v) > 1.5 * std]
-        if outliers:
-            outlier_text = (
-                f'本周{"、".join([d for d, _ in outliers])}出现异常波动,偏离均值{mean_v:.0f}单超过1.5倍标准差。'
-                f'异常日需区分"机会型"(大客户集中下单)与"风险型"(系统故障/数据补录)。'
-                f'建议对异常日逐单标注原因,长期积累后可建立异常检测规则,实现自动化预警。'
-            )
-        else:
-            outlier_text = (
-                f'本周各日订单围绕均值{mean_v:.0f}单正常波动,无显著异常点。'
-                f'说明业务运行平稳,外部干扰较少。建议保持当前运营节奏,'
-                f'同时将本周作为"平稳基线"保存,用于未来异常检测的对比基准。'
-            )
-    else:
-        outlier_text = (
-            f'缺乏分日数据,无法识别周内异常点。'
-            f'建议建立日报机制后,采用"均值+1.5倍标准差"规则自动标记异常交易日。'
-        )
-    items.append({'title': '⚠️ 异常点识别', 'content': outlier_text})
-
-    # ⑥ 下周走势预判
-    if daily_trend and prev_daily_trend:
-        curr_avg = sum(daily_trend.values()) / len(daily_trend)
-        prev_avg = sum(prev_daily_trend.values()) / len(prev_daily_trend)
-        momentum = _pct_change(curr_avg, prev_avg)
-        pred_low = int(curr_avg * 0.85)
-        pred_high = int(curr_avg * 1.15)
-        forecast = (
-            f'基于本周日均{curr_avg:.0f}单及环比动量{_fmt_pct(momentum)},'
-            f'预测下周日均订单区间{pred_low}-{pred_high}单,整周预计{pred_low * 5}-{pred_high * 5}单。'
-            f'{"若大客户持续下单,有望突破区间上限" if (momentum or 0) > 10 else ""}'
-            f'{"若外部因素未改善,可能回落至区间下限" if (momentum or 0) < 0 else ""}'
-            f'。建议每日晨会对照预测区间,偏差大于20%时触发专项复盘。'
-        )
-    else:
-        forecast = (
-            f'当前在跟订单{tracking_orders}单,基于历史经验,下周预计维持在'
-            f'{int(tracking_orders * 0.85)}-{int(tracking_orders * 1.15)}单区间。'
-            f'建议重点关注A→B阶段转化速率,这是下周增量的最可预测来源。'
-        )
-    items.append({'title': '📉 下周走势预判', 'content': forecast})
-
-    return items
-
-
-
-def _insight_weekly_wow(metrics: dict, context: dict) -> list[dict]:
-    """Page 4: 各环节转化效率、瓶颈环节深度诊断、库存/资金占用"""
-    items = []
-    status_wow = metrics.get('status_wow', {})
-    status_dist = metrics.get('status_dist', {})
-    total_orders = metrics.get('tracking_orders', 0)
-
-    def _get_status(name):
-        return status_dist.get(name, 0)
-
-    # ① 各环节转化效率
-    a_name, b_name = '合同拟定中', '已锁定合同待付订金'
-    a_curr = status_wow.get(a_name, {}).get('current', 0)
-    a_prev = status_wow.get(a_name, {}).get('previous', 0)
-    b_curr = status_wow.get(b_name, {}).get('current', 0)
-    b_prev = status_wow.get(b_name, {}).get('previous', 0)
-    if a_prev and b_prev:
-        prev_conv = round(b_prev / a_prev * 100, 1)
-        curr_conv = round(b_curr / a_curr * 100, 1) if a_curr else 0
-        conv_chg = _pct_change(curr_conv, prev_conv)
-        conv = (
-            f'A→B阶段转化率本周{curr_conv}%({b_curr}/{a_curr}),上周{prev_conv}%({b_prev}/{a_prev}),'
-            f'{_fmt_chg_dir(conv_chg)}{_fmt_pct(conv_chg)}。'
-            f'{"转化率提升,说明合同推进效率改善" if (conv_chg or 0) > 0 else "转化率下降,合同锁定遇阻"}。'
-            f'建议拆解转化失败的根因:客户侧(审批慢/资金未到位)vs 我方侧(条款争议/响应慢),针对性优化。'
-        )
-    else:
-        conv = (
-            f'本周A阶段{a_curr}单、B阶段{b_curr}单,因缺乏上周A/B分阶段数据,无法计算转化率变化。'
-            f'建议建立阶段快照机制,每周固定时点统计各阶段存量。当前可先设定A→B周转化目标为A阶段存量的15%,'
-            f'并落实到具体负责人。'
-        )
-    items.append({'title': '⚡ A→B转化效率', 'content': conv})
-
-    # ② 瓶颈环节深度诊断
-    changes = [(name, data.get('change_pct', 0) or 0) for name, data in status_wow.items()]
-    if changes:
-        worst = min(changes, key=lambda x: x[1])
-        best = max(changes, key=lambda x: x[1])
-        bottleneck = (
-            f'本周降幅最大环节为"{worst[0]}"({_fmt_pct(worst[1])}),增幅最大为"{best[0]}"(+{_fmt_pct(best[1])})。'
-            f'{"若降幅环节为E/F,说明交付端受阻;若为A/B,则前端获客或转化遇困。"}'
-            f'建议对{worst[0]}环节启动"五问法"根因分析:'
-            f'是流程卡点、资源不足、还是外部政策突变?找到根因后48h内出具改进方案。'
-        )
-    else:
-        bottleneck = (
-            f'本周各阶段环比数据不完整,暂无法识别瓶颈环节。'
-            f'建议建立每周阶段快照对比机制,重点监控C→D(生产)和D→E(尾款)两个关键转化节点。'
-            f'这两个节点直接决定交付周期和资金回笼速度,是 Weekly Review 的核心指标。'
-        )
-    items.append({'title': '📉 瓶颈环节深度诊断', 'content': bottleneck})
-
-    # ③ 库存与资金占用分析
-    c_curr = status_wow.get('已付订金待生产', {}).get('current', 0)
-    d_curr = status_wow.get('已生产待付尾款', {}).get('current', 0)
-    c_prev = status_wow.get('已付订金待生产', {}).get('previous', 0)
-    d_prev = status_wow.get('已生产待付尾款', {}).get('previous', 0)
-    cd_total = c_curr + d_curr
-    cd_prev = c_prev + d_prev
-    cd_chg = _pct_change(cd_total, cd_prev)
-    inventory = (
-        f'生产端(C+D)本周合计{cd_total}单,较上周{_fmt_chg_dir(cd_chg)}{_fmt_pct(cd_chg)}。'
-        f'{"生产端积压加重,资金与产能占用上升" if (cd_chg or 0) > 10 else "生产端平稳,库存压力可控" if abs(cd_chg or 0) <= 10 else "生产端去化加速,周转改善"}。'
-        f'D阶段{d_curr}单为核心风险点:已生产未收款,每单约占用产能和资金。'
-        f'建议对D阶段大于14天的订单启动专项催收,缩短资金占用周期。'
-    )
-    items.append({'title': '💰 库存与资金占用分析', 'content': inventory})
-
-    # ④ 发运端效率
-    e_curr = status_wow.get('已付尾款待发运', {}).get('current', 0)
-    f_curr = status_wow.get('已发运', {}).get('current', 0)
-    e_prev = status_wow.get('已付尾款待发运', {}).get('previous', 0)
-    ef_total = e_curr + f_curr
-    ef_text = (
-        f'发运端(E+F)本周合计{ef_total}单,其中待发运{e_curr}单、已发运{f_curr}单。'
-        f'待发运较上周{_fmt_chg_dir(_pct_change(e_curr, e_prev))}{_fmt_pct(_pct_change(e_curr, e_prev))}。'
-        f'{"待发运积压上升,需排查船期/舱位/报关瓶颈" if e_curr > (e_prev * 1.2 if e_prev else 0) else "发运节奏顺畅,交付闭环效率良好"}。'
-        f'建议物流部门每周提供船期表,销售据此与客户对齐收货预期,减少催单消耗的管理精力。'
-    )
-    items.append({'title': '🚢 发运端效率', 'content': ef_text})
-
-    # ⑤ 漏斗健康度综合评分
-    if total_orders:
-        early = _get_status('合同拟定中') + _get_status('已锁定合同待付订金')
-        mid = _get_status('已付订金待生产') + _get_status('已生产待付尾款')
-        late = _get_status('已付尾款待发运') + _get_status('已发运')
-        early_pct = round(early / total_orders * 100, 1)
-        mid_pct = round(mid / total_orders * 100, 1)
-        late_pct = round(late / total_orders * 100, 1)
-        health = (
-            f'本周漏斗结构:前期{early_pct}%({early}单)、中期{mid_pct}%({mid}单)、后期{late_pct}%({late}单)。'
-            f'{"前期占比偏高,pipeline充足但转化压力较大" if early_pct > 40 else ""}'
-            f'{"中期占比偏高,生产端承压,需关注产能瓶颈" if mid_pct > 40 else ""}'
-            f'{"后期占比偏高,交付冲刺期,需确保物流资源到位" if late_pct > 40 else ""}'
-            f'理想结构为前期35%-中期35%-后期30%,当前{"接近理想" if 30 <= early_pct <= 40 and 30 <= mid_pct <= 40 else "偏离理想,需针对性调整"}。'
-        )
-    else:
-        health = (
-            f'本周暂无订单数据,无法计算漏斗结构。'
-            f'建议建立标准化漏斗健康度评分模型:前期35%-中期35%-后期30%为基准,'
-            f'偏离超过10个百分点时自动触发预警并推荐改进动作。'
-        )
-    items.append({'title': '🏥 漏斗健康度综合评分', 'content': health})
-
-    return items
-
-
-
-def _insight_weekly_region(metrics: dict, context: dict) -> list[dict]:
-    """Page 5: 区域战略优先级、区域间协同、新兴市场孵化"""
-    items = []
-    region_dist = metrics.get('region_dist', {})
-    total_qty = metrics.get('total_qty', 0)
-    prev_region_dist = context.get('prev_region_dist', {})
-
-    if not region_dist:
-        return [
-            {'title': '💡 区域战略优先级', 'content': '本周暂无区域分布数据。建议完善订单数据中的国家与区域映射,以便识别明星区域与问题区域,优化资源投放策略。'},
-            {'title': '📈 区域间协同', 'content': '缺乏区域维度数据,暂无法评估区域间协同效应。建议收集相邻区域订单关联数据,识别是否存在客户转介绍或区域联动带来的增长。'},
-        ]
-
-    # ① 区域战略优先级矩阵
-    regions = []
-    for name, data in region_dist.items():
-        qty = data.get('qty', 0)
-        pct = data.get('pct', 0)
-        prev_qty = prev_region_dist.get(name, {}).get('qty', 0) if prev_region_dist else 0
-        growth = _pct_change(qty, prev_qty)
-        regions.append((name, qty, pct, growth))
-
-    stars = [r for r in regions if r[2] > 20 and (r[3] is None or r[3] > 0)]
-    cows = [r for r in regions if r[2] > 20 and (r[3] is not None and r[3] <= 0)]
-    questions = [r for r in regions if r[2] <= 20 and (r[3] is None or r[3] > 0)]
-    dogs = [r for r in regions if r[2] <= 20 and (r[3] is not None and r[3] <= 0)]
-
-    matrix = (
-        f'基于"占比×增速"矩阵分析:'
-        f'{"明星区域(高占比+增长):" + "、".join([r[0] for r in stars[:2]]) + ",应继续加大投入;" if stars else ""}'
-        f'{"现金牛(高占比+放缓):" + "、".join([r[0] for r in cows[:2]]) + ",维持投入收割利润;" if cows else ""}'
-        f'{"问题区域(低占比+增长):" + "、".join([r[0] for r in questions[:2]]) + ",需评估是否加大培育;" if questions else ""}'
-        f'{"瘦狗区域(低占比+下滑):" + "、".join([r[0] for r in dogs[:2]]) + ",考虑收缩或调整策略。" if dogs else ""}'
-    )
-    if not any([stars, cows, questions, dogs]):
-        matrix = (
-            f'本周各区域占比与增速数据不足以完成四象限分类。'
-            f'建议建立区域级周环比追踪,当区域数据完整后可自动生成波士顿矩阵并推荐资源配置策略。'
-        )
-    items.append({'title': '💡 区域战略优先级矩阵', 'content': matrix})
-
-    # ② 区域间协同效应
-    top_regions = sorted(regions, key=lambda x: x[1], reverse=True)[:3]
-    if len(top_regions) >= 2:
-        synergy = (
-            f'{top_regions[0][0]}本周贡献{top_regions[0][1]}台领跑,{top_regions[1][0]}以{top_regions[1][1]}台紧随其后。'
-            f'若两区域存在客户转介绍或同一代理商覆盖,则具备协同放大效应。'
-            f'建议复盘{top_regions[0][0]}的成功经验(车型偏好/渠道模式/定价策略),'
-            f'形成标准化打法后向{top_regions[1][0]}及同类区域复制,降低试错成本。'
-        )
-    else:
-        synergy = (
-            f'本周{top_regions[0][0]}为绝对主导区域,其他区域占比偏低。'
-            f'单极结构下协同效应有限,建议评估是否通过"成熟区带新区"机制,'
-            f'让成熟区域负责人兼任相邻新兴市场顾问,实现经验外溢。'
-        )
-    items.append({'title': '📈 区域间协同效应', 'content': synergy})
-
-    # ③ 新兴市场孵化
-    top8_countries = set()
-    for data in region_dist.values():
-        for c in data.get('top_countries', []):
-            top8_countries.add(c.get('country', ''))
-    all_countries_count = metrics.get('countries', 0)
-    if all_countries_count > len(top8_countries):
-        emerging = (
-            f'本周覆盖{all_countries_count}国,Top 8框架内国家{len(top8_countries)}个,'
-            f'另有{all_countries_count - len(top8_countries)}国为框架外新兴市场。'
-            f'若框架外国家本周出现首单或复购,说明市场孵化取得突破。'
-            f'建议对框架外国家建立"观察名单",单月订单大于3台即纳入正式跟踪,并匹配专项资源。'
-        )
-    else:
-        emerging = (
-            f'本周覆盖{all_countries_count}国,订单高度集中在Top国家。'
-            f'新兴市场孵化进度较慢,建议设定"每月突破1个新国家"的拓展目标,'
-            f'通过参加区域性车展、与当地经销商建立合作等方式扩大市场覆盖。'
-        )
-    items.append({'title': '🌱 新兴市场孵化', 'content': emerging})
-
-    # ④ 区域投入ROI评估
-    if regions:
-        growths = [r[3] for r in regions if r[3] is not None]
-        avg_growth = sum(growths) / len(growths) if growths else 0
-        roi = (
-            f'本周区域平均增速{_fmt_pct(avg_growth)}。'
-            f'{"增速为正的区域的投入产出比优于整体,建议将增量预算向这些区域倾斜" if avg_growth > 0 else "整体增速承压,建议收缩低效区域投入,集中资源保高潜市场"}。'
-            f'ROI评估应综合考虑订单量、利润率和售后成本:高订单低利润区域需谨慎追加投入,'
-            f'低订单高利润区域(如配件服务收入高)反而值得深耕。'
-        )
-    else:
-        roi = (
-            f'缺乏区域增速数据,暂无法计算投入ROI。'
-            f'建议建立区域级损益表,至少包含订单量、毛利率、物流成本、售后成本四项指标,'
-            f'每季度评估一次区域真实贡献度,优化资源分配。'
-        )
-    items.append({'title': '💰 区域投入ROI评估', 'content': roi})
-
-    # ⑤ 区域风险分散
-    top1_region_pct = max(r[2] for r in regions) if regions else 0
-    risk = (
-        f'第一大区域占比{top1_region_pct}%,{"集中度偏高,存在单区域政策/汇率波动风险" if top1_region_pct > 50 else "集中度适中,风险分散良好"}。'
-        f'{"建议3个月内将第一大区域占比压降至50%以下,通过培育第二梯队实现结构优化" if top1_region_pct > 50 else "建议继续巩固现有均衡格局,同时培育1-2个潜力区域作为第三极"}。'
-        f'区域多元化是抵御单一市场政策风险的最有效手段。'
-    )
-    items.append({'title': '⚠️ 区域风险分散', 'content': risk})
-
-    return items
-
-
-
-def _insight_weekly_country(metrics: dict, context: dict) -> list[dict]:
-    """Page 6: 国家组合健康度、大客户集中度、竞争格局"""
-    items = []
-    top_countries = metrics.get('top_countries', {})
-    top_countries_change = metrics.get('top_countries_change', {})
-    total_qty = metrics.get('total_qty', 0)
-    top6_concentration_pct = metrics.get('top6_concentration_pct', 0)
-
-    if not top_countries:
-        return [
-            {'title': '💡 国家组合健康度', 'content': '本周暂无国家分布数据。建议完善目的国家字段,以便评估国家组合集中度风险并制定分散策略。'},
-        ]
-
-    country_qty = {}
-    for c, v in top_countries.items():
-        country_qty[c] = v if isinstance(v, int) else v.get('qty', 0)
-
-    sorted_countries = sorted(country_qty.items(), key=lambda x: x[1], reverse=True)
-    top3_qty = sum(v for _, v in sorted_countries[:3])
-    top3_pct = round(top3_qty / total_qty * 100, 1) if total_qty else 0
-
-    # ① 国家组合健康度
-    health = (
-        f'Top 3国家合计{top3_qty}台({top3_pct}%),{"超过40%警戒线,单国波动对整体业绩影响显著" if top3_pct > 40 else "低于40%,国家分布相对健康"}。'
-        f'{"Top 6集中度" + str(top6_concentration_pct) + "%进一步印证了大客户/大国家驱动特征" if top6_concentration_pct > 60 else ""}'
-        f'建议设定"Top 3占比小于40%、Top 6小于65%"的组合健康目标,'
-        f'通过培育第二梯队国家(4-8名)逐步降低头部依赖。'
-    )
-    items.append({'title': '💡 国家组合健康度', 'content': health})
-
-    # ② 大客户集中度
-    top1_country, top1_qty = sorted_countries[0]
-    chg_data = top_countries_change.get(top1_country, {})
-    chg_pct = chg_data.get('change_pct') if isinstance(chg_data, dict) else None
-    concentration = (
-        f'{top1_country}本周{top1_qty}台领跑,环比{_fmt_pct(chg_pct) if chg_pct is not None else "—"}。'
-        f'若该国订单来自单一客户,则存在极大客户依赖风险,该客户流失将导致月度目标大幅缺口。'
-        f'建议对Top 1国家进行客户拆解:单一客户占比大于50%则触发红色预警,需立即启动新客户开发计划。'
-    )
-    items.append({'title': '👤 大客户集中度风险', 'content': concentration})
-
-    # ③ 国家增速梯队
-    if top_countries_change:
-        growing = [(c, d['change_pct']) for c, d in top_countries_change.items() if d.get('change_pct', 0) and d['change_pct'] > 0]
-        declining = [(c, d['change_pct']) for c, d in top_countries_change.items() if d.get('change_pct', 0) and d['change_pct'] < 0]
-        growing.sort(key=lambda x: x[1], reverse=True)
-        declining.sort(key=lambda x: x[1])
-        tier = (
-            f'国家增速梯队:上升最快{"为" + "、".join([c for c, _ in growing[:2]]) if growing else "暂无"},'
-            f'下滑最快{"为" + "、".join([c for c, _ in declining[:2]]) if declining else "暂无"}。'
-            f'上升国家需分析是政策红利还是客户拓展见效,判断可持续性;'
-            f'下滑国家需区分暂时性因素(节假日/账期)还是结构性因素(竞争加剧/需求萎缩),对症下药。'
-        )
-    else:
-        tier = (
-            f'本周缺乏国家维度环比数据,无法划分增速梯队。'
-            f'建议每周固定输出Top 10国家环比变化表,识别"黑马"与"掉队"国家,'
-            f'为资源动态调配提供数据依据。'
-        )
-    items.append({'title': '📊 国家增速梯队', 'content': tier})
-
-    # ④ 竞争格局判断
-    competition = (
-        f'若{top1_country}市场出现价格竞争或交付周期压缩,说明竞品正在渗透。'
-        f'建议建立"竞争信号"监测机制:客户询盘时提及竞品频次、询盘转化率变化、订单周期拉长等。'
-        f'本周若Top 1国家订单增速放缓但询盘量上升,可能是竞品截流信号,需立即调整报价或服务策略。'
-    )
-    items.append({'title': '🏁 竞争格局判断', 'content': competition})
-
-    # ⑤ 国家生命周期策略
-    if len(sorted_countries) >= 2:
-        mid = sorted_countries[len(sorted_countries)//2][1] if sorted_countries else 0
-        mature = [c for c, v in sorted_countries if v >= mid * 1.2]
-        emerging = [c for c, v in sorted_countries if v < mid * 0.8]
-        lifecycle = (
-            f'成熟市场({"、".join(mature[:3])})订单稳定,建议主推高毛利车型和金融服务提升客单价;'
-            f'新兴市场({"、".join(emerging[:3])})处于首单突破期,当前应以保交付口碑为核心,'
-            f'建立标杆案例后通过客户转介绍实现低成本获客。两种市场的KPI应差异化设置。'
-        )
-    else:
-        lifecycle = (
-            f'当前国家数量不足以划分生命周期梯队。'
-            f'建议积累更多国家数据后,按"订单量+复购率"双维度将国家分为导入期、成长期、成熟期、衰退期,'
-            f'匹配差异化的产品、定价和服务策略。'
-        )
-    items.append({'title': '📈 国家生命周期策略', 'content': lifecycle})
-
-    return items
-
-
-
-def _insight_weekly_team(metrics: dict, context: dict) -> list[dict]:
-    """Page 7: 人均产出趋势、区域专注度、协作效率"""
-    items = []
-    team = metrics.get('team', {})
-    team_wow = metrics.get('team_wow', {})
-    per_capita_orders = metrics.get('per_capita_orders', 0)
-    prev_per_capita = context.get('prev_per_capita_orders', 0)
-    countries = metrics.get('countries', 0)
-
-    if isinstance(team, dict) and 'owners' in team:
-        owners = team['owners']
-        qty_map = team.get('qty', {})
-    else:
-        owners = {k: v.get('orders', 0) for k, v in team.items()} if team else {}
-        qty_map = {k: v.get('qty', 0) for k, v in team.items()} if team else {}
-
-    if not owners:
-        return [
-            {'title': '💡 人均产出趋势', 'content': '本周暂无负责人数据。建议完善订单归属字段,以便评估团队人效并优化负载分配。'},
-            {'title': '⚖️ 区域专注度', 'content': '缺乏团队数据,无法评估区域专注度。建议建立人均覆盖国家数指标,超过8个时触发精力分散预警。'},
-        ]
-
-    n_members = len(owners)
-    total_orders = sum(owners.values())
-    sorted_owners = sorted(owners.items(), key=lambda x: x[1], reverse=True)
-    top_owner, top_val = sorted_owners[0]
-
-    # ① 人均产出趋势
-    pc_chg = _pct_change(per_capita_orders, prev_per_capita)
-    productivity = (
-        f'本周团队{n_members}人,人均{per_capita_orders:.1f}单,'
-        f'{"较上周" + _fmt_chg_dir(pc_chg) + _fmt_pct(pc_chg) if prev_per_capita else "基准待建立"}。'
-        f'{"人均产出提升,团队效率改善" if (pc_chg or 0) > 0 else "人均产出下滑,需排查是人员增加稀释还是订单总量下降"}。'
-        f'建议将人均产出纳入周会Review,连续两周下滑时启动专项诊断。'
-    )
-    items.append({'title': '💡 人均产出趋势', 'content': productivity})
-
-    # ② 头部与尾部差距
-    tail_vals = [v for _, v in sorted_owners[-3:]]
-    tail_avg = sum(tail_vals) / len(tail_vals) if tail_vals else 0
-    gap = _safe_div(top_val, tail_avg) if tail_avg else 0
-    disparity = (
-        f'团队头部{top_owner}本周{top_val}单,尾部3人均约{tail_avg:.1f}单,'
-        f'头尾差距{gap:.1f}倍,{"差距较大,存在激励或能力分化" if gap > 3 else "差距适中,团队相对均衡"}。'
-        f'若差距来自能力差异,建议安排头部带教;若来自资源分配不均(如国家/客户分配),'
-        f'建议重新盘点客户池,向低负载负责人倾斜高潜客户。'
-    )
-    items.append({'title': '📊 头部与尾部差距', 'content': disparity})
-
-    # ③ 区域专注度
-    if countries and n_members:
-        avg_countries = countries / n_members
-        focus = (
-            f'团队人均覆盖{avg_countries:.1f}个国家,'
-            f'{"超过8个,精力高度分散,单国深度不足" if avg_countries > 8 else "在合理范围内,兼顾广度与深度"}。'
-            f'{"建议将国家按优先级分为A/B/C类,A类由专人深耕,B类共享覆盖,C类交给代理商" if avg_countries > 8 else ""}'
-            f'{"建议对重点国家实施双人backup机制,避免单点依赖" if avg_countries <= 5 else ""}'
-            f'。国家覆盖数与订单量呈倒U型关系,过多或过少均不利。'
-        )
-    else:
-        focus = (
-            f'缺乏国家数或团队人数数据,无法计算人均覆盖。'
-            f'建议建立"人均覆盖国家数"指标,警戒线为8个,超过时触发区域重新划分流程。'
-        )
-    items.append({'title': '⚖️ 区域专注度', 'content': focus})
-
-    # ④ 协作效率
-    cross_orders = context.get('cross_owner_orders', 0)
-    if cross_orders:
-        ratio = round(cross_orders / total_orders * 100, 1) if total_orders else 0
-        collaboration = (
-            f'本周跨负责人协作订单{cross_orders}单,占总量{ratio}%。'
-            f'{"协作占比偏低,团队呈单兵作战状态,知识共享不足" if ratio < 5 else "协作机制运行良好,团队具备协同作战能力"}。'
-            f'建议对大型项目(单均大于50台)强制要求双负责人制,既分散风险又促进经验交流。'
-        )
-    else:
-        collaboration = (
-            f'本周暂无跨负责人协作订单数据。建议建立协作订单标记机制,'
-            f'识别需要多区域/多客户类型协同的复杂项目。'
-            f'协作效率是团队从"单兵"向"集团军"转型的关键指标,应纳入季度考核。'
-        )
-    items.append({'title': '🤝 协作效率', 'content': collaboration})
-
-    # ⑤ 人员稳定性
-    if team_wow:
-        churn = [o for o, d in team_wow.items() if d.get('previous', 0) > 0 and d.get('current', 0) == 0]
-        new_rise = [o for o, d in team_wow.items() if d.get('previous', 0) == 0 and d.get('current', 0) > 0]
-        stability = (
-            f'团队变动:{"上周有产出但本周挂零:" + "、".join(churn[:2]) if churn else "无流失风险信号"};'
-            f'{"本周新涌现:" + "、".join(new_rise[:2]) if new_rise else "无新人冒头"}。'
-            f'{"需关注挂零负责人的客户状态,是否离职/调岗/休假导致订单断档,必要时启动客户交接保护机制" if churn else "团队人员稳定,无异常流失或新增涌现,建议保持当前激励机制并关注长期职业发展路径"}'
-            f'{"新涌现负责人值得复盘其成功经验,快速复制至团队" if new_rise else ""}'
-        )
-    else:
-        stability = (
-            f'缺乏上周团队对比数据,无法评估人员稳定性。'
-            f'建议建立人员周环比追踪,连续两周挂零或大幅波动时触发主管一对一沟通。'
-            f'人员稳定性是业务连续性的基础,突然变动往往导致客户流失和知识断层。'
-        )
-    items.append({'title': '👥 人员稳定性', 'content': stability})
-
-    return items
-
-
-
-def _insight_weekly_issue(metrics: dict, context: dict) -> list[dict]:
-    """Page 8: 问题根因分类、影响面量化、重复性问题、解决进度"""
-    items = []
-    issues = metrics.get('issues', [])
-    support_categories = metrics.get('support_categories', {})
-    tracking_orders = metrics.get('tracking_orders', 0)
-    total_qty = metrics.get('total_qty', 0)
-
-    # ① 问题根因分类
-    if issues:
-        root_causes = {'技术': 0, '流程': 0, '外部': 0}
-        for issue in issues:
-            detail = issue.get('detail', '') + issue.get('title', '')
-            if any(k in detail for k in ['系统', 'IT', '技术', '质量', '检测']):
-                root_causes['技术'] += 1
-            elif any(k in detail for k in ['流程', '审批', '协调', '内部']):
-                root_causes['流程'] += 1
-            else:
-                root_causes['外部'] += 1
-        dominant = max(root_causes.items(), key=lambda x: x[1])
-        root = (
-            f'本周{len(issues)}项问题按根因分类:技术类{root_causes["技术"]}项、流程类{root_causes["流程"]}项、外部类{root_causes["外部"]}项。'
-            f'{"技术类最多,说明产品质量或系统稳定性存在短板,需产研部门介入" if dominant[0] == "技术" else ""}'
-            f'{"流程类最多,说明内部协作存在断点,建议梳理跨部门SOP" if dominant[0] == "流程" else ""}'
-            f'{"外部类最多,说明问题主要来自客户/政策/物流等不可控因素,需建立预案库" if dominant[0] == "外部" else ""}'
-            f'。根因分类是预防性管理的基础,建议每次问题上报时强制选择根因类型。'
-        )
-    else:
-        root = (
-            f'本周暂无显式问题记录。建议建立"无问题也是一种信号"的视角:'
-            f'若支持需求总量上升但问题记录为零,可能是问题未被识别或归类。'
-            f'建议每周五由负责人提交本周卡点,即使已自行解决也记录备案,积累组织知识。'
-        )
-    items.append({'title': '🔍 问题根因分类', 'content': root})
-
-    # ② 影响面量化
-    if issues:
-        est_orders = max(len(issues), sum(1 for i in issues if i.get('severity') == '高'))
-        est_qty = est_orders * 30
-        est_amount = est_orders * 150
-        impact = (
-            f'本周问题预计影响订单{est_orders}单、车辆约{est_qty}台、金额约¥{est_amount}万。'
-            f'其中高严重度问题{"占比高,需立即升级至管理层" if est_orders > 3 else "可控,按常规流程处理即可"}。'
-            f'建议建立"问题影响面"必填字段:阻断单数、预计台数、预计金额,便于后续按损失金额排序处理优先级。'
-        )
-    else:
-        impact = (
-            f'本周无显式问题,预计影响面为零。建议将节省的管理精力转向'
-            f'"支持需求前置化处理",把潜在问题消灭在萌芽阶段。'
-            f'支持需求中若出现高频关键词,应视为早期预警信号。'
-        )
-    items.append({'title': '💰 影响面量化', 'content': impact})
-
-    # ③ 重复性问题识别
-    if support_categories:
-        top_cat = max(support_categories.items(), key=lambda x: x[1])
-        recurring = (
-            f'本周支持需求中"{top_cat[0]}"类出现{top_cat[1]}次,为最高频类别。'
-            f'若该类别连续两周及以上位居榜首,则可判定为重复性问题。'
-            f'建议对{top_cat[0]}类问题启动"根治计划":统计发生场景→提炼标准化处理模板→培训一线人员→设置系统校验规则,'
-            f'目标是将该类问题发生频率压降50%以上。'
-        )
-    else:
-        recurring = (
-            f'本周无支持需求分类数据,无法识别重复性问题。'
-            f'建议积累4周以上数据后,按"月度出现频次大于3次且连续两周上榜"定义重复性问题,'
-            f'并建立专项改进小组负责根治。'
-        )
-    items.append({'title': '🔄 重复性问题识别', 'content': recurring})
-
-    # ④ 上周问题解决率
-    prev_resolved = context.get('prev_week_resolved_issues', 0)
-    prev_total = context.get('prev_week_total_issues', 0)
-    if prev_total:
-        resolve_rate = round(prev_resolved / prev_total * 100, 1)
-        resolution = (
-            f'上周问题{prev_total}项,已解决{prev_resolved}项,解决率{resolve_rate}%。'
-            f'{"解决率大于80%,问题闭环效率高" if resolve_rate > 80 else "解决率小于80%,存在遗留问题堆积风险"}。'
-            f'未解决问题应滚动至本周继续跟踪,避免问题"报而不决"形成管理债务。'
-            f'建议建立问题看板,按"待处理/处理中/待验证/已关闭"四状态管理。'
-        )
-    else:
-        resolution = (
-            f'缺乏上周问题解决率数据。建议建立问题生命周期管理:从上报到关闭全流程记录,'
-            f'每周计算"本周关闭率"和"平均关闭时长"。目标:关闭率大于85%、平均时长小于5个工作日。'
-        )
-    items.append({'title': '✅ 上周问题解决率', 'content': resolution})
-
-    # ⑤ 问题预防机制
-    prevention = (
-        f'建议建立三层预防机制:第一层"系统校验"(如合同超14天自动标红)、'
-        f'第二层"流程卡点"(如大额订单必须经法务预审核)、'
-        f'第三层"文化驱动"(如每月评选"零问题周"并给予团队奖励)。'
-        f'从"救火"转向"防火",是问题管理的终极目标。'
-    )
-    items.append({'title': '🛡️ 问题预防机制', 'content': prevention})
-
-    return items
-
-
-
-def _insight_weekly_plan(metrics: dict, context: dict) -> list[dict]:
-    """Page 9: 目标拆解逻辑、资源匹配、里程碑、风险对冲"""
-    items = []
-    next_week_goals = metrics.get('next_week_goals', [])
-    monthly_target = context.get('monthly_target', 0)
-    total_qty = metrics.get('total_qty', 0)
-    tracking_orders = metrics.get('tracking_orders', 0)
-
-    # ① 目标拆解逻辑
-    if next_week_goals and monthly_target:
-        total_goal = sum(g.get('number', 0) for g in next_week_goals)
-        breakdown = (
-            f'下周目标合计{total_goal}项任务,源自月度目标{monthly_target}台的四分之一拆解({monthly_target/4:.0f}台/周)。'
-            f'本周实际完成{total_qty}台,{"高于" if total_qty > monthly_target/4 else "低于"}周均目标,'
-            f'下周需{"保持惯性" if total_qty > monthly_target/4 else "加速追赶"}。'
-            f'目标拆解应遵循"存量转化保底+新增拓展增量"双轨逻辑,避免仅靠单一来源支撑。'
-        )
-    else:
-        breakdown = (
-            f'下周目标共{len(next_week_goals)}项,因缺乏月度目标或本周台数数据,暂无法做比例拆解。'
-            f'建议业务部门输入月度目标后,系统自动按"四周均衡"或"前低后高冲刺"模式生成周目标。'
-            f'当前可先基于本周{tracking_orders}单设定下周增长5%-10%的软目标。'
-        )
-    items.append({'title': '🎯 目标拆解逻辑', 'content': breakdown})
-
-    # ② 资源匹配
-    n_goals = len(next_week_goals)
-    n_owners = len(metrics.get('team', {}).get('owners', {})) if isinstance(metrics.get('team'), dict) and 'owners' in metrics.get('team', {}) else len(metrics.get('team', {}))
-    pending_ship = metrics.get('pending_shipment', 0)
-    pending_pay = metrics.get('pending_payment', 0)
-    resource = (
-        f'下周需完成{n_goals}项目标,当前团队{n_owners}人,人均承担{n_goals/n_owners if n_owners else 0:.1f}项目标。'
-        f'待发运{pending_ship}单、待收款{pending_pay}单为资源消耗大头,需提前协调物流舱位和财务催收人力。'
-        f'若目标增量超过团队当前负载20%,建议申请临时支援或外包非核心环节。'
-    )
-    items.append({'title': '⚙️ 资源匹配', 'content': resource})
-
-    # ③ 里程碑
-    milestones = []
-    for g in next_week_goals[:2]:
-        milestones.append(f'{g.get("id", "")}:{g.get("title", "")}({g.get("number", 0)})')
-    milestone_text = (
-        f'下周必须达成节点:{";".join(milestones) if milestones else "暂无具体里程碑"}。'
-        f'里程碑应满足SMART原则:具体到单数/台数、可量化、可验证。'
-        f'建议每周一晨会公开承诺里程碑,周五复盘完成率,未完成项需在当日24:00前提交原因和改进措施。'
-    )
-    items.append({'title': '🚩 里程碑节点', 'content': milestone_text})
-
-    # ④ 风险对冲(Plan B)
-    risks = context.get('next_week_risks', [])
-    if risks:
-        plan_b = (
-            f'下周已识别风险:{"、".join([r.get("title", "") for r in risks[:3]])}。'
-            f'Plan B原则:若A方案因外部因素受阻,48h内切换至备用方案。'
-            f'例如:若主船期延误,提前锁定备用船公司;若大客户延迟下单,启动备选客户清单。'
-            f'建议每项高风险目标均配置Plan B,并在周初完成资源预置。'
-        )
-    else:
-        plan_b = (
-            f'下周风险清单尚未建立。建议基于本周问题和支持需求,推演下周可能出现的3个最大风险场景:'
-            f'(1)物流延误导致E阶段积压、(2)大客户账期延迟导致D→E转化受阻、(3)政策变动导致A阶段合同搁置。'
-            f'针对每类场景提前设计应对预案,确保目标韧性。'
-        )
-    items.append({'title': '🛡️ 风险对冲(Plan B)', 'content': plan_b})
-
-    # ⑤ 关键依赖项
-    dependencies = context.get('next_week_dependencies', [])
-    if dependencies:
-        dep_text = (
-            f'下周目标达成依赖于:{"、".join(dependencies[:3])}。'
-            f'关键依赖应提前一周确认状态,避免临时发现资源不到位导致目标悬空。'
-            f'建议建立"依赖项红绿灯"机制:周一全绿确认、周三黄灯预警、周五必须全绿放行。'
-        )
-    else:
-        dep_text = (
-            f'下周关键依赖项尚未明确。建议梳理:物流舱位确认书、财务收款截止时间、'
-            f'生产排期锁定函等关键外部确认,作为目标达成的先决条件。'
-            f'内部依赖(如跨部门协作)也应落实到具体责任人和交付时间。'
-        )
-    items.append({'title': '🔗 关键依赖项', 'content': dep_text})
-
-    return items
-
-
-
-# =============================================================================
-# MONTHLY INSIGHTS
-# =============================================================================
-
-def monthly_insights(page_type: str, metrics: dict, context: dict) -> list[dict]:
-    """Dispatch to specific monthly page insight functions."""
-    dispatch = {
-        'monthly_overview': _insight_monthly_overview,
-        'monthly_funnel': _insight_monthly_funnel,
-        'monthly_region': _insight_monthly_region,
-        'monthly_country': _insight_monthly_country,
-        'monthly_trend': _insight_monthly_trend,
-        'monthly_team': _insight_monthly_team,
-        'monthly_support': _insight_monthly_support,
-        'monthly_plan': _insight_monthly_plan,
-    }
-    fn = dispatch.get(page_type)
-    return fn(metrics, context) if fn else []
-
-
-def _insight_monthly_overview(metrics: dict, context: dict) -> list[dict]:
-    """Page 3: 月度节奏、目标达成率、季节性、年度进度"""
-    items = []
-    total_contracts = metrics.get('total_contracts', 0)
-    total_qty = metrics.get('total_qty', 0)
-    shipped_orders = metrics.get('shipped_orders', 0)
-    shipped_qty = metrics.get('shipped_qty', 0)
-    trend_by_period = metrics.get('trend_by_period', {})
-    monthly_target = context.get('monthly_target', 0)
-    yoy_total_qty = metrics.get('yoy_total_qty', 0)
-    annual_target = context.get('annual_target', 0)
-
-    # ① 月度节奏(上中下旬)
-    if trend_by_period:
-        early = trend_by_period.get('early', 0)
-        mid = trend_by_period.get('mid', 0)
-        late = trend_by_period.get('late', 0)
-        total_period = early + mid + late
-        if total_period:
-            early_pct = round(early / total_period * 100, 1)
-            mid_pct = round(mid / total_period * 100, 1)
-            late_pct = round(late / total_period * 100, 1)
-            rhythm = (
-                f'本月订单节奏:上旬{early_pct}%({early:.1f}单/日)、中旬{mid_pct}%({mid:.1f}单/日)、下旬{late_pct}%({late:.1f}单/日)。'
-                f'{"下旬冲刺特征明显,说明团队具备收官能力但前期储备不足" if late > early and late > mid else ""}'
-                f'{"上旬开门红,中下旬平稳,月度节奏健康" if early >= mid and mid >= late * 0.8 else ""}'
-                f'{"中旬平台期过长,存在阶段性懈怠,需加强月中跟踪" if mid < early * 0.7 else ""}'
-                f'。理想节奏为上旬35%-中旬30%-下旬35%,当前{"接近理想" if 25 <= early_pct <= 45 and 25 <= mid_pct <= 40 else "需调整"}。'
-            )
-        else:
-            rhythm = f'本月分旬数据异常,无法计算节奏占比。建议检查日报数据完整性。'
-    else:
-        rhythm = (
-            f'本月累计{total_contracts}单,缺乏上中下旬分旬数据。'
-            f'建议将月度按自然旬拆分,识别"月初开门红/月中平台期/月末冲刺"的典型模式。'
-            f'节奏分析是预测下月走势和优化资源投放时点的重要依据。'
-        )
-    items.append({'title': '📅 月度节奏分析', 'content': rhythm})
-
-    # ② 目标达成率
-    if monthly_target and total_qty:
-        achievement = round(total_qty / monthly_target * 100, 1)
-        gap = total_qty - monthly_target
-        target_text = (
-            f'本月实际完成{total_qty}台,目标{monthly_target}台,达成率{achievement}%,'
-            f'{"超额" if gap >= 0 else "缺口"}{abs(gap)}台。'
-            f'{"达成率超过100%,建议复盘成功因素并固化为标准打法" if achievement >= 100 else ""}'
-            f'{"达成率90%-100%,基本达标但无冗余,需确保最后几天无退单" if 90 <= achievement < 100 else ""}'
-            f'{"达成率低于90%,缺口较大,需启动紧急补单或下调下月目标" if achievement < 90 else ""}'
-            f'。目标达成率应分解到周,每周偏差大于10%时即触发预警。'
-        )
-    else:
-        target_text = (
-            f'本月完成{total_qty}台,因未设定月度目标,无法计算达成率。'
-            f'建议业务部门在月初输入目标值,系统将自动按周拆解并生成进度看板。'
-            f'当前可先以历史月均值为隐性目标,评估相对表现。'
-        )
-    items.append({'title': '🎯 目标达成率', 'content': target_text})
-
-    # ③ 季节性分析(同比)
-    if yoy_total_qty:
-        yoy_chg = _pct_change(total_qty, yoy_total_qty)
-        seasonal = (
-            f'本月{total_qty}台,去年同期{yoy_total_qty}台,同比{_fmt_chg_dir(yoy_chg)}{_fmt_pct(yoy_chg)}。'
-            f'{"同比加速增长,市场景气度上行或我司市占率提升" if (yoy_chg or 0) > 10 else ""}'
-            f'{"同比小幅增长,与行业大盘同步" if 0 <= (yoy_chg or 0) <= 10 else ""}'
-            f'{"同比下滑,需警惕行业下行或竞争加剧" if (yoy_chg or 0) < 0 else ""}'
-            f'。建议结合行业报告判断:若我司增速高于行业,说明策略有效;若低于行业,则需重新审视产品/定价/渠道。'
-        )
-    else:
-        seasonal = (
-            f'本月完成{total_qty}台,缺乏去年同期数据。'
-            f'建议建立年度数据档案,以便进行同比分析。同比是剔除季节性干扰的最佳方式,'
-            f'尤其在受春节、海外斋月等影响的月份,环比可能失真,同比更具参考价值。'
-        )
-    items.append({'title': '📊 季节性分析(同比)', 'content': seasonal})
-
-    # ④ 年度进度
-    if annual_target and total_qty:
-        annual_progress = round(total_qty / annual_target * 100, 1)
-        month = context.get('month', 1)
-        expected_annual = month / 12 * 100
-        annual_gap = annual_progress - expected_annual
-        annual_text = (
-            f'本月贡献{total_qty}台,占年度目标{annual_target}台的{annual_progress}%,'
-            f'按时间进度应达{expected_annual:.1f}%,{"领先" if annual_gap >= 0 else "落后"}{abs(annual_gap):.1f}个百分点。'
-            f'{"年度进度超前,建议适当储备Q4项目避免年末透支" if annual_gap > 5 else ""}'
-            f'{"年度进度滞后,剩余月份需月均追赶" + str(abs(int(annual_gap/100*annual_target/(12-month)))) + "台" if annual_gap < -5 and month < 12 else ""}'
-            f'。年度进度是战略资源配置的北极星指标,每季度应做一次资源再平衡。'
-        )
-    else:
-        annual_text = (
-            f'本月完成{total_qty}台,因未设定年度目标,无法计算年度进度。'
-            f'建议年初输入年度目标,系统自动按月分解并生成进度条。'
-            f'年度目标的合理性直接影响月度策略:过高导致团队透支,过低导致资源闲置。'
-        )
-    items.append({'title': '🗓️ 年度进度', 'content': annual_text})
-
-    # ⑤ 交付闭环效率
-    if total_contracts:
-        ship_pct = round(shipped_orders / total_contracts * 100, 1)
-        delivery = (
-            f'本月已发运{shipped_orders}单({shipped_qty}台),占月内合同{ship_pct}%。'
-            f'{"发运占比高,交付闭环效率高" if ship_pct > 60 else "发运占比偏低,大量订单滞留中后期阶段"}。'
-            f'需区分"本月新签本月发运"(效率极高)与"历史存量本月发运"(清理旧账),'
-            f'前者反映流程效率,后者反映历史包袱。建议分别统计两类占比。'
-        )
-    else:
-        delivery = (
-            f'本月暂无合同数据,无法计算交付闭环效率。'
-            f'建议建立"当月签约-当月发运"比率作为流程效率核心指标,目标值大于30%。'
-        )
-    items.append({'title': '🚢 交付闭环效率', 'content': delivery})
-
-    return items
-
-
-
-def _insight_monthly_funnel(metrics: dict, context: dict) -> list[dict]:
-    """Page 4: 各环节转化率、瓶颈诊断、历史最优、改进路线图"""
-    items = []
-    status_funnel = metrics.get('status_funnel', {})
-    stage_analysis = metrics.get('stage_analysis', {})
-    total_contracts = metrics.get('total_contracts', 0)
-
-    if not status_funnel or not total_contracts:
-        return [
-            {'title': '💡 漏斗结构诊断', 'content': '本月暂无漏斗数据。建议完善订单状态字段,以便计算各阶段转化率并识别瓶颈。'},
-        ]
-
-    # ① 各环节转化率分析
-    names = ['合同拟定中', '已锁定合同待付订金', '已付订金待生产', '已生产待付尾款', '已付尾款待发运', '已发运']
-    prev_funnel = context.get('prev_status_funnel', {})
-    conv_text = '本月漏斗各阶段占比:'
-    parts = []
-    for name in names:
-        pct = status_funnel.get(name, {}).get('pct', 0)
-        prev_pct = prev_funnel.get(name, {}).get('pct', 0) if prev_funnel else 0
-        chg = _pct_change(pct, prev_pct)
-        parts.append(f'{name}{pct}%({_fmt_chg_dir(chg)}{_fmt_pct(chg)})')
-    conv_text += '、'.join(parts[:4]) + '。'
-    conv_text += '转化率变化反映流程效率波动,建议锁定两个关键转化节点重点监控:A→B(合同锁定)和D→E(尾款回收)。'
-    items.append({'title': '💡 各环节转化率分析', 'content': conv_text})
-
-    # ② 瓶颈环节深度诊断
-    early = stage_analysis.get('early', {}).get('pct', 0)
-    mid = stage_analysis.get('mid', {}).get('pct', 0)
-    late = stage_analysis.get('late', {}).get('pct', 0)
-    bottleneck = (
-        f'阶段结构:前期{early}%(合同+锁定)、中期{mid}%(生产)、后期{late}%(待发运+已发运)。'
-        f'{"前期占比过高,新增pipeline充足但转化效率低,需重点突破合同审批和定金催收" if early > 40 else ""}'
-        f'{"中期占比过高,生产端积压严重,需排查产能瓶颈或生产计划失衡" if mid > 40 else ""}'
-        f'{"后期占比过高,交付压力大,需确保物流和报关资源到位" if late > 40 else ""}'
-        f'。与行业benchmark对比:理想状态为前期30%-中期35%-后期35%,当前{"接近理想" if 25 <= early <= 40 and 25 <= mid <= 45 else "偏离理想,需专项改进"}。'
-    )
-    items.append({'title': '📉 瓶颈环节深度诊断', 'content': bottleneck})
-
-    # ③ 历史最优水平对比
-    hist_best = context.get('hist_best_conversion', {})
-    if hist_best:
-        best_text = (
-            f'历史最优A→B转化率{hist_best.get("a_to_b", "—")}%、D→E转化率{hist_best.get("d_to_e", "—")}%。'
-            f'本月表现与最优水平对比,可量化当前流程效率损失。'
-            f'差距大于10个百分点时,说明流程存在显著退化,需启动流程再造;'
-            f'差距小于5个百分点时,可通过微调优化逼近最优。建议每季度更新历史最优基准。'
-        )
-    else:
-        best_text = (
-            f'本月A→B及D→E转化率数据已记录,但历史最优基准尚未建立。'
-            f'建议追溯过去12个月数据,提取各阶段转化率峰值作为"历史最优"标杆。'
-            f'历史最优不仅是目标,更是证明"我们曾做到过"的证据,对团队信心建设至关重要。'
-        )
-    items.append({'title': '🏆 历史最优水平对比', 'content': best_text})
-
-    # ④ 改进路线图
-    roadmap = (
-        f'下月漏斗改进重点:'
-        f'(1)A→B转化:优化合同模板,将标准合同审批周期从5天压缩至3天;'
-        f'(2)C→D转化:建立生产进度可视化看板,让客户实时追踪车辆生产状态,减少催单焦虑;'
-        f'(3)D→E转化:财务前置介入,车辆下线前7天启动尾款提醒,而非传统下线后催收。'
-        f'每项改进需设定量化目标、责任人和验收标准,月底复盘执行效果。'
-    )
-    items.append({'title': '🛣️ 改进路线图', 'content': roadmap})
-
-    # ⑤ 资金占用与周转
-    pending_pay = metrics.get('pending_payment', {}).get('orders', 0)
-    pending_pay_qty = metrics.get('pending_payment', {}).get('qty', 0)
-    pending_ship = metrics.get('pending_shipment', {}).get('orders', 0)
-    capital = (
-        f'本月D阶段(已生产待付尾款){pending_pay}单(约{pending_pay_qty}台),是资金占用核心环节。'
-        f'假设单台均价15万,则D阶段资金占用约¥{pending_pay_qty * 15:,}万。'
-        f'若D阶段平均滞留天数超过14天,建议对超期订单启动"财务+销售"联合催收机制,'
-        f'目标是将D阶段平均周转天数压降至10天以内。'
-    )
-    items.append({'title': '💰 资金占用与周转', 'content': capital})
-
-    return items
-
-
-
-def _insight_monthly_region(metrics: dict, context: dict) -> list[dict]:
-    """Page 5: 区域战略优先级矩阵、资源投入ROI、订单结构差异"""
-    items = []
-    region_dist = metrics.get('region_dist', {})
-    prev_region_dist = context.get('prev_region_dist', {})
-
-    if not region_dist:
-        return [
-            {'title': '💡 区域战略优先级矩阵', 'content': '本月暂无区域分布数据。建议完善国家-区域映射表,以便按区域维度分析市场规模与增速矩阵。'},
-        ]
-
-    # ① 区域战略优先级矩阵(市场规模×增速)
-    regions = []
-    for name, data in region_dist.items():
-        qty = data.get('qty', 0)
-        pct = data.get('pct', 0)
-        prev_qty = prev_region_dist.get(name, {}).get('qty', 0) if prev_region_dist else 0
-        growth = _pct_change(qty, prev_qty)
-        regions.append((name, qty, pct, growth))
-
-    stars = [r for r in regions if r[2] > 20 and (r[3] is None or r[3] > 0)]
-    cows = [r for r in regions if r[2] > 20 and (r[3] is not None and r[3] <= 0)]
-    questions = [r for r in regions if r[2] <= 20 and (r[3] is None or r[3] > 0)]
-    dogs = [r for r in regions if r[2] <= 20 and (r[3] is not None and r[3] <= 0)]
-
-    matrix = (
-        f'基于"市场规模×增速"四象限分析:'
-        f'{"明星(高规模+高增长):" + "、".join([r[0] for r in stars[:2]]) + ",应追加资源扩大优势;" if stars else ""}'
-        f'{"现金牛(高规模+低增长):" + "、".join([r[0] for r in cows[:2]]) + ",维持投入收割利润;" if cows else ""}'
-        f'{"问题(低规模+高增长):" + "、".join([r[0] for r in questions[:2]]) + ",需判断是真潜力还是虚假繁荣;" if questions else ""}'
-        f'{"瘦狗(低规模+低增长):" + "、".join([r[0] for r in dogs[:2]]) + ",收缩资源或调整模式。" if dogs else ""}'
-    )
-    items.append({'title': '💡 区域战略优先级矩阵', 'content': matrix})
-
-    # ② 资源投入ROI
-    roi_text = (
-        f'区域ROI评估应超越订单量,引入利润率、售后成本、物流复杂度三维度。'
-        f'例如:某区域订单量大但物流成本占比高(偏远国/内陆国),其真实ROI可能低于订单量小的近海区域。'
-        f'建议每季度输出区域损益表,按"净利润=订单毛利-物流成本-售后成本-人力分摊"公式计算,'
-        f'将资源向高ROI区域倾斜,低ROI区域探索代理商模式降低成本。'
-    )
-    items.append({'title': '💰 资源投入ROI', 'content': roi_text})
-
-    # ③ 区域间订单结构差异
-    if len(regions) >= 2:
-        r1_name, r1_data = max(region_dist.items(), key=lambda x: x[1].get('qty', 0))
-        r1_top = [c['country'] for c in r1_data.get('top_countries', [])[:2]]
-        structure = (
-            f'{r1_name}为最大区域,其Top国家{r1_top}的车型偏好可能与其他区域存在显著差异。'
-            f'例如:亚洲市场偏好紧凑型电动车,非洲市场偏好皮卡/商用车,拉美市场对SUV需求旺盛。'
-            f'区域间车型差异反映市场需求本质不同,建议按区域定制产品组合和营销话术,'
-            f'避免全球统一策略导致的水土不服。'
-        )
-    else:
-        structure = (
-            f'本月区域数据不足,无法分析订单结构差异。'
-            f'建议积累多区域数据后,按"区域×车型"交叉分析,识别区域专属需求特征。'
-        )
-    items.append({'title': '📊 区域间订单结构差异', 'content': structure})
-
-    # ④ 区域培育策略
-    if questions:
-        q_names = [r[0] for r in questions[:2]]
-        nurture = (
-            f'问题区域({"、".join(q_names)})增速快但规模小,处于市场培育期。'
-            f'培育策略:前期以"样板工程"为核心,投入1-2个标杆客户确保极致交付体验;'
-            f'中期通过客户转介绍和本地车展扩大知名度;后期引入本地代理商实现轻资产扩张。'
-            f'培育期通常需要6-12个月,需设定阶段性里程碑避免过早放弃。'
-        )
-    else:
-        nurture = (
-            f'本月暂无高增长低基数的问题区域。建议审视现有区域增速:'
-            f'若有区域增速超过50%但占比仍低于10%,应立即纳入问题区域清单并给予专项资源。'
-        )
-    items.append({'title': '🌱 区域培育策略', 'content': nurture})
-
-    return items
-
-
-
-def _insight_monthly_country(metrics: dict, context: dict) -> list[dict]:
-    """Page 6: 国家组合健康度、大客户集中度、新国家孵化、竞争态势"""
-    items = []
-    top_countries = metrics.get('top_countries', {})
-    top_countries_change = metrics.get('top_countries_change', {})
-    total_qty = metrics.get('total_qty', 0)
-
-    if not top_countries:
-        return [
-            {'title': '💡 国家组合健康度', 'content': '本月暂无国家分布数据。建议完善目的国家字段,以便评估国家组合健康度并制定分散策略。'},
-        ]
-
-    country_qty = {}
-    country_orders = {}
-    for c, v in top_countries.items():
-        if isinstance(v, int):
-            country_qty[c] = v
-            country_orders[c] = 0
-        else:
-            country_qty[c] = v.get('qty', 0)
-            country_orders[c] = v.get('orders', 0)
-
-    sorted_countries = sorted(country_qty.items(), key=lambda x: x[1], reverse=True)
-    top3_qty = sum(v for _, v in sorted_countries[:3])
-    top3_pct = round(top3_qty / total_qty * 100, 1) if total_qty else 0
-
-    # ① 国家组合健康度评分
-    health_score = 100
-    if top3_pct > 50:
-        health_score -= 30
-    elif top3_pct > 40:
-        health_score -= 15
-    if len(sorted_countries) < 5:
-        health_score -= 20
-    health = (
-        f'国家组合健康度评分{health_score}/100(Top 3集中度{top3_pct}%、覆盖国家数{len(sorted_countries)})。'
-        f'{"评分优秀,国家分布均衡,抗风险能力强" if health_score >= 85 else ""}'
-        f'{"评分良好,存在优化空间,建议培育第二梯队" if 70 <= health_score < 85 else ""}'
-        f'{"评分偏低,头部依赖严重或覆盖不足,需立即启动分散计划" if health_score < 70 else ""}'
-        f'。评分规则:Top 3集中度每超10%扣15分,覆盖国家不足5个扣20分。'
-    )
-    items.append({'title': '💡 国家组合健康度评分', 'content': health})
-
-    # ② 大客户集中度风险
-    top1_country, top1_qty = sorted_countries[0]
-    top1_orders = country_orders.get(top1_country, 0)
-    risk = (
-        f'{top1_country}本月{top1_qty}台({top1_orders}单)领跑。'
-        f'若该国最大单一客户占比超过50%,则触发红色预警:该客户流失将导致月度目标缺口{round(top1_qty * 0.5)}台。'
-        f'建议对该国客户结构进行"金字塔"分层:底部散户(小于5台)占60%、腰部客户(5-20台)占30%、顶部大客户(大于20台)不超过10%,'
-        f'确保任何单一客户流失不会动摇基本盘。'
-    )
-    items.append({'title': '👤 大客户集中度风险', 'content': risk})
-
-    # ③ 新国家孵化进展
-    new_countries = context.get('new_countries_this_month', [])
-    if new_countries:
-        new_text = (
-            f'本月新开拓国家:{"、".join(new_countries[:5])}。'
-            f'新国家首单是0到1的突破,意义重大但风险较高。建议对新国家实施"护航计划":'
-            f'首单交付全程由资深负责人跟进,确保零差错;交付后30天内回访客户,收集反馈并建立口碑案例;'
-            f'若首单反馈良好,第2-3单可逐步放宽管控,交由本地负责人常规跟进。'
-        )
-    else:
-        new_text = (
-            f'本月无新国家突破。建议审视"新国家孵化 pipeline":是否有正在洽谈的潜在市场?'
-            f'若连续3个月无新国家,需反思是市场选择过于保守还是孵化流程存在断点。'
-            f'建议每季度设定"新国家突破"为团队OKR之一,激励市场拓展。'
-        )
-    items.append({'title': '🌱 新国家孵化进展', 'content': new_text})
-
-    # ④ 竞争态势
-    competitive = (
-        f'竞争态势监测建议:对Top 3国家建立"竞品对标"机制,跟踪维度包括:'
-        f'(1)价格带:竞品是否发起价格战、促销力度如何;'
-        f'(2)交付周期:竞品从签约到发运的时长是否短于我方;'
-        f'(3)服务网络:竞品是否在当地设立配件仓或服务站。'
-        f'本月若某国订单增速放缓但询盘量上升,可能是竞品截流信号,需立即调整策略。'
-    )
-    items.append({'title': '🏁 竞争态势', 'content': competitive})
-
-    return items
-
-
-
-def _insight_monthly_trend(metrics: dict, context: dict) -> list[dict]:
-    """Page 7: 阶段性特征、异常波动归因、外部因素、下月预测"""
-    items = []
-    trend_by_period = metrics.get('trend_by_period', {})
-    daily_trend = metrics.get('daily_trend', {})
-    peak_dates = metrics.get('peak_dates', [])
-    forecast_next = metrics.get('forecast_next', 0)
-    total_qty = metrics.get('total_qty', 0)
-    total_contracts = metrics.get('total_contracts', 0)
-
-    # ① 阶段性特征
-    if trend_by_period:
-        early = trend_by_period.get('early', 0)
-        mid = trend_by_period.get('mid', 0)
-        late = trend_by_period.get('late', 0)
-        late_chg = trend_by_period.get('late_change_pct')
-        phase = (
-            f'本月三旬日均:上旬{early:.1f}单、中旬{mid:.1f}单、下旬{late:.1f}单。'
-            f'{"上旬开门红,开局强势" if early > mid and early > late else ""}'
-            f'{"中旬平台期,需警惕懈怠" if mid < early * 0.8 and mid < late * 0.8 else ""}'
-            f'{"下旬冲刺,收官能力强" if late > early and late > mid else ""}'
-            f'下旬较中旬{_fmt_chg_dir(late_chg)}{_fmt_pct(late_chg)}。'
-            f'阶段性特征反映了团队的节奏控制能力,理想状态为"高开稳走",避免过度依赖月末冲刺。'
-        )
-    else:
-        phase = (
-            f'本月累计{total_contracts}单,缺乏上中下旬分旬数据。'
-            f'建议将月度按自然旬拆分,识别"月初开门红/月中平台期/月末冲刺"的典型模式。'
-            f'阶段特征分析有助于优化资源投放时点:上旬重签约、中旬重生产、下旬重交付。'
-        )
-    items.append({'title': '📅 阶段性特征', 'content': phase})
-
-    # ② 异常波动事件归因
-    if daily_trend and peak_dates:
-        peak_vals = [daily_trend.get(d, 0) for d in peak_dates]
-        avg_val = sum(daily_trend.values()) / len(daily_trend)
-        anomaly = (
-            f'本月峰值日:{"、".join(peak_dates)}({"、".join([str(v) for v in peak_vals])}单),'
-            f'分别为均值的{_safe_div(max(peak_vals), avg_val):.1f}倍。'
-            f'峰值日需区分"机会型"(大客户集中下单/展会签约)与"补录型"(历史订单系统批量导入)。'
-            f'建议对峰值日逐单标注事件类型,长期积累后可识别真正的业务脉冲与数据噪声。'
-        )
-    else:
-        anomaly = (
-            f'本月缺乏分日峰值数据,无法识别异常波动事件。'
-            f'建议建立日报机制后,采用"均值+2倍标准差"规则自动标记异常日,并要求负责人提交事件说明。'
-        )
-    items.append({'title': '⚠️ 异常波动事件归因', 'content': anomaly})
-
-    # ③ 外部因素关联
-    fx_change = context.get('fx_change_pct')
-    policy_events = context.get('policy_events', [])
-    shipping_delay = context.get('shipping_delay_days', 0)
-    external = (
-        f'外部因素评估:'
-        f'{"汇率变动" + _fmt_pct(fx_change) + ",对出口报价竞争力产生" + ("正面" if (fx_change or 0) < 0 else "负面") + "影响;" if fx_change is not None else "暂无汇率数据;"}'
-        f'{"本月政策事件:" + "、".join(policy_events[:2]) + ";" if policy_events else "暂无重大政策事件;"}'
-        f'{"船期平均延误" + str(shipping_delay) + "天,可能滞后影响客户下单信心" if shipping_delay else "船期正常"}。'
-        f'外部因素与订单波动存在1-4周滞后,建议建立外部因素登记簿,量化其对后续月份的影响。'
-    )
-    items.append({'title': '🌐 外部因素关联', 'content': external})
-
-    # ④ 下月预测
-    mom_pct = _pct_change(total_contracts, metrics.get('prev_total_contracts', 0))
-    if forecast_next:
-        forecast = (
-            f'Pipeline预测下月交付{forecast_next}台,结合本月{total_contracts}单(MoM {_fmt_pct(mom_pct)}),'
-            f'下月订单量预计维持在{int(total_contracts * 0.9)}-{int(total_contracts * 1.15)}单区间。'
-            f'{"若外部利好持续,有望突破区间上限" if (mom_pct or 0) > 10 else ""}'
-            f'{"若趋势承压,可能回落至区间下限,需提前储备补单方案" if (mom_pct or 0) < 0 else ""}'
-            f'。预测准确性应每月复盘,偏差超过20%时校准预测模型参数。'
-        )
-    else:
-        forecast = (
-            f'本月完成{total_contracts}单,缺乏pipeline预测数据。'
-            f'基于历史趋势,下月预计{int(total_contracts * 0.9)}-{int(total_contracts * 1.1)}单。'
-            f'建议建立A+B阶段存量订单与下月签约的转化模型,提升预测准确性。'
-        )
-    items.append({'title': '🔮 下月预测', 'content': forecast})
-
-    # ⑤ 趋势持续性信号
-    sustain = (
-        f'判断下月趋势持续性的三个信号:'
-        f'(1)早期pipeline:A+B阶段存量是否充足,能否支撑下月转化;'
-        f'(2)客户活跃度:本月更新进度订单数是否维持高位,反映客户 engagement;'
-        f'(3)支持需求趋势:若支持需求逐周下降,说明流程顺畅,订单转化阻力减小。'
-        f'三个信号中有两个向好,则下月趋势延续概率大于70%。'
-    )
-    items.append({'title': '📊 趋势持续性信号', 'content': sustain})
-
-    return items
-
-
-
-def _insight_monthly_team(metrics: dict, context: dict) -> list[dict]:
-    """Page 8: 人均效能、团队结构优化、激励效果、下月人员配置"""
-    items = []
-    team = metrics.get('team', {})
-    per_capita_orders = metrics.get('per_capita_orders', 0)
-    per_capita_qty = metrics.get('per_capita_qty', 0)
-    total_contracts = metrics.get('total_contracts', 0)
-    total_qty = metrics.get('total_qty', 0)
-
-    if isinstance(team, dict) and 'owners' in team:
-        owners = team['owners']
-        qty_map = team.get('qty', {})
-    else:
-        owners = {k: v.get('orders', 0) for k, v in team.items()} if team else {}
-        qty_map = {k: v.get('qty', 0) for k, v in team.items()} if team else {}
-
-    if not owners:
-        return [
-            {'title': '💡 人均效能趋势', 'content': '本月暂无负责人数据。建议完善订单归属字段,以便评估团队人效并优化配置。'},
-        ]
-
-    n_members = len(owners)
-    sorted_owners = sorted(owners.items(), key=lambda x: x[1], reverse=True)
-    top_owner, top_val = sorted_owners[0]
-    prev_per_capita = context.get('prev_per_capita_orders', 0)
-    yoy_per_capita = context.get('yoy_per_capita_orders', 0)
-
-    # ① 人均效能趋势
-    mom_chg = _pct_change(per_capita_orders, prev_per_capita)
-    yoy_chg = _pct_change(per_capita_orders, yoy_per_capita)
-    efficiency = (
-        f'本月人均{per_capita_orders:.1f}单({per_capita_qty:.0f}台),'
-        f'环比{_fmt_chg_dir(mom_chg)}{_fmt_pct(mom_chg)},同比{_fmt_chg_dir(yoy_chg)}{_fmt_pct(yoy_chg)}。'
-        f'{"人均效能双升,团队能力在积累" if (mom_chg or 0) > 0 and (yoy_chg or 0) > 0 else ""}'
-        f'{"环比升但同比降,说明短期改善但尚未恢复历史水平" if (mom_chg or 0) > 0 and (yoy_chg or 0) < 0 else ""}'
-        f'{"人均效能承压,需排查是市场总量下降还是团队效率退化" if (mom_chg or 0) < 0 and (yoy_chg or 0) < 0 else ""}'
-        f'。人均效能是团队健康度的核心指标,建议纳入月度考核并与激励挂钩。'
-    )
-    items.append({'title': '💡 人均效能趋势', 'content': efficiency})
-
-    # ② 团队结构优化建议
-    tail_count = sum(1 for _, v in sorted_owners if v < per_capita_orders * 0.5)
-    structure = (
-        f'本月团队{n_members}人,尾部{tail_count}人产出低于人均50%。'
-        f'{"尾部占比高,团队呈金字塔结构,需加强腰部建设" if tail_count > n_members // 3 else "尾部占比低,团队呈橄榄型,结构健康"}。'
-        f'优化建议:'
-        f'(1)低产出人员:分析是能力问题(培训)还是资源问题(客户/国家重新分配);'
-        f'(2)高产出人员:防止过度依赖,建立AB角备份;'
-        f'(3)新入职人员:前3个月以保护期为主,第4个月起按正常人均考核。'
-    )
-    items.append({'title': '⚖️ 团队结构优化建议', 'content': structure})
-
-    # ③ 激励效果评估
-    incentive_effect = context.get('incentive_effect', '')
-    if incentive_effect:
-        incentive = (
-            f'本月激励政策效果:{incentive_effect}。'
-            f'激励效果评估应区分"增量激励"(新签奖励)与"存量激励"(转化奖励),'
-            f'避免团队为拿新签奖而忽视存量转化。建议设置激励上限和平衡系数,'
-            f'确保短期激励与长期客户价值不冲突。'
-        )
-    else:
-        incentive = (
-            f'本月暂无激励效果量化数据。建议下月实施激励政策时,同步记录政策前后的人均产出变化。'
-            f'激励效果=(政策后人均产出-政策前人均产出)/激励总成本,ROI大于1.5说明激励有效。'
-        )
-    items.append({'title': '🏆 激励效果评估', 'content': incentive})
-
-    # ④ 标杆对比与提升路径
-    top_gap = top_val - per_capita_orders if per_capita_orders else 0
-    best_practice = (
-        f'本月团队领跑者{top_owner}完成{top_val}单,超人均{top_gap:.1f}单,是均值{_safe_div(top_val, per_capita_orders) if per_capita_orders else 0:.1f}倍。'
-        f'建议将其客户跟进SOP、谈判策略、响应时效提炼为标准流程,通过"老带新"机制复制到全团队。'
-        f'同时建立月度技能分享会,让头部负责人分享成交案例和失败教训,缩短新人成长周期。'
-        f'对于连续两月低于人均50%的成员,启动一对一辅导计划,30天内无明显改善则考虑调岗。'
-    )
-    items.append({'title': '🎯 标杆对比与提升路径', 'content': best_practice})
-
-    # ⑤ 下月人员配置
-    next_month_target = context.get('next_month_target', 0)
-    if next_month_target and per_capita_orders:
-        required = int(next_month_target / per_capita_orders)
-        gap = required - n_members
-        staffing = (
-            f'下月目标{next_month_target}单,按本月人均{per_capita_orders:.1f}单计算,需{required}人,'
-            f'当前{n_members}人,{"缺口" + str(gap) + "人,建议启动招聘或内部调配" if gap > 0 else "冗余" + str(abs(gap)) + "人,可优化至其他业务线" if gap < 0 else "刚好匹配"}。'
-            f'若市场处于上升期,建议按目标人数的110%配置,预留20%冗余应对突发需求。'
-        )
-    else:
-        staffing = (
-            f'下月人员配置建议:维持现有{n_members}人编制,重点优化结构而非单纯扩编。'
-            f'若人均产出连续两月下滑,说明市场或团队出现问题,此时扩编只会稀释效率;'
-            f'若人均产出持续上升且超负荷,则扩编是必要且紧迫的。'
-        )
-    items.append({'title': '👥 下月人员配置', 'content': staffing})
-
-    return items
-
-
-
-def _insight_monthly_support(metrics: dict, context: dict) -> list[dict]:
-    """Page 9: 需求类型趋势、高频问题根因、流程优化、SLA达成率"""
-    items = []
-    support_categories = metrics.get('support_categories', {})
-    support_count = metrics.get('support_count', 0)
-    support_pct = metrics.get('support_pct', 0)
-    prev_support_categories = context.get('prev_support_categories', {})
-
-    # ① 需求类型趋势
-    if support_categories and prev_support_categories:
-        trends = []
-        for cat, count in support_categories.items():
-            prev = prev_support_categories.get(cat, 0)
-            chg = _pct_change(count, prev)
-            trends.append((cat, chg))
-        trends.sort(key=lambda x: (x[1] or 0), reverse=True)
-        fastest = trends[0] if trends else (None, None)
-        trend_text = (
-            f'本月支持需求共{support_count}项(占订单{support_pct}%)。'
-            f'增长最快类别为"{fastest[0]}"({_fmt_pct(fastest[1])}),'
-            f'{"说明该领域存在系统性短板,需优先根治" if (fastest[1] or 0) > 50 else ""}'
-            f'{"增速可控,按常规节奏优化即可" if (fastest[1] or 0) <= 50 else ""}'
-            f'。建议每月输出支持需求趋势图,识别"慢性增长"与"急性爆发"两类问题,分别用流程优化和应急响应处理。'
-        )
-    else:
-        trend_text = (
-            f'本月支持需求共{support_count}项(占订单{support_pct}%)。'
-            f'缺乏上月分类数据,无法计算各类别增速。建议建立支持需求分类台账,'
-            f'按财务/法务/物流/售后/IT五大类归档,每月对比识别增长最快的类别。'
-        )
-    items.append({'title': '📈 需求类型趋势', 'content': trend_text})
-
-    # ② 高频问题根因
-    if support_categories:
-        top3 = sorted(support_categories.items(), key=lambda x: x[1], reverse=True)[:3]
-        root = (
-            f'本月Top 3高频问题:{"、".join([f"{c}({v}项)" for c, v in top3])}。'
-            f'高频问题的根因通常可归结为三类:'
-            f'(1)流程断点:跨部门协作无明确SLA,导致需求在部门间空转;'
-            f'(2)信息孤岛:客户/订单/物流数据分散,重复查询浪费人力;'
-            f'(3)能力短板:一线人员对产品/政策理解不足,过度依赖支持部门。'
-            f'建议对Top 3问题各做一次5Why分析,找到可系统改进的根因。'
-        )
-    else:
-        root = (
-            f'本月无支持需求分类数据。建议强制要求提交支持需求时选择类别并简述根因,'
-            f'否则不予处理。数据质量是分析的前提,没有分类的数据无法产生洞察。'
-        )
-    items.append({'title': '🔍 高频问题根因', 'content': root})
-
-    # ③ 流程优化建议
-    optimization = (
-        f'流程优化建议:'
-        f'(1)自助化:将Top 20%高频问题转化为FAQ或系统自助查询,减少人工支持需求;'
-        f'(2)前置化:在订单关键节点(如合同锁定/生产完成)自动触发检查清单,提前消除潜在问题;'
-        f'(3)标准化:对剩余80%中频问题建立SOP和处理模板,将平均处理时长压缩50%。'
-        f'优化效果应每月量化:支持需求总量是否下降、重复问题占比是否降低、客户满意度是否提升。'
-    )
-    items.append({'title': '⚙️ 流程优化建议', 'content': optimization})
-
-    # ④ SLA达成率
-    sla_data = context.get('sla_achievement', {})
-    if sla_data:
-        overall = sla_data.get('overall', 0)
-        sla_text = (
-            f'本月支持需求SLA达成率{overall}%。'
-            f'{"达成率大于90%,响应速度优秀" if overall > 90 else "达成率80%-90%,基本达标但存在超期" if overall >= 80 else "达成率低于80%,响应速度严重滞后,需立即增派人手或优化流程"}。'
-            f'分类达成率:{"、".join([f"{k}:{v}%" for k, v in sla_data.items() if k != "overall"][:3])}。'
-            f'低于目标的类别应作为下月改进重点,分配专项资源提升。'
-        )
-    else:
-        sla_text = (
-            f'本月暂无SLA达成率数据。建议为每类支持需求设定处理时限:'
-            f'紧急(4h)、高(24h)、中(48h)、低(72h),并记录实际关闭时间。'
-            f'SLA是支持部门的服务承诺,也是内部客户体验的核心指标。'
-        )
-    items.append({'title': '⏱️ SLA达成率', 'content': sla_text})
-
-    return items
-
-
-
-def _insight_monthly_plan(metrics: dict, context: dict) -> list[dict]:
-    """Page 10: 目标可行性分析、关键假设、风险场景、Contingency Plan"""
-    items = []
-    next_month_goals = metrics.get('next_month_goals', [])
-    total_qty = metrics.get('total_qty', 0)
-    risks = metrics.get('risks', [])
-    forecast_next = metrics.get('forecast_next', 0)
-
-    # ① 目标可行性分析
-    if next_month_goals:
-        goal_total = sum(g.get('number', 0) for g in next_month_goals)
-        feasibility = (
-            f'下月目标共{len(next_month_goals)}项,量化指标合计{goal_total}。'
-            f'基于本月{total_qty}台及pipeline预测{forecast_next}台,目标可行性{"高" if forecast_next >= goal_total * 0.8 else "中" if forecast_next >= goal_total * 0.5 else "低"}。'
-            f'{"Pipeline充足,目标具备充分支撑" if forecast_next >= goal_total else "Pipeline低于目标,需额外拓展新单填补缺口"}。'
-            f'可行性分析应每月更新,若连续两月可行性评级为低,需下调目标或追加资源。'
-        )
-    else:
-        feasibility = (
-            f'下月目标尚未设定。建议基于本月{total_qty}台和pipeline{forecast_next}台,'
-            f'按"保底(本月×0.9)/基准(本月×1.0)/挑战(本月×1.2)"三档设定目标。'
-            f'三档目标分别对应不同的资源投入和激励方案,给团队明确的方向感。'
-        )
-    items.append({'title': '🎯 目标可行性分析', 'content': feasibility})
-
-    # ② 关键假设
-    assumptions = context.get('key_assumptions', [])
-    if assumptions:
-        assump_text = (
-            f'下月目标达成的关键假设:{"、".join(assumptions[:3])}。'
-            f'关键假设是目标可行性的前提条件,任一假设失效都可能导致目标无法达成。'
-            f'建议每周Review假设状态:绿色(稳定)、黄色(波动)、红色(失效),红色假设需在48h内启动应对预案。'
-        )
-    else:
-        assump_text = (
-            f'下月目标的关键假设尚未明确。建议至少列出3个最关键假设:'
-            f'(1)大客户复购率维持本月水平;'
-            f'(2)主要船期无大面积延误;'
-            f'(3)汇率波动不超过5%。'
-            f'假设清单是风险管理的起点,没有假设的目标只是愿望。'
-        )
-    items.append({'title': '🔑 关键假设', 'content': assump_text})
-
-    # ③ 风险场景(最坏情况)
-    if risks:
-        worst_gap = context.get('worst_case_gap', 0)
-        risk_text = (
-            f'已识别风险:{"、".join([r.get("title", "") for r in risks[:3]])}。'
-            f'最坏情况下,预计月度缺口约{worst_gap}台。'
-            f'缺口弥补方案:(1)加速A→B转化,释放存量pipeline;(2)启动紧急促销,刺激短期下单;'
-            f'(3)协调生产加班,压缩交付周期以提升客户信心。'
-            f'风险场景应每两周更新一次,确保预案与市场变化同步。'
-        )
-    else:
-        risk_text = (
-            f'本月风险清单为空。建议基于历史数据和当前pipeline,推演3个最坏场景:'
-            f'(1)Top 1客户延迟下单,缺口30%;(2)船期延误2周,E阶段积压导致客户暂停新签;'
-            f'(3)目的国进口政策突变,已锁定合同无法执行。每个场景设定触发条件和应对动作。'
-        )
-    items.append({'title': '⚠️ 风险场景(最坏情况)', 'content': risk_text})
-
-    # ④ Contingency Plan
-    contingency = (
-        f'Contingency Plan原则:当实际进度低于目标70%或关键假设失效时,自动触发应急预案。'
-        f'预案内容包括:'
-        f'(1)资源重新配置:将低效区域人力调至高效区域;'
-        f'(2)目标动态调整:按"保利润/保现金流/保市场份额"优先级重新排序;'
-        f'(3)外部资源调用:启动代理商紧急补单、申请总部促销资源、协调备用物流商。'
-        f'预案需在月初制定并获管理层批准,确保危机发生时24h内可执行。'
-    )
-    items.append({'title': '🛡️ Contingency Plan', 'content': contingency})
-
-    # ⑤ 里程碑与复盘机制
-    milestones = context.get('monthly_milestones', [])
-    if milestones:
-        mile_text = (
-            f'下月关键里程碑:{"、".join(milestones[:3])}。'
-            f'里程碑应满足可验证性:有明确交付物、验收人和截止时间。'
-            f'建议建立"双周复盘"机制:每月15日和月底对照里程碑,偏差大于20%时触发专项调整。'
-        )
-    else:
-        mile_text = (
-            f'下月里程碑尚未设定。建议按"第一周签约冲刺、第二周生产锁定、第三周尾款回收、第四周交付收官"'
-            f'设置4个周里程碑,每个里程碑有量化指标和责任人。没有里程碑的月度计划只是方向,不是计划。'
-        )
-    items.append({'title': '🚩 里程碑与复盘机制', 'content': mile_text})
-
-    return items
-
-
-# =============================================================================
-# HELPER FUNCTIONS
-# =============================================================================
-
-def _pct_change(curr, prev):
-    if prev and prev != 0:
-        return round((curr - prev) / prev * 100, 1)
-    return None
-
-
-def _fmt_pct(val):
-    if val is None:
-        return '—'
-    sign = '+' if val >= 0 else ''
-    return f'{sign}{val:.1f}%'
-
-
-def _fmt_chg_dir(val):
-    if val is None:
-        return ''
-    return '增加' if val >= 0 else '减少'
-
-
-def _safe_div(a, b):
-    return round(a / b, 1) if b else 0

Dosya farkı çok büyük olduğundan ihmal edildi
+ 2 - 1101
generate-data-report-ppt/scripts/metrics_calculator.py


+ 115 - 48
generate-data-report-ppt/scripts/page_layouts.py

@@ -1,13 +1,18 @@
 """
 Dynamic page layout engine for the universal data report generator.
 Provides pre-defined layout templates and layout calculation utilities.
+Supports dynamic slide dimensions via LayoutContext for custom templates.
 """
 from pptx.util import Emu, Pt
 from pptx.dml.color import RGBColor
-from dataclasses import dataclass
+from dataclasses import dataclass, field
 from typing import Optional
 
 
+# ==============================================================================
+# DEFAULT CONSTANTS (backward compatible)
+# ==============================================================================
+
 SLIDE_WIDTH = 16256000
 SLIDE_HEIGHT = 9144000
 MARGIN_LEFT = Emu(762000)
@@ -19,6 +24,52 @@ FOOTER_HEIGHT = Emu(320000)
 CONTENT_WIDTH = SLIDE_WIDTH - MARGIN_LEFT - MARGIN_RIGHT
 
 
+# ==============================================================================
+# LAYOUT CONTEXT
+# ==============================================================================
+
+@dataclass
+class LayoutContext:
+    """Encapsulates slide dimensions and content geometry for a specific template."""
+    slide_width: int = SLIDE_WIDTH
+    slide_height: int = SLIDE_HEIGHT
+    content_top: int = field(default_factory=lambda: int(CONTENT_TOP_BASE))
+    footer_top: int = FOOTER_TOP
+    margin_left: int = field(default_factory=lambda: int(MARGIN_LEFT))
+    margin_right: int = field(default_factory=lambda: int(MARGIN_RIGHT))
+    margin_top: int = field(default_factory=lambda: int(MARGIN_TOP))
+
+    @property
+    def content_width(self) -> int:
+        return self.slide_width - self.margin_left - self.margin_right
+
+    @classmethod
+    def from_template_profile(cls, profile) -> 'LayoutContext':
+        """Build LayoutContext from a TemplateProfile (template_parser)."""
+        # Import here to avoid circular dependency at module load
+        from template_parser import TemplateProfile
+        if not isinstance(profile, TemplateProfile):
+            raise TypeError("profile must be a TemplateProfile instance")
+        
+        # Use content top from content master if available
+        content_top = profile.get_content_top("content")
+        margins = profile.safe_margins
+        
+        return cls(
+            slide_width=profile.slide_width,
+            slide_height=profile.slide_height,
+            content_top=content_top,
+            footer_top=profile.slide_height - int(Emu(320000)),  # default footer area
+            margin_left=margins.get("left", int(MARGIN_LEFT)),
+            margin_right=margins.get("right", int(MARGIN_RIGHT)),
+            margin_top=margins.get("top", int(MARGIN_TOP)),
+        )
+
+
+# ==============================================================================
+# LAYOUT ZONES
+# ==============================================================================
+
 @dataclass
 class LayoutZone:
     x: int
@@ -28,63 +79,75 @@ class LayoutZone:
     zone_type: str
 
 
-def calculate_content_area(content_top_emu: int = None) -> LayoutZone:
-    top = content_top_emu or int(CONTENT_TOP_BASE)
-    height = FOOTER_TOP - top - Emu(100000)
+def _resolve_ctx(ctx: Optional[LayoutContext] = None) -> LayoutContext:
+    return ctx if ctx is not None else LayoutContext()
+
+
+def calculate_content_area(content_top_emu: int = None,
+                           ctx: Optional[LayoutContext] = None) -> LayoutZone:
+    ctx = _resolve_ctx(ctx)
+    top = content_top_emu if content_top_emu is not None else ctx.content_top
+    height = ctx.footer_top - top - int(Emu(100000))
     return LayoutZone(
-        x=int(MARGIN_LEFT),
+        x=ctx.margin_left,
         y=top,
-        width=int(CONTENT_WIDTH),
-        height=int(height),
+        width=ctx.content_width,
+        height=max(int(height), int(Emu(500000))),
         zone_type='content_area',
     )
 
 
 def get_kpi_grid(content_top_emu: int = None, cols: int = 3, rows: int = 2,
                  card_width_emu: int = 4699000, card_height_emu: int = 3048000,
-                 gap_x_emu: int = 444500, gap_y_emu: int = 381000) -> list[LayoutZone]:
-    start_y = max(int(CONTENT_TOP_BASE), content_top_emu or int(CONTENT_TOP_BASE))
+                 gap_x_emu: int = 444500, gap_y_emu: int = 381000,
+                 ctx: Optional[LayoutContext] = None) -> list[LayoutZone]:
+    ctx = _resolve_ctx(ctx)
+    start_y = max(ctx.content_top, content_top_emu or ctx.content_top)
     zones = []
     for row in range(rows):
         for col in range(cols):
-            x = int(MARGIN_LEFT) + col * (card_width_emu + gap_x_emu)
+            x = ctx.margin_left + col * (card_width_emu + gap_x_emu)
             y = start_y + row * (card_height_emu + gap_y_emu)
             zones.append(LayoutZone(x=x, y=y, width=card_width_emu, height=card_height_emu, zone_type='kpi_card'))
     return zones
 
 
-def get_chart_left_zone(content_top_emu: int = None, chart_ratio: float = 0.6) -> LayoutZone:
-    content = calculate_content_area(content_top_emu)
-    chart_w = int(content.width * chart_ratio) - Emu(200000)
+def get_chart_left_zone(content_top_emu: int = None, chart_ratio: float = 0.6,
+                        ctx: Optional[LayoutContext] = None) -> LayoutZone:
+    content = calculate_content_area(content_top_emu, ctx)
+    chart_w = int(content.width * chart_ratio) - int(Emu(200000))
     return LayoutZone(
         x=content.x,
         y=content.y,
-        width=chart_w,
+        width=max(chart_w, int(Emu(1000000))),
         height=content.height,
         zone_type='chart_left',
     )
 
 
-def get_insight_right_zone(content_top_emu: int = None, chart_ratio: float = 0.6) -> LayoutZone:
-    content = calculate_content_area(content_top_emu)
+def get_insight_right_zone(content_top_emu: int = None, chart_ratio: float = 0.6,
+                           ctx: Optional[LayoutContext] = None) -> LayoutZone:
+    content = calculate_content_area(content_top_emu, ctx)
     chart_w = int(content.width * chart_ratio)
-    text_left = content.x + chart_w + Emu(200000)
+    text_left = content.x + chart_w + int(Emu(200000))
     text_w = content.x + content.width - text_left
     return LayoutZone(
         x=text_left,
         y=content.y,
-        width=text_w,
+        width=max(text_w, int(Emu(800000))),
         height=content.height,
         zone_type='insight_right',
     )
 
 
-def get_full_width_zone(content_top_emu: int = None) -> LayoutZone:
-    return calculate_content_area(content_top_emu)
+def get_full_width_zone(content_top_emu: int = None,
+                        ctx: Optional[LayoutContext] = None) -> LayoutZone:
+    return calculate_content_area(content_top_emu, ctx)
 
 
-def get_two_column_zones(content_top_emu: int = None, gap_emu: int = 381000) -> tuple[LayoutZone, LayoutZone]:
-    content = calculate_content_area(content_top_emu)
+def get_two_column_zones(content_top_emu: int = None, gap_emu: int = 381000,
+                         ctx: Optional[LayoutContext] = None) -> tuple[LayoutZone, LayoutZone]:
+    content = calculate_content_area(content_top_emu, ctx)
     half_w = (content.width - gap_emu) // 2
     left = LayoutZone(x=content.x, y=content.y, width=half_w, height=content.height, zone_type='column_left')
     right = LayoutZone(x=content.x + half_w + gap_emu, y=content.y, width=half_w, height=content.height, zone_type='column_right')
@@ -92,8 +155,9 @@ def get_two_column_zones(content_top_emu: int = None, gap_emu: int = 381000) ->
 
 
 def get_two_row_zones(content_top_emu: int = None, gap_emu: int = 381000,
-                      top_ratio: float = 0.55) -> tuple[LayoutZone, LayoutZone]:
-    content = calculate_content_area(content_top_emu)
+                      top_ratio: float = 0.55,
+                      ctx: Optional[LayoutContext] = None) -> tuple[LayoutZone, LayoutZone]:
+    content = calculate_content_area(content_top_emu, ctx)
     top_h = int(content.height * top_ratio)
     top = LayoutZone(x=content.x, y=content.y, width=content.width, height=top_h, zone_type='row_top')
     bottom = LayoutZone(
@@ -106,34 +170,34 @@ def get_two_row_zones(content_top_emu: int = None, gap_emu: int = 381000,
     return top, bottom
 
 
-def get_card_grid(n: int, content_top_emu: int = None, max_cols: int = 3) -> list[LayoutZone]:
-    content = calculate_content_area(content_top_emu)
+def get_card_grid(n: int, content_top_emu: int = None, max_cols: int = 3,
+                  ctx: Optional[LayoutContext] = None) -> list[LayoutZone]:
+    content = calculate_content_area(content_top_emu, ctx)
     cols = min(max_cols, n)
     rows = (n + cols - 1) // cols
-    card_w = (content.width - (cols - 1) * Emu(254000)) // cols
-    card_h = (content.height - (rows - 1) * Emu(254000)) // rows
+    card_w = (content.width - (cols - 1) * int(Emu(254000))) // cols
+    card_h = (content.height - (rows - 1) * int(Emu(254000))) // rows
 
     zones = []
     for i in range(n):
         col = i % cols
         row = i // cols
-        x = content.x + col * (card_w + Emu(254000))
-        y = content.y + row * (card_h + Emu(254000))
+        x = content.x + col * (card_w + int(Emu(254000)))
+        y = content.y + row * (card_h + int(Emu(254000)))
         zones.append(LayoutZone(x=x, y=y, width=card_w, height=card_h, zone_type=f'card_{i}'))
     return zones
 
 
-def get_alert_card_zones(n: int, content_top_emu: int = None) -> list[LayoutZone]:
-    content = calculate_content_area(content_top_emu)
-    card_h = Emu(2286000)
-    gap = Emu(254000)
-    return get_card_grid(n, content_top_emu, max_cols=3)
+def get_alert_card_zones(n: int, content_top_emu: int = None,
+                         ctx: Optional[LayoutContext] = None) -> list[LayoutZone]:
+    return get_card_grid(n, content_top_emu, max_cols=3, ctx=ctx)
 
 
-def get_issue_card_zones(n: int, content_top_emu: int = None) -> list[LayoutZone]:
-    content = calculate_content_area(content_top_emu)
-    card_h = Emu(2032000)
-    gap = Emu(254000)
+def get_issue_card_zones(n: int, content_top_emu: int = None,
+                         ctx: Optional[LayoutContext] = None) -> list[LayoutZone]:
+    content = calculate_content_area(content_top_emu, ctx)
+    card_h = int(Emu(2032000))
+    gap = int(Emu(254000))
     start_y = content.y
     zones = []
     for i in range(min(n, 3)):
@@ -142,25 +206,27 @@ def get_issue_card_zones(n: int, content_top_emu: int = None) -> list[LayoutZone
     return zones
 
 
-def get_table_zone(content_top_emu: int = None, ratio: float = 0.5) -> LayoutZone:
-    content = calculate_content_area(content_top_emu)
+def get_table_zone(content_top_emu: int = None, ratio: float = 0.5,
+                   ctx: Optional[LayoutContext] = None) -> LayoutZone:
+    content = calculate_content_area(content_top_emu, ctx)
     return LayoutZone(
         x=content.x,
-        y=content.y + int(content.height * ratio) + Emu(200000),
+        y=content.y + int(content.height * ratio) + int(Emu(200000)),
         width=content.width,
         height=int(content.height * (1 - ratio)),
         zone_type='table_bottom',
     )
 
 
-def detect_layout_slots(slide) -> dict:
+def detect_layout_slots(slide, ctx: Optional[LayoutContext] = None) -> dict:
+    ctx = _resolve_ctx(ctx)
     slots = {
         'has_header': False,
         'has_footer': False,
         'has_page_title': False,
-        'content_top': int(CONTENT_TOP_BASE),
-        'content_width': int(CONTENT_WIDTH),
-        'content_height': FOOTER_TOP - int(CONTENT_TOP_BASE) - Emu(100000),
+        'content_top': ctx.content_top,
+        'content_width': ctx.content_width,
+        'content_height': ctx.footer_top - ctx.content_top - int(Emu(100000)),
     }
     for shape in slide.shapes:
         if shape.has_text_frame:
@@ -193,8 +259,9 @@ def ensure_safe_position(shape, slide_width: int, slide_height: int) -> bool:
     return adjusted
 
 
-def calculate_fill_ratio(slide, content_top_emu: int = None) -> float:
-    content = calculate_content_area(content_top_emu)
+def calculate_fill_ratio(slide, content_top_emu: int = None,
+                         ctx: Optional[LayoutContext] = None) -> float:
+    content = calculate_content_area(content_top_emu, ctx)
     total_area = content.width * content.height
     if total_area <= 0:
         return 0.0

Dosya farkı çok büyük olduğundan ihmal edildi
+ 427 - 349
generate-data-report-ppt/scripts/ppt_builder.py


+ 139 - 4
generate-data-report-ppt/scripts/quality_inspector.py

@@ -48,8 +48,9 @@ class QualityIssue:
 
 
 class QualityInspector:
-    def __init__(self, theme_colors: dict = None):
+    def __init__(self, theme_colors: dict = None, layout_context=None):
         self.theme_colors = theme_colors or {}
+        self.layout_context = layout_context
         self.fix_count = 0
         self.fix_log = []
 
@@ -388,10 +389,16 @@ class QualityInspector:
         return issues
 
     def _check_content(self, slide, page_idx, config, prs, page_type='content') -> list[QualityIssue]:
+        # Resolve dynamic content top from layout context if available
+        content_top_emu = None
+        if self.layout_context:
+            content_top_emu = self.layout_context.content_top
         issues = []
 
         if page_type in ('cover', 'end'):
             issues += self._check_text_overflow(slide, page_idx)
+            if page_type == 'cover':
+                issues += self._check_cover_quality(slide, page_idx)
             return issues
 
         issues += self._check_dynamic_page_fit(page_idx, page_type, config)
@@ -411,7 +418,7 @@ class QualityInspector:
                     'C008', False, {'type': 'empty_page'}))
             return issues
 
-        fill_ratio = calculate_fill_ratio(slide)
+        fill_ratio = calculate_fill_ratio(slide, content_top_emu=content_top_emu)
 
         if page_type in ('kpi_overview', 'trend', 'distribution', 'ranking', 'summary') or page_type in FORECAST_PAGE_TYPES:
             if fill_ratio < FILL_RATIO_THRESHOLDS['sparse']:
@@ -503,6 +510,92 @@ class QualityInspector:
 
         return issues
 
+    # ---- Cover page quality checks (V006, C012) ----
+    # These catch the most common python-pptx template pitfalls before the
+    # user sees the output: white text on white background, and unfilled
+    # template default text in placeholders.
+
+    _COVER_TEMPLATE_DEFAULT_PATTERNS = [
+        '单击此处编辑母版标题样式', '单击此处添加标题',
+        '单击此处编辑母版文本样式', '单击此处添加文本',
+        '单击此处添加副标题',
+    ]
+
+    # Light/pale colors that are invisible on white backgrounds
+    _LIGHT_COLOR_THRESHOLD = 0xCC  # RGB channels above this = "very light"
+
+    def _check_cover_quality(self, slide, page_idx) -> list[QualityIssue]:
+        """Check cover page for common template rendering issues.
+
+        V006: placeholder text is white/light but positioned on light
+              background (e.g. below a colored banner). Auto-fixable.
+        C012: placeholder still contains template default text like
+              "单击此处编辑母版标题样式". Not auto-fixable — needs rebuild.
+        """
+        issues = []
+        slide_h = int(slide.slide_height) if hasattr(slide, 'slide_height') else SLIDE_HEIGHT
+
+        # Detect the approximate end of the colored banner area.
+        # Heuristic: find the tallest filled rectangle that starts at y=0.
+        banner_bottom = 0
+        for shape in slide.shapes:
+            try:
+                sy = int(shape.top)
+                sh = int(shape.height)
+                if sy < Emu(50000):  # starts near top
+                    if sh < slide_h * 0.7:  # not full-slide background
+                        banner_bottom = max(banner_bottom, sy + sh)
+            except Exception:
+                pass
+
+        for shape in slide.shapes:
+            if not shape.is_placeholder or not shape.has_text_frame:
+                continue
+
+            tf = shape.text_frame
+            text = tf.text.strip()
+
+            # --- C012: template default text ---
+            if text and any(p in text for p in self._COVER_TEMPLATE_DEFAULT_PATTERNS):
+                issues.append(QualityIssue(
+                    'critical', 'content', page_idx,
+                    f'封面占位符仍为模板默认文字 "{text[:30]}",idx={shape.placeholder_format.idx}',
+                    'C012', False,
+                    {'type': 'cover_template_text', 'shape': shape}
+                ))
+                continue
+
+            # --- V006: white/light text on light background ---
+            if text and banner_bottom > 0:
+                try:
+                    sy = int(shape.top)
+                except Exception:
+                    continue
+
+                # Only flag text BELOW the banner (on white area)
+                if sy < banner_bottom:
+                    continue
+
+                # Check if text color is very light / near-white
+                for para in tf.paragraphs:
+                    for run in para.runs:
+                        if run.font.color and run.font.color.rgb:
+                            rgb = run.font.color.rgb
+                            if (int(str(rgb)[:2], 16) >= self._LIGHT_COLOR_THRESHOLD and
+                                int(str(rgb)[2:4], 16) >= self._LIGHT_COLOR_THRESHOLD and
+                                int(str(rgb)[4:6], 16) >= self._LIGHT_COLOR_THRESHOLD):
+                                issues.append(QualityIssue(
+                                    'critical', 'visual', page_idx,
+                                    f'封面文字 "{text[:20]}" 颜色过浅 (#{rgb}) '
+                                    f'位于白色背景区域(>y={banner_bottom})将不可见',
+                                    'V006', True,
+                                    {'type': 'cover_text_invisible', 'shape': shape,
+                                     'banner_bottom': banner_bottom}
+                                ))
+                                break
+
+        return issues
+
     def _check_text_overflow(self, slide, page_idx) -> list[QualityIssue]:
         issues = []
         for shape in slide.shapes:
@@ -615,8 +708,41 @@ class QualityInspector:
         elif fd.get('type') == 'placeholder':
             shape = fd.get('shape')
             if shape and shape.has_text_frame:
-                for para in shape.text_frame.paragraphs:
-                    para.text = re.sub(r'\{[^}]+\}', '', para.text)
+                text = shape.text_frame.text or ''
+                # For KPI placeholders, remove the entire shape and nearby card backgrounds
+                kpi_pattern = re.compile(r'\{kpi\d+_(label|value)\}')
+                if kpi_pattern.search(text):
+                    # Remove this text shape
+                    self._remove_shape(shape)
+                    # Also remove nearby rounded rectangle backgrounds
+                    try:
+                        sx = int(shape.left)
+                        sy = int(shape.top)
+                        sw = int(shape.width)
+                        sh = int(shape.height)
+                        pad = 300000
+                        for other in list(slide.shapes):
+                            try:
+                                ox = int(other.left)
+                                oy = int(other.top)
+                                ow = int(other.width)
+                                oh = int(other.height)
+                                in_region = (
+                                    ox >= sx - pad and ox + ow <= sx + sw + pad and
+                                    oy >= sy - pad and oy + oh <= sy + sh + pad
+                                )
+                                if in_region and other != shape:
+                                    # Check if it's a background shape (no text or empty text)
+                                    if not other.has_text_frame or not (other.text_frame.text or '').strip():
+                                        self._remove_shape(other)
+                            except Exception:
+                                pass
+                    except Exception:
+                        pass
+                else:
+                    # For other placeholders, just clear the text
+                    for para in shape.text_frame.paragraphs:
+                        para.text = re.sub(r'\{[^}]+\}', '', para.text)
                 fd['fixed'] = True
 
         elif fd.get('type') == 'edge_left':
@@ -656,6 +782,15 @@ class QualityInspector:
                             run.font.name = DEFAULT_FONT
             fd['fixed'] = True
 
+        elif fd.get('type') == 'cover_text_invisible':
+            shape = fd.get('shape')
+            if shape and shape.has_text_frame:
+                dark_color = self.theme_colors.get('primary', RGBColor(0x1E, 0x3A, 0x5F))
+                for para in shape.text_frame.paragraphs:
+                    for run in para.runs:
+                        run.font.color.rgb = dark_color
+                fd['fixed'] = True
+
     def _fix_content(self, slide, issue, prs):
         fd = issue.fix_data
         if fd.get('type') == 'sparse':

+ 4 - 0
generate-data-report-ppt/scripts/quality_rules.py

@@ -32,6 +32,8 @@ QUALITY_RULES = [
     QualityRule('V003', 'visual', '字号过大(>60pt)', 'major', True, '_check_font_too_large', '_fix_font_too_large'),
     QualityRule('V004', 'visual', '颜色对比度不足', 'major', True, '_check_contrast', '_fix_contrast'),
     QualityRule('V005', 'visual', '图片拉伸变形', 'major', True, '_check_image_aspect', '_fix_image_aspect'),
+    QualityRule('V006', 'visual', '封面文字颜色与背景冲突(白色文字在浅色背景上不可见)', 'critical', True,
+                '_check_cover_text_visibility', '_fix_cover_text_visibility'),
 
     QualityRule('C001', 'content', '页面留白过多(填充率<35%)', 'critical', True, '_check_sparse_page', '_fix_sparse_page'),
     QualityRule('C002', 'content', 'KPI卡片数值为空', 'critical', True, '_check_empty_kpi', '_fix_empty_kpi'),
@@ -42,6 +44,8 @@ QUALITY_RULES = [
     QualityRule('C007', 'content', '分析段数不足', 'critical', True, '_check_insight_count', '_fix_insight_count'),
     QualityRule('C008', 'content', '页面内容为空(<50字)', 'critical', True, '_check_empty_page', '_fix_empty_page'),
     QualityRule('C009', 'content', '图表缺少分析文本', 'critical', True, '_check_chart_no_text', '_fix_chart_no_text'),
+    QualityRule('C012', 'content', '封面占位符仍为模板默认文字(未替换)', 'critical', False,
+                '_check_cover_template_text', None),
 
     QualityRule('D001', 'data', '图表数据与文本矛盾', 'critical', False, '_check_data_text_contradiction', None),
     QualityRule('D002', 'data', '页码错乱', 'major', True, '_check_page_numbers', '_fix_page_numbers'),

+ 3 - 0
generate-data-report-ppt/scripts/report_config.py

@@ -74,6 +74,7 @@ class ThemePreset(str, Enum):
     DARK_PROFESSIONAL = 'dark_professional'
     WARM_BRAND = 'warm_brand'
     CUSTOM = 'custom'
+    FROM_TEMPLATE = 'from_template'
 
 
 @dataclass
@@ -181,6 +182,8 @@ class ReportConfig:
 
     theme: ThemeConfig = field(default_factory=ThemeConfig)
     template_path: str = ''
+    template_profile: Optional[object] = None  # TemplateProfile from template_parser
+    use_template_theme: bool = True
     visual_style_direction: str = ''
     page_structure_template: str = ''
 

+ 587 - 0
generate-data-report-ppt/scripts/template_parser.py

@@ -0,0 +1,587 @@
+"""
+Template parser engine for the universal data report generator.
+Reads any .pptx template and outputs a structured TemplateProfile describing
+master slide types, placeholders, colors, fonts, and layout geometry.
+"""
+from __future__ import annotations
+
+import os
+import re
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+
+from pptx import Presentation
+from pptx.dml.color import RGBColor
+from pptx.util import Emu
+
+
+# ==============================================================================
+# DATA MODELS
+# ==============================================================================
+
+@dataclass
+class MasterSlideInfo:
+    slide_index: int
+    master_type: str  # 'cover' | 'content' | 'toc' | 'end' | 'unknown'
+    placeholders: list[str] = field(default_factory=list)
+    content_top: int = 0  # EMU
+    has_footer: bool = False
+    has_background: bool = False
+    shape_count: int = 0
+
+
+@dataclass
+class TemplateProfile:
+    path: str
+    is_builtin: bool
+    slide_width: int
+    slide_height: int
+    master_slides: list[MasterSlideInfo] = field(default_factory=list)
+    placeholder_map: dict[str, list[int]] = field(default_factory=dict)
+    detected_theme: dict[str, str] = field(default_factory=dict)
+    detected_fonts: dict[str, str] = field(default_factory=dict)
+    safe_margins: dict[str, int] = field(default_factory=dict)
+
+    def get_master_for(self, page_type: str) -> Optional[MasterSlideInfo]:
+        """Return the first master slide matching page_type, or None."""
+        for ms in self.master_slides:
+            if ms.master_type == page_type:
+                return ms
+        return None
+
+    def get_content_top(self, page_type: str = "content") -> int:
+        """Return content_top for the given page_type, or best guess."""
+        ms = self.get_master_for(page_type)
+        if ms and ms.content_top > 0:
+            return ms.content_top
+        # Fallback to any content page
+        for ms in self.master_slides:
+            if ms.master_type == "content" and ms.content_top > 0:
+                return ms.content_top
+        # Hard fallback
+        return int(Emu(1422400))
+
+    def get_master_index_for(self, page_type: str) -> int:
+        """Return slide index for page_type, with fallback rules."""
+        ms = self.get_master_for(page_type)
+        if ms:
+            return ms.slide_index
+        # Fallback heuristics
+        if page_type == "cover" and self.master_slides:
+            return self.master_slides[0].slide_index
+        if page_type == "end" and self.master_slides:
+            return self.master_slides[-1].slide_index
+        if page_type == "toc" and len(self.master_slides) >= 3:
+            return self.master_slides[2].slide_index
+        if len(self.master_slides) >= 2:
+            return self.master_slides[1].slide_index
+        return 0
+
+
+# ==============================================================================
+# PLACEHOLDER DETECTION
+# ==============================================================================
+
+_PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
+
+# Canonical placeholder -> list of aliases (including itself)
+PLACEHOLDER_ALIASES: dict[str, list[str]] = {
+    "{report_title}": ["{report_title}", "{标题}", "{title}", "{报告标题}"],
+    "{report_type}": ["{report_type}", "{报告类型}", "{type}"],
+    "{date}": ["{date}", "{日期}", "{report_date}", "{报告日期}"],
+    "{department}": ["{department}", "{部门}", "{source}", "{来源}", "{dept}"],
+    "{period}": ["{period}", "{周期}", "{report_period}", "{时间周期}"],
+    "{gen_time}": ["{gen_time}", "{生成时间}", "{generated_time}"],
+    "{page_title}": ["{page_title}", "{页面标题}", "{subtitle}", "{page_header}"],
+    "{source}": ["{source}", "{数据来源}", "{data_source}"],
+    "{page_num}": ["{page_num}", "{页码}", "{page_number}"],
+}
+
+# Chapter placeholders are generated dynamically
+for i in range(1, 13):
+    PLACEHOLDER_ALIASES[f"{{chapter{i}_title}}"] = [f"{{chapter{i}_title}}", f"{{章节{i}标题}}"]
+    PLACEHOLDER_ALIASES[f"{{chapter{i}_desc}}"] = [f"{{chapter{i}_desc}}", f"{{章节{i}描述}}"]
+
+# KPI placeholders
+for i in range(1, 13):
+    PLACEHOLDER_ALIASES[f"{{kpi{i}_label}}"] = [f"{{kpi{i}_label}}", f"{{kpi{i}_name}}"]
+    PLACEHOLDER_ALIASES[f"{{kpi{i}_value}}"] = [f"{{kpi{i}_value}}", f"{{kpi{i}_val}}"]
+
+
+def _scan_placeholders(slide) -> list[str]:
+    """Scan a slide for all placeholder-like strings {xxx}."""
+    found = set()
+    for shape in slide.shapes:
+        if shape.has_text_frame:
+            text = shape.text_frame.text or ""
+            for match in _PLACEHOLDER_RE.finditer(text):
+                found.add(match.group(0))
+    return sorted(found)
+
+
+def _normalize_placeholder(raw: str) -> Optional[str]:
+    """Map a raw placeholder to its canonical form, if known."""
+    raw_lower = raw.lower()
+    for canonical, aliases in PLACEHOLDER_ALIASES.items():
+        if raw_lower in [a.lower() for a in aliases]:
+            return canonical
+    return None
+
+
+# ==============================================================================
+# MASTER SLIDE TYPE DETECTION
+# ==============================================================================
+
+_TYPE_KEYWORDS: dict[str, list[str]] = {
+    "cover": ["{report_title}", "{date}", "{department}", "{report_type}", "{gen_time}"],
+    "content": ["{page_title}", "{source}", "{page_num}", "{period}"],
+    "toc": ["{chapter", "contents", "目录", "catalog", "agenda"],
+    "end": ["{report_title}", "感谢", "thank", "结语", "尾页", "end"],
+}
+
+
+def _detect_master_type(slide, slide_index: int, total_slides: int) -> str:
+    """Detect the semantic type of a master slide."""
+    texts = []
+    placeholders = []
+    for shape in slide.shapes:
+        if shape.has_text_frame:
+            t = (shape.text_frame.text or "").strip()
+            if t:
+                texts.append(t.lower())
+                placeholders.extend(_PLACEHOLDER_RE.findall(t))
+
+    text_block = " ".join(texts)
+    ph_block = " ".join(placeholders).lower()
+
+    scores: dict[str, int] = {"cover": 0, "content": 0, "toc": 0, "end": 0, "unknown": 0}
+
+    # Score by keywords
+    for ptype, keywords in _TYPE_KEYWORDS.items():
+        for kw in keywords:
+            if kw.lower() in ph_block or kw.lower() in text_block:
+                scores[ptype] += 1
+
+    # Position heuristics
+    if slide_index == 0:
+        scores["cover"] += 2
+    if slide_index == total_slides - 1:
+        scores["end"] += 2
+    if total_slides >= 3 and slide_index == 2:
+        scores["toc"] += 1
+
+    # Content page has page_title but not report_title (cover does)
+    if "{page_title}" in ph_block:
+        if "{report_title}" in ph_block:
+            # Could be cover with both; check position of report_title
+            # If report_title is at top-left small text, it's a header → content
+            scores["cover"] += 1
+        else:
+            scores["content"] += 3
+
+    # TOC strongly signaled by chapter placeholders
+    if "{chapter" in ph_block:
+        scores["toc"] += 5
+
+    # Distinguish end from cover: end usually lacks date/department placeholders
+    if "{date}" in ph_block and "{department}" in ph_block:
+        scores["cover"] += 2
+        scores["end"] -= 1
+
+    # Cover usually has KPI placeholders
+    if "{kpi1_label}" in ph_block:
+        scores["cover"] += 2
+
+    best = max(scores, key=lambda k: scores[k])
+    if scores[best] == 0:
+        # Default fallback by position
+        if slide_index == 0:
+            return "cover"
+        if slide_index == total_slides - 1:
+            return "end"
+        return "content"
+    return best
+
+
+# ==============================================================================
+# CONTENT TOP DETECTION
+# ==============================================================================
+
+def _detect_content_top(slide, default_gap: int = 381000) -> int:
+    """Detect content start Y by finding page_title placeholder bottom + gap."""
+    page_title_bottom = None
+    for shape in slide.shapes:
+        if not shape.has_text_frame:
+            continue
+        text = shape.text_frame.text or ""
+        # Match any page_title alias
+        if _matches_any_placeholder(text, "{page_title}"):
+            page_title_bottom = int(shape.top) + int(shape.height)
+            break
+
+    if page_title_bottom is not None:
+        return page_title_bottom + default_gap
+
+    # Fallback: find any text shape in the upper area that looks like a title
+    for shape in slide.shapes:
+        if not shape.has_text_frame:
+            continue
+        if int(shape.top) > Emu(500000) and int(shape.top) < Emu(1500000):
+            text = (shape.text_frame.text or "").strip()
+            if text and len(text) < 40 and "{" not in text:
+                return int(shape.top) + int(shape.height) + default_gap
+
+    return int(Emu(1422400))
+
+
+def _matches_any_placeholder(text: str, canonical: str) -> bool:
+    aliases = PLACEHOLDER_ALIASES.get(canonical, [canonical])
+    for alias in aliases:
+        if alias in text:
+            return True
+    return False
+
+
+# ==============================================================================
+# COLOR EXTRACTION
+# ==============================================================================
+
+def _extract_colors(slide) -> dict[str, str]:
+    """Extract dominant colors from a slide's shapes and theme."""
+    colors: dict[str, str] = {}
+
+    # Try theme color scheme first
+    try:
+        theme = slide.slide_layout.slide_master.theme
+        cs = theme.color_scheme
+        # Map theme colors
+        theme_map = {
+            "primary": cs.accent1,
+            "accent": cs.accent2,
+            "accent2": cs.accent3,
+            "accent_neg": cs.accent6,  # often red/orange
+            "text": cs.text1,
+            "background": cs.background1,
+        }
+        for key, color_obj in theme_map.items():
+            try:
+                rgb = color_obj.rgb
+                if rgb:
+                    colors[key] = _rgb_to_hex(rgb)
+            except Exception:
+                pass
+    except Exception:
+        pass
+
+    # Extract from shape fills (heuristic for primary color)
+    fill_colors: dict[str, int] = {}
+    text_colors: dict[str, int] = {}
+
+    for shape in slide.shapes:
+        # Fill colors
+        try:
+            if hasattr(shape, "fill") and shape.fill.type is not None:
+                if hasattr(shape.fill, "fore_color") and shape.fill.fore_color:
+                    rgb = getattr(shape.fill.fore_color, "rgb", None)
+                    if rgb:
+                        hex_str = _rgb_to_hex(rgb)
+                        fill_colors[hex_str] = fill_colors.get(hex_str, 0) + 1
+                        # Weight by area
+                        area = int(shape.width) * int(shape.height)
+                        fill_colors[hex_str] += area // 1000000000
+        except Exception:
+            pass
+
+        # Text colors
+        try:
+            if shape.has_text_frame:
+                for para in shape.text_frame.paragraphs:
+                    for run in para.runs:
+                        if run.font.color and run.font.color.rgb:
+                            hex_str = _rgb_to_hex(run.font.color.rgb)
+                            text_colors[hex_str] = text_colors.get(hex_str, 0) + 1
+        except Exception:
+            pass
+
+    # Determine primary from most common dark fill
+    dark_fills = {h: c for h, c in fill_colors.items() if _is_dark_color(h)}
+    if dark_fills:
+        primary = max(dark_fills, key=lambda k: dark_fills[k])
+        colors["primary"] = primary
+
+    # Determine accent from bright fills
+    bright_fills = {h: c for h, c in fill_colors.items() if _is_bright_color(h) and not _is_dark_color(h)}
+    if bright_fills:
+        accent = max(bright_fills, key=lambda k: bright_fills[k])
+        colors["accent"] = accent
+
+    # Text color
+    if text_colors:
+        text_col = max(text_colors, key=lambda k: text_colors[k])
+        if text_col.upper() not in ("FFFFFF", "000000") or len(text_colors) == 1:
+            colors["text"] = text_col
+
+    return colors
+
+
+def _rgb_to_hex(rgb) -> str:
+    if rgb is None:
+        return "#333333"
+    try:
+        return f"#{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
+    except Exception:
+        try:
+            return f"#{int(rgb):06X}"
+        except Exception:
+            return "#333333"
+
+
+def _is_dark_color(hex_str: str) -> bool:
+    hex_str = hex_str.lstrip("#")
+    if len(hex_str) != 6:
+        return False
+    try:
+        r, g, b = int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)
+        luminance = 0.299 * r + 0.587 * g + 0.114 * b
+        return luminance < 120
+    except Exception:
+        return False
+
+
+def _is_bright_color(hex_str: str) -> bool:
+    hex_str = hex_str.lstrip("#")
+    if len(hex_str) != 6:
+        return False
+    try:
+        r, g, b = int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)
+        saturation = max(r, g, b) - min(r, g, b)
+        return saturation > 40
+    except Exception:
+        return False
+
+
+# ==============================================================================
+# FONT EXTRACTION
+# ==============================================================================
+
+def _extract_fonts(slide) -> dict[str, str]:
+    """Extract dominant title and body fonts from a slide."""
+    title_fonts: dict[str, int] = {}
+    body_fonts: dict[str, int] = {}
+
+    for shape in slide.shapes:
+        if not shape.has_text_frame:
+            continue
+        top = int(shape.top)
+        for para in shape.text_frame.paragraphs:
+            for run in para.runs:
+                font_name = run.font.name
+                if not font_name:
+                    continue
+                # Title area: top < ~1.5M EMU (approx 3.8cm)
+                if top < Emu(1500000):
+                    title_fonts[font_name] = title_fonts.get(font_name, 0) + 1
+                else:
+                    body_fonts[font_name] = body_fonts.get(font_name, 0) + 1
+
+    result: dict[str, str] = {}
+    if title_fonts:
+        result["title_font"] = max(title_fonts, key=lambda k: title_fonts[k])
+    if body_fonts:
+        result["body_font"] = max(body_fonts, key=lambda k: body_fonts[k])
+    # Number font often same as body or Arial; keep it simple
+    result["number_font"] = result.get("body_font", "Arial")
+    return result
+
+
+# ==============================================================================
+# SAFE MARGIN DETECTION
+# ==============================================================================
+
+def _extract_safe_margins(slide) -> dict[str, int]:
+    """Estimate safe margins by looking at leftmost/topmost shapes."""
+    lefts = []
+    tops = []
+    for shape in slide.shapes:
+        try:
+            l = int(shape.left)
+            t = int(shape.top)
+            if l > 0 and l < Emu(2000000):
+                lefts.append(l)
+            if t > 0 and t < Emu(2000000):
+                tops.append(t)
+        except Exception:
+            pass
+
+    margins = {}
+    if lefts:
+        margins["left"] = min(lefts)
+        margins["right"] = min(lefts)
+    if tops:
+        margins["top"] = min(tops)
+    # Bottom margin harder to detect; use default
+    margins["bottom"] = int(Emu(254000))
+    return margins
+
+
+# ==============================================================================
+# BACKGROUND DETECTION
+# ==============================================================================
+
+def _has_background(slide) -> bool:
+    """Check if slide has explicit background shapes or images."""
+    try:
+        if slide.background.fill.type is not None:
+            return True
+    except Exception:
+        pass
+    for shape in slide.shapes:
+        try:
+            if int(shape.left) == 0 and int(shape.top) == 0:
+                if int(shape.width) > Emu(10000000) and int(shape.height) > Emu(5000000):
+                    return True
+        except Exception:
+            pass
+    return False
+
+
+def _has_footer(slide) -> bool:
+    """Check if slide has footer-like text at bottom."""
+    for shape in slide.shapes:
+        if not shape.has_text_frame:
+            continue
+        try:
+            top = int(shape.top)
+            if top > Emu(8000000):
+                text = (shape.text_frame.text or "").strip()
+                if text and ("{source}" in text or "{period}" in text or "{page_num}" in text):
+                    return True
+        except Exception:
+            pass
+    return False
+
+
+# ==============================================================================
+# MAIN PARSER
+# ==============================================================================
+
+def parse_template(path: str) -> TemplateProfile:
+    """Parse a .pptx template file and return a TemplateProfile."""
+    abs_path = os.path.abspath(path)
+    prs = Presentation(abs_path)
+
+    total_slides = len(prs.slides)
+    is_builtin = "assets" in abs_path.replace("\\", "/").lower()
+
+    master_slides: list[MasterSlideInfo] = []
+    placeholder_map: dict[str, list[int]] = {}
+    all_colors: dict[str, dict[str, int]] = {}
+    all_fonts: dict[str, dict[str, int]] = {}
+
+    for idx, slide in enumerate(prs.slides):
+        mtype = _detect_master_type(slide, idx, total_slides)
+        placeholders = _scan_placeholders(slide)
+        content_top = _detect_content_top(slide)
+
+        ms = MasterSlideInfo(
+            slide_index=idx,
+            master_type=mtype,
+            placeholders=placeholders,
+            content_top=content_top,
+            has_footer=_has_footer(slide),
+            has_background=_has_background(slide),
+            shape_count=len(list(slide.shapes)),
+        )
+        master_slides.append(ms)
+
+        # Build placeholder -> master index map
+        for ph in placeholders:
+            canonical = _normalize_placeholder(ph) or ph
+            if canonical not in placeholder_map:
+                placeholder_map[canonical] = []
+            if idx not in placeholder_map[canonical]:
+                placeholder_map[canonical].append(idx)
+
+        # Aggregate colors
+        colors = _extract_colors(slide)
+        for k, v in colors.items():
+            if k not in all_colors:
+                all_colors[k] = {}
+            all_colors[k][v] = all_colors[k].get(v, 0) + 1
+
+        # Aggregate fonts
+        fonts = _extract_fonts(slide)
+        for k, v in fonts.items():
+            if k not in all_fonts:
+                all_fonts[k] = {}
+            all_fonts[k][v] = all_fonts[k].get(v, 0) + 1
+
+    # Determine final detected_theme by voting across master slides
+    detected_theme: dict[str, str] = {}
+    for key, vote in all_colors.items():
+        if vote:
+            detected_theme[key] = max(vote, key=lambda k: vote[k])
+
+    # Determine final detected_fonts by voting
+    detected_fonts: dict[str, str] = {}
+    for key, vote in all_fonts.items():
+        if vote:
+            detected_fonts[key] = max(vote, key=lambda k: vote[k])
+
+    # Safe margins: use first content-like slide or cover
+    safe_margins: dict[str, int] = {}
+    for ms in master_slides:
+        if ms.master_type in ("content", "cover"):
+            slide = prs.slides[ms.slide_index]
+            safe_margins = _extract_safe_margins(slide)
+            break
+    if not safe_margins:
+        safe_margins = {"left": int(Emu(762000)), "right": int(Emu(762000)), "top": int(Emu(254000)), "bottom": int(Emu(254000))}
+
+    # Resolve slide dimensions
+    slide_width = int(prs.slide_width) if prs.slide_width else 16256000
+    slide_height = int(prs.slide_height) if prs.slide_height else 9144000
+
+    return TemplateProfile(
+        path=abs_path,
+        is_builtin=is_builtin,
+        slide_width=slide_width,
+        slide_height=slide_height,
+        master_slides=master_slides,
+        placeholder_map=placeholder_map,
+        detected_theme=detected_theme,
+        detected_fonts=detected_fonts,
+        safe_margins=safe_margins,
+    )
+
+
+def get_builtin_template_profile(report_type: str = "daily") -> TemplateProfile:
+    """Parse a built-in template and return its profile."""
+    base = os.path.join(os.path.dirname(__file__), "..", "assets")
+    template_map = {
+        "daily": os.path.join(base, "report-master.pptx"),
+        "weekly": os.path.join(base, "weekly-master.pptx"),
+        "monthly": os.path.join(base, "monthly-master.pptx"),
+    }
+    path = template_map.get(report_type, template_map["daily"])
+    return parse_template(path)
+
+
+# ==============================================================================
+# DEBUG
+# ==============================================================================
+
+if __name__ == "__main__":
+    import json
+    for rtype in ["daily", "weekly", "monthly"]:
+        profile = get_builtin_template_profile(rtype)
+        print(f"\n=== {rtype.upper()} TEMPLATE PROFILE ===")
+        print(f"  Path: {profile.path}")
+        print(f"  Size: {profile.slide_width} x {profile.slide_height}")
+        print(f"  Masters:")
+        for ms in profile.master_slides:
+            print(f"    [{ms.slide_index}] {ms.master_type}: placeholders={ms.placeholders}, content_top={ms.content_top}")
+        print(f"  Theme: {profile.detected_theme}")
+        print(f"  Fonts: {profile.detected_fonts}")
+        print(f"  Margins: {profile.safe_margins}")

+ 112 - 0
generate-data-report-ppt/scripts/theme_manager.py

@@ -96,6 +96,118 @@ def get_theme(preset: ThemePreset, custom_overrides: dict = None) -> ThemeConfig
     return PRESETS.get(preset, PRESETS[ThemePreset.BUSINESS_CLASSIC])
 
 
+def extract_theme_from_template(template_profile) -> ThemeConfig:
+    """
+    Build a ThemeConfig from a TemplateProfile's detected colors and fonts.
+    Adapts to dark templates by ensuring sufficient contrast for charts and text.
+    Falls back to BUSINESS_CLASSIC if no usable colors were detected.
+    """
+    detected = getattr(template_profile, 'detected_theme', {}) or {}
+    detected_fonts = getattr(template_profile, 'detected_fonts', {}) or {}
+    if not detected:
+        return PRESETS[ThemePreset.BUSINESS_CLASSIC].copy()
+
+    def _get(key: str, fallback: str) -> str:
+        return detected.get(key, detected.get(key.replace('_', ''), fallback))
+
+    primary = _get('primary', '#1E3A5F')
+    accent = _get('accent', '#10B981')
+    accent_neg = _get('accent_neg', '#EF4444')
+    text = _get('text', '#333333')
+    text_gray = _get('text_gray', '#666666')
+    bg = detected.get('background', '')
+
+    # Detect if template is dark-themed
+    is_dark = _is_dark_color(primary) or _is_dark_color(bg) or _is_dark_color(detected.get('dark', ''))
+
+    if is_dark:
+        # For dark templates: use bright, high-contrast colors
+        primary = '#38BDF8' if _is_dark_color(primary) else primary  # bright cyan-blue
+        text = '#F8FAFC' if _is_dark_color(text) else text  # near-white
+        text_gray = '#94A3B8' if _is_dark_color(text_gray) else text_gray  # light slate
+        card_bg = '#1E293B'  # dark slate card background
+        gray_bg = '#0F172A'  # very dark background
+        line = '#334155'  # medium slate line
+        series = ['#38BDF8', '#34D399', '#FBBF24', '#F87171', '#A78BFA', '#22D3EE', '#FB923C', '#60A5FA']
+    else:
+        # Light template: derive soft backgrounds from primary
+        card_bg = _lighten_hex(primary, 0.92)
+        gray_bg = _lighten_hex(primary, 0.96)
+        line = _lighten_hex(text, 0.80)
+        series = [primary, accent, accent_neg]
+        if 'accent2' in detected:
+            series.append(detected['accent2'])
+        else:
+            series.append('#ED7D31')
+        series.extend(['#64748B', '#EF4444', '#707070', '#4472C4'])
+        series = series[:8]
+
+    return ThemeConfig(
+        preset=ThemePreset.FROM_TEMPLATE,
+        name='模板提取主题',
+        primary=primary,
+        accent=accent,
+        accent_neg=accent_neg,
+        secondary=_get('secondary', '#64748B'),
+        dark=_get('dark', primary),
+        white='#FFFFFF',
+        gray_bg=gray_bg,
+        card_bg=card_bg,
+        text=text,
+        text_gray=text_gray,
+        line=line,
+        chart_series=series,
+        title_font=detected_fonts.get('title_font', '微软雅黑'),
+        body_font=detected_fonts.get('body_font', '微软雅黑'),
+        number_font=detected_fonts.get('number_font', 'Arial'),
+    )
+
+
+def _is_dark_color(hex_str: str) -> bool:
+    """Check if a hex color is dark (luminance < 120)."""
+    if not hex_str or not isinstance(hex_str, str):
+        return False
+    hex_str = hex_str.lstrip('#')
+    if len(hex_str) != 6:
+        return False
+    try:
+        r = int(hex_str[0:2], 16)
+        g = int(hex_str[2:4], 16)
+        b = int(hex_str[4:6], 16)
+        luminance = 0.299 * r + 0.587 * g + 0.114 * b
+        return luminance < 120
+    except Exception:
+        return False
+
+
+def _lighten_hex(hex_str: str, factor: float) -> str:
+    """Lighten a hex color by mixing with white. factor 0=original, 1=white."""
+    hex_str = hex_str.lstrip('#')
+    if len(hex_str) != 6:
+        return '#F2F2F2'
+    try:
+        r = int(int(hex_str[0:2], 16) * (1 - factor) + 255 * factor)
+        g = int(int(hex_str[2:4], 16) * (1 - factor) + 255 * factor)
+        b = int(int(hex_str[4:6], 16) * (1 - factor) + 255 * factor)
+        return f'#{min(255, r):02X}{min(255, g):02X}{min(255, b):02X}'
+    except Exception:
+        return '#F2F2F2'
+
+
+def merge_theme(template_theme: ThemeConfig, user_theme: ThemeConfig) -> ThemeConfig:
+    """Merge two ThemeConfigs, with user_theme overriding non-empty values."""
+    from dataclasses import fields
+    result = ThemeConfig()
+    for f in fields(ThemeConfig):
+        user_val = getattr(user_theme, f.name, None)
+        template_val = getattr(template_theme, f.name, None)
+        if user_val is not None and user_val != '' and user_val != f.default:
+            setattr(result, f.name, user_val)
+        elif template_val is not None and template_val != '' and template_val != f.default:
+            setattr(result, f.name, template_val)
+    return result
+
+
 def theme_to_rgb_colors(theme: ThemeConfig) -> dict:
     return {
         'primary': _hex_to_rgb(theme.primary),

BIN
海外订单数据周报.pptx


BIN
海外订单数据日报.pptx


BIN
海外订单日报_4月数据.xlsx


BIN
海外订单月度数据报告(2026年4月).pptx


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor