Skill: mapufan/fn-portrait (version 3.0.1)

--- name: fn-portrait description: "Financial report footnote extraction and analysis tool for Chinese A-share listed companies. Use when: (1) User wants to extract financial note data from annual reports (附注), (2) User wants to analyze financial statement footnotes, (3) User mentions 'Portrait', '财报附注', '年报分析', 'financial notes extraction', (4) User wants to download annual reports from CNINFO (巨潮资讯网), (5) User wants to visualize financial data from Chinese stock reports. Supports Shanghai/Shenzhen stock exchanges, STAR Market (科创板), ChiNext (创业板), and main boards." license: MIT --- # FN Portrait Toolkit Financial report footnote extraction and analysis tool for Chinese A-share listed companies. ## Quick Start ### Prerequisites Install Python dependencies: ```bash uv pip install pandas openpyxl requests matplotlib numpy pillow pdfplumber filelock ``` ### Configure LLM (Choose one) **Option A: DeepSeek (Recommended for China)** ```bash export DEEPSEEK_API_KEY=sk-... ``` **Option B: Moonshot** ```bash export KIMI_API_KEY=sk-... ``` **Option C: Ollama (Local, no API key needed)** ```bash # Install and start Ollama ollama pull gemma3:1b ollama serve ``` ### Run Analysis ```bash # Basic usage python ~/.openclaw/skills/fn-portrait/scripts/fn_pipeline.py <stock_code> <company_name> # Full example python ~/.openclaw/skills/fn-portrait/scripts/fn_pipeline.py 688018 乐鑫科技 2023-2025 科创板 ``` **Parameters:** - `stock_code`: 6-digit stock code (e.g., 688018) - `company_name`: Chinese company name (e.g., 乐鑫科技) - `years`: Year range (default: 2023-2025, format: YYYY or YYYY-YYYY) - `plate`: Stock exchange plate (default: 科创板, options: 科创板/创业板/沪主板/深主板) ## Pipeline Steps 1. **Download PDFs** - Fetch annual reports from CNINFO (巨潮资讯网) 2. **Extract Data** - Parse financial footnotes using pdfplumber 3. **LLM Analysis** - Semantic analysis of financial data 4. **Generate Portrait** - Create visualization charts ## Output Files - `portraits/Portrait_<code>_<name>.png` - Final visualization chart - `output2/<code>_<name>/` - Extracted Excel data - `output2/<code>_<name>/<code>_<name>_LLM分析结果.xlsx` - LLM analysis results - `RAWPDF/<name>PDF/` - Downloaded PDF files ## Supported Financial Items ### Balance Sheet Notes - Current Assets: 货币资金, 交易性金融资产, 应收票据, 应收账款, 存货分类 - Non-current Assets: 固定资产情况, 在建工程, 商誉 ### Income Statement Notes - Revenue: 营业收入, 营业成本, 境内外毛利率 - Expenses: 管理费用, 销售费用, 财务费用, 研发费用 ### Cash Flow Notes - Operating activities - Investing activities - Financing activities ## Advanced Usage ### Skip Steps ```bash python fn_pipeline.py 688018 乐鑫科技 --skip-download # Use existing PDFs python fn_pipeline.py 688018 乐鑫科技 --skip-extract # Use existing extracted data python fn_pipeline.py 688018 乐鑫科技 --skip-llm # Skip LLM analysis python fn_pipeline.py 688018 乐鑫科技 --skip-portrait # Skip chart generation ``` ### Custom Directories ```bash python fn_pipeline.py 688018 乐鑫科技 --rawpdf-dir /path/to/pdfs --output-dir /path/to/output ``` ## Troubleshooting ### Ollama Connection Error Ensure Ollama is running: `ollama serve` ### PDF Download Fails Check internet connection and CNINFO website accessibility ### Missing Fonts Fonts are bundled in `assets/fonts/`. If display issues occur, install Noto CJK fonts system-wide. ## References - See [references/financial-items.md](references/financial-items.md) for complete list of extractable financial items - See [references/api-integration.md](references/api-integration.md) for API integration details

path: requirements.txt
content: pandas
openpyxl
requests
matplotlib
numpy
pillow
pdfplumber
filelock


path: README.md
content: # FN Portrait Toolkit

金融财报 Portrait 生成工具 - 从 PDF 年报提取结构化数据，通过 LLM 智能分析，生成综合财务画像。

## 安装

```bash
pip install -r requirements.txt
```

## 使用

```bash
export DEEPSEEK_API_KEY=sk-...
python scripts/fn_pipeline.py 688777 中控技术 --years 2023-2025 --plate 科创板
```

## 支持模型

- DeepSeek API（推荐）
- Moonshot API
- Ollama 本地模型


path: assets/fonts/README.md
content: # 字体目录


path: scripts/ReportLinkCrawler2.py
content: #!/usr/bin/env python
# -*- coding: UTF-8 -*-
"""
ReportLinkCrawler2.py - 年报/半年报 PDF 下载爬虫（增强版）
基于巨潮资讯网API，支持参数化配置、预统计、增量下载

使用方法：
    python ReportLinkCrawler2.py <报告期> <板块> <保存文件夹>
    
示例：
    python ReportLinkCrawler2.py 2025年报   科创板 RAWPDF-2025A
    python ReportLinkCrawler2.py 2025半年报 科创板 RAWPDF-2025H
    python ReportLinkCrawler2.py 2024年报   创业板 RAWPDF-2024CY
    python ReportLinkCrawler2.py 2025年报   沪主板 RAWPDF-2025SH

支持板块：
    科创板, 创业板, 沪主板, 深主板
    （也可用代码：shkcp, szcy, shmb, szmb）
"""

from __future__ import annotations

import logging
import os
import re
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Dict, Any, Tuple

import requests

# 日志配置
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s'
)

# 板块名称映射
PLATE_NAME_MAP: Dict[str, str] = {
    '科创板': 'shkcp',
    '创业板': 'szcy',
    '沪主板': 'shmb',
    '深主板': 'szmb',
    'shkcp': 'shkcp',
    'szcy': 'szcy',
    'shmb': 'shmb',
    'szmb': 'szmb',
}

PLATE_DISPLAY_MAP: Dict[str, str] = {
    'shkcp': '科创板',
    'szcy': '创业板',
    'shmb': '沪主板',
    'szmb': '深主板',
}

# 报告类型配置
REPORT_CONFIG: Dict[str, Dict[str, Any]] = {
    '年报': {
        'category': 'category_ndbg_szsh',
        'search_months': (3, 4),        # 次年3-4月发布
        'title_keyword': '年度报告',
        'exclude_keywords': ['英文', '摘要', '已取消', '修订版', '更正后', 'XBRL'],
        'file_suffix': '年报',
    },
    '半年报': {
        'category': 'category_bndbg_szsh',
        'search_months': (7, 8),        # 当年7-8月发布
        'title_keyword': '半年度报告',
        'exclude_keywords': ['英文', '摘要', '已取消', '修订版', '更正后', 'XBRL'],
        'file_suffix': '半年报',
    },
}


@dataclass(frozen=True)
class ReportPeriod:
    """报告期解析结果"""
    year: int
    report_type: str        # '年报' 或 '半年报'


@dataclass(frozen=True)
class CrawlerConfig:
    """爬虫配置类"""
    target_year: int
    report_type: str = '年报'
    plate: str = "shkcp"
    max_retries: int = 3
    retry_delay: int = 5
    timeout: int = 30
    output_dir: str = "./RAWPDF"
    delay_between_downloads: float = 1.0


class CNINFOClient:
    """巨潮资讯API客户端"""
    
    BASE_URL = "http://www.cninfo.com.cn/new/hisAnnouncement/query"
    PDF_BASE_URL = "http://static.cninfo.com.cn/"
    
    HEADERS = {
        "Accept": "*/*",
        "Accept-Encoding": "gzip, deflate",
        "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
        "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
        "Host": "www.cninfo.com.cn",
        "Origin": "http://www.cninfo.com.cn",
        "Referer": "http://www.cninfo.com.cn/new/commonUrl/pageOfSearch",
        "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
        "X-Requested-With": "XMLHttpRequest"
    }
    
    def __init__(self, config: CrawlerConfig) -> None:
        self.config = config
        self.session = requests.Session()
        self.session.headers.update(self.HEADERS)
        self.report_cfg = REPORT_CONFIG[config.report_type]
    
    def search_announcements(self, start_date: str, end_date: str) -> List[Dict[str, Any]]:
        """
        搜索指定日期范围内的公告
        """
        date_range = f"{start_date}~{end_date}"
        all_results = []
        page_num = 1
        
        while True:
            data = {
                "pageNum": page_num,
                "pageSize": 30,
                "column": "sse",
                "tabName": "fulltext",
                "plate": self.config.plate,
                "searchkey": "",
                "secid": "",
                "category": self.report_cfg['category'],
                "trade": "",
                "seDate": date_range,
                "sortName": "time",
                "sortType": "desc",
                "isHLtitle": "false"
            }
            
            try:
                response = self.session.post(
                    self.BASE_URL,
                    data=data,
                    timeout=self.config.timeout
                )
                response.raise_for_status()
                result = response.json()
                
                announcements = result.get("announcements", [])
                if not announcements:
                    break
                
                all_results.extend(announcements)
                
                total_pages = result.get("totalpages", 0)
                if page_num >= total_pages:
                    break
                
                page_num += 1
                time.sleep(0.5)
                
            except Exception as e:
                logging.error(f"搜索失败: {e}")
                break
        
        return all_results
    
    def download_pdf(self, adjunct_url: str, save_path: str, company_code: str, company_name: str) -> bool:
        """下载PDF文件"""
        try:
            pdf_url = f"{self.PDF_BASE_URL}{adjunct_url}"
            
            logging.info(f"  下载: {company_code} {company_name}")
            logging.info(f"  URL: {pdf_url}")
            
            pdf_session = requests.Session()
            pdf_headers = {
                'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
                'Accept': 'application/pdf,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
                'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
                'Accept-Encoding': 'gzip, deflate',
                'Referer': 'http://www.cninfo.com.cn/new/commonUrl/pageOfSearch?url=disclosure/list/search',
                'Connection': 'keep-alive',
            }
            
            response = pdf_session.get(
                pdf_url,
                headers=pdf_headers,
                timeout=self.config.timeout,
                stream=True,
                allow_redirects=True
            )
            response.raise_for_status()
            
            with open(save_path, 'wb') as f:
                for chunk in response.iter_content(chunk_size=8192):
                    if chunk:
                        f.write(chunk)
            
            file_size = os.path.getsize(save_path) / 1024
            logging.info(f"  ✓ 成功: {os.path.basename(save_path)} ({file_size:.1f} KB)")
            return True
            
        except Exception as e:
            logging.error(f"  ✗ 失败: {e}")
            return False


class ReportLinkCrawler:
    """报告下载爬虫主类"""
    
    def __init__(self, config: CrawlerConfig) -> None:
        self.config = config
        self.client = CNINFOClient(config)
        self.report_cfg = REPORT_CONFIG[config.report_type]
        self.stats = {
            'total': 0,
            'success': 0,
            'failed': 0,
            'skipped': 0,
            'already_exists': 0,
        }
        self.existing_codes: set[str] = set()
    
    def _extract_year_from_title(self, title: str) -> Optional[int]:
        """从标题中提取年份"""
        match = re.search(r'(\d{4})年', title)
        if match:
            return int(match.group(1))
        return None
    
    def _should_download(self, title: str, year: int) -> bool:
        """判断是否应该下载"""
        title_year = self._extract_year_from_title(title)
        if title_year != year:
            return False
        
        for keyword in self.report_cfg['exclude_keywords']:
            if keyword in title:
                return False
        
        return True
    
    def _construct_filename(self, company_code: str, company_name: str, year: int) -> str:
        """构造文件名"""
        company_name_clean = re.sub(r'[\\/:*?"<>|]', '', company_name)
        suffix = self.report_cfg['file_suffix']
        return f"{company_code}_{company_name_clean}_{year}{suffix}.pdf"
    
    def _get_search_date_range(self, year: int) -> Tuple[str, str]:
        """
        根据报告类型计算搜索日期范围
        
        Returns:
            (start_date, end_date)
        """
        start_month, end_month = self.report_cfg['search_months']
        
        if self.config.report_type == '年报':
            # 年报在次年发布
            search_year = year + 1
        else:
            # 半年报在当年发布
            search_year = year
        
        start_date = f"{search_year}-{start_month:02d}-01"
        
        # 计算结束月份的最后一天
        if end_month == 12:
            end_date = f"{search_year}-{end_month:02d}-31"
        else:
            # 下月第一天减一天
            from datetime import datetime, timedelta
            next_month = datetime(search_year, end_month + 1, 1)
            last_day = (next_month - timedelta(days=1)).day
            end_date = f"{search_year}-{end_month:02d}-{last_day}"
        
        return start_date, end_date
    
    def _scan_existing_files(self, output_dir: Path) -> set[str]:
        """扫描目标目录中已存在的文件，提取公司代码"""
        existing_codes: set[str] = set()
        if not output_dir.exists():
            return existing_codes
        
        suffix = self.report_cfg['file_suffix']
        for f in output_dir.iterdir():
            if f.is_file() and f.suffix.lower() == '.pdf':
                # 匹配文件名格式: 688352_颀中科技_2025年报.pdf 或 688352_颀中科技_2025半年报.pdf
                pattern = rf'(\d{{6}})_.*_{re.escape(suffix)}\.pdf$'
                match = re.match(pattern, f.name)
                if match:
                    existing_codes.add(match.group(1))
        
        return existing_codes
    
    def preview_announcements(self, year: int) -> tuple[List[Dict[str, Any]], Path]:
        """
        预览阶段：搜索并统计符合条件的公告，同时扫描已有文件
        """
        suffix = self.report_cfg['file_suffix']
        
        logging.info("=" * 80)
        logging.info(f"【{suffix}下载爬虫 - 预览模式】")
        logging.info("=" * 80)
        logging.info(f"报告期: {year}{suffix}")
        logging.info(f"板块: {PLATE_DISPLAY_MAP.get(self.config.plate, self.config.plate)} ({self.config.plate})")
        logging.info(f"输出目录: {self.config.output_dir}")
        logging.info("")
        
        output_dir = Path(self.config.output_dir)
        output_dir.mkdir(parents=True, exist_ok=True)
        
        # 扫描已有文件
        self.existing_codes = self._scan_existing_files(output_dir)
        logging.info(f"📁 扫描本地目录: {output_dir}")
        logging.info(f"   已存在 {len(self.existing_codes)} 个公司{suffix}文件")
        if self.existing_codes:
            logging.info(f"   已下载公司: {', '.join(sorted(self.existing_codes))}")
        logging.info("")
        
        # 计算搜索日期范围
        start_date, end_date = self._get_search_date_range(year)
        
        logging.info(f"🔍 搜索日期范围: {start_date} ~ {end_date}")
        logging.info(f"正在搜索{suffix}公告...")
        
        announcements = self.client.search_announcements(start_date, end_date)
        logging.info(f"   API 返回 {len(announcements)} 条公告")
        logging.info("")
        
        # 筛选
        qualified: List[Dict[str, Any]] = []
        skipped_keywords = 0
        skipped_wrong_year = 0
        skipped_existing = 0
        
        seen_codes: set[str] = set()
        
        for ann in announcements:
            try:
                company_code = ann["secCode"]
                company_name = ann["secName"]
                title = ann["announcementTitle"]
                
                # 检查年份
                title_year = self._extract_year_from_title(title)
                if title_year != year:
                    skipped_wrong_year += 1
                    continue
                
                # 检查排除关键词
                if any(kw in title for kw in self.report_cfg['exclude_keywords']):
                    skipped_keywords += 1
                    continue
                
                # 检查是否已存在
                if company_code in self.existing_codes:
                    skipped_existing += 1
                    continue
                
                # 同一公司去重
                if company_code in seen_codes:
                    continue
                seen_codes.add(company_code)
                
                qualified.append(ann)
                
            except Exception as e:
                logging.warning(f"  处理公告出错: {e}")
                continue
        
        # 打印统计
        logging.info("=" * 80)
        logging.info("【预览统计】")
        logging.info("=" * 80)
        logging.info(f"API 返回公告总数:    {len(announcements)}")
        logging.info(f"  └─ 非目标年份:     {skipped_wrong_year}")
        logging.info(f"  └─ 含排除关键词:   {skipped_keywords}")
        logging.info(f"  └─ 本地已存在:     {skipped_existing}")
        logging.info(f"  └─ 去重后待下载:   {len(qualified)}")
        logging.info("")
        
        if qualified:
            logging.info("待下载公司列表:")
            for idx, ann in enumerate(qualified, 1):
                code = ann["secCode"]
                name = ann["secName"]
                title = ann["announcementTitle"]
                logging.info(f"  {idx:3d}. {code} {name} - {title}")
            logging.info("")
        else:
            logging.info("✅ 没有需要下载的新文件。")
            logging.info("")
        
        return qualified, output_dir
    
    def download_announcements(self, announcements: List[Dict[str, Any]], output_dir: Path, year: int, max_downloads: int = None) -> None:
        """下载指定的公告列表"""
        if not announcements:
            return
        
        suffix = self.report_cfg['file_suffix']
        
        logging.info("=" * 80)
        logging.info("【开始下载】")
        logging.info("=" * 80)
        logging.info("")
        
        if max_downloads is not None:
            announcements = announcements[:max_downloads]
            logging.info(f"⚠️ 测试模式：仅下载前 {max_downloads} 个文件")
            logging.info("")
        
        total = len(announcements)
        for idx, announcement in enumerate(announcements, 1):
            try:
                company_code = announcement["secCode"]
                company_name = announcement["secName"]
                title = announcement["announcementTitle"]
                adjunct_url = announcement["adjunctUrl"]
                
                logging.info(f"[{idx}/{total}] {company_code} {company_name}")
                logging.info(f"  标题: {title}")
                
                self.stats['total'] += 1
                
                filename = self._construct_filename(company_code, company_name, year)
                save_path = output_dir / filename
                
                if save_path.exists():
                    logging.info(f"  跳过: 文件已存在")
                    self.stats['already_exists'] += 1
                    continue
                
                success = self.client.download_pdf(
                    adjunct_url, str(save_path), company_code, company_name
                )
                
                if success:
                    self.stats['success'] += 1
                else:
                    self.stats['failed'] += 1
                
                if idx < total:
                    time.sleep(self.config.delay_between_downloads)
                
            except Exception as e:
                logging.error(f"  处理出错: {e}")
                self.stats['failed'] += 1
            
            logging.info("")
        
        self._print_stats()
    
    def _print_stats(self) -> None:
        """打印统计信息"""
        suffix = self.report_cfg['file_suffix']
        logging.info("=" * 80)
        logging.info("【下载统计】")
        logging.info("=" * 80)
        logging.info(f"总任务数:   {self.stats['total']}")
        logging.info(f"成功下载:   {self.stats['success']}")
        logging.info(f"本地已存在: {self.stats['already_exists']}")
        logging.info(f"下载失败:   {self.stats['failed']}")
        logging.info("")


def parse_plate(plate_input: str) -> str:
    """解析板块输入，返回标准代码"""
    plate = plate_input.strip()
    if plate in PLATE_NAME_MAP:
        return PLATE_NAME_MAP[plate]
    raise ValueError(f"不支持的板块: {plate}。支持的板块: {', '.join(PLATE_NAME_MAP.keys())}")


def parse_report_period(period_input: str) -> ReportPeriod:
    """
    解析报告期参数
    
    支持格式:
        2025年报, 2025半年报
        2025年报, 2025年半年报（自动提取）
    """
    period = period_input.strip()
    
    # 尝试匹配: 2025年报 或 2025半年报
    match = re.match(r'^(\d{4})(年?[半]?年报)$', period)
    if match:
        year = int(match.group(1))
        type_str = match.group(2)
        # 统一处理
        if '半年' in type_str:
            report_type = '半年报'
        else:
            report_type = '年报'
        return ReportPeriod(year=year, report_type=report_type)
    
    # 尝试更宽松的匹配
    match2 = re.match(r'^(\d{4}).*$', period)
    if match2:
        year = int(match2.group(1))
        if '半年' in period:
            report_type = '半年报'
        else:
            report_type = '年报'
        return ReportPeriod(year=year, report_type=report_type)
    
    raise ValueError(
        f"无效的报告期格式: '{period_input}'。"
        f"支持的格式如: '2025年报', '2025半年报'"
    )


def run(
    report_period: str = '2025年报',
    plate: str = '科创板',
    output_dir: str = './RAWPDF',
    auto_confirm: bool = False,
    max_downloads: int = None,
    stock_codes: list = None,
) -> None:
    """
    手动运行函数（参数直接写在代码里，适合 Jupyter/IDE 调用）
    
    Args:
        report_period: 报告期，如 '2025年报'、'2025半年报'
        plate: 板块，如 '科创板'、'创业板'、'沪主板'、'深主板'
        output_dir: 保存文件夹路径
        auto_confirm: 是否自动确认下载（跳过 y/n 交互）
        max_downloads: 限制下载数量（用于测试）
        stock_codes: 指定股票代码列表，如 ['688199', '688302']，只下载这些公司的报告
    """
    # 解析参数
    period = parse_report_period(report_period)
    plate_code = parse_plate(plate)
    
    suffix = REPORT_CONFIG[period.report_type]['file_suffix']
    logging.info("=" * 80)
    logging.info(f"【巨潮资讯{suffix}下载爬虫 - 手动模式】")
    logging.info("=" * 80)
    logging.info(f"报告期:   {period.year}{suffix}")
    logging.info(f"板块:     {PLATE_DISPLAY_MAP.get(plate_code, plate_code)} ({plate_code})")
    logging.info(f"保存目录: {output_dir}")
    logging.info(f"自动确认: {'是' if auto_confirm else '否（需手动确认）'}")
    if stock_codes:
        logging.info(f"指定股票: {', '.join(stock_codes)}")
    logging.info("")
    
    # 配置
    config = CrawlerConfig(
        target_year=period.year,
        report_type=period.report_type,
        plate=plate_code,
        output_dir=output_dir,
        delay_between_downloads=1.0
    )
    
    # 创建爬虫实例
    crawler = ReportLinkCrawler(config)
    
    # 阶段 1: 预览
    qualified, output_dir_path = crawler.preview_announcements(year=period.year)
    
    # 按股票代码过滤
    if stock_codes and qualified:
        filtered = [q for q in qualified if any(code in q['secCode'] for code in stock_codes)]
        logging.info(f"股票过滤: {len(qualified)} → {len(filtered)} 个文件")
        qualified = filtered
    
    # 阶段 2: 确认
    if not qualified:
        logging.info("任务完成，没有需要下载的新文件。")
        return
    
    if not auto_confirm:
        try:
            confirm = input(f"确认下载以上 {len(qualified)} 个文件? (y/n): ").strip().lower()
        except (EOFError, KeyboardInterrupt):
            print("")
            confirm = 'n'
        
        if confirm not in ('y', 'yes', '是', '确认', '1'):
            logging.info("用户取消下载，程序退出。")
            return
    else:
        logging.info(f"自动确认已开启，开始下载 {len(qualified)} 个文件...")
    
    # 阶段 3: 执行下载
    crawler.download_announcements(qualified, output_dir_path, period.year, max_downloads)
    
    logging.info("=" * 80)
    logging.info("程序执行完成！")
    logging.info("=" * 80)


def main():
    """主函数：支持命令行参数"""
    if len(sys.argv) < 4:
        print("用法: python ReportLinkCrawler2.py <报告期> <板块> <保存文件夹>")
        print("")
        print("示例:")
        print("  python ReportLinkCrawler2.py 2025年报   科创板 RAWPDF-2025A")
        print("  python ReportLinkCrawler2.py 2025半年报 科创板 RAWPDF-2025H")
        print("  python ReportLinkCrawler2.py 2024年报   创业板 RAWPDF-2024CY")
        print("  python ReportLinkCrawler2.py 2025年报   沪主板 RAWPDF-2025SH")
        print("")
        print("支持板块: 科创板, 创业板, 沪主板, 深主板")
        print("          (也可用代码: shkcp, szcy, shmb, szmb)")
        sys.exit(1)
    
    # 参数 1: 报告期
    try:
        period = parse_report_period(sys.argv[1])
    except ValueError as e:
        print(f"错误: {e}")
        sys.exit(1)
    
    # 参数 2: 板块
    try:
        plate_code = parse_plate(sys.argv[2])
    except ValueError as e:
        print(f"错误: {e}")
        sys.exit(1)
    
    # 参数 3: 保存文件夹
    output_dir = sys.argv[3].strip()
    
    # 打印配置信息
    suffix = REPORT_CONFIG[period.report_type]['file_suffix']
    logging.info("=" * 80)
    logging.info(f"【巨潮资讯{suffix}下载爬虫 - 增强版】")
    logging.info("=" * 80)
    logging.info(f"报告期:   {period.year}{suffix}")
    logging.info(f"板块:     {PLATE_DISPLAY_MAP.get(plate_code, plate_code)} ({plate_code})")
    logging.info(f"保存目录: {output_dir}")
    logging.info("")
    
    # 配置
    config = CrawlerConfig(
        target_year=period.year,
        report_type=period.report_type,
        plate=plate_code,
        output_dir=output_dir,
        delay_between_downloads=1.0
    )
    
    # 创建爬虫实例
    crawler = ReportLinkCrawler(config)
    
    # 阶段 1: 预览
    qualified, output_dir_path = crawler.preview_announcements(year=period.year)
    
    # 阶段 2: 用户确认
    if not qualified:
        logging.info("任务完成，没有需要下载的新文件。")
        return
    
    try:
        confirm = input(f"确认下载以上 {len(qualified)} 个文件? (y/n): ").strip().lower()
    except (EOFError, KeyboardInterrupt):
        print("")
        confirm = 'n'
    
    if confirm not in ('y', 'yes', '是', '确认', '1'):
        logging.info("用户取消下载，程序退出。")
        return
    
    # 阶段 3: 执行下载
    crawler.download_announcements(qualified, output_dir_path, period.year)
    
    logging.info("=" * 80)
    logging.info("程序执行完成！")
    logging.info("=" * 80)


if __name__ == '__main__':
    main()


path: scripts/LLM2Excel3.py
content: #!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
LLM2Excel_合并版.py - LLM语义分析合并版
同时分析"经营情况的讨论与分析"和"报告期内获得的研发成果"
输出到同一个Excel文件的不同sheet

使用方法：
    python LLM2Excel_合并版.py <股票代码> <公司名称> <经营情况文本路径> <研发成果文本路径> [输出目录] [模型名称]

示例：
    python LLM2Excel_合并版.py 688049 炬芯科技 \
        "经营情况.txt" \
        "研发成果.txt" \
        "LLM测试输出"
"""

import requests
import pandas as pd
import json
import re
import sys
import os
from datetime import datetime


BUSINESS_SCHEMA = {
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "业务板块": {"type": "string"},
            "战略目标": {"type": "string"},
            "现状描述": {"type": "string"},
            "关键措施": {"type": "string"}
        },
        "required": ["业务板块", "战略目标", "现状描述", "关键措施"],
        "additionalProperties": False
    }
}

RD_SCHEMA = {
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "技术/产品类别": {"type": "string"},
            "具体成果": {"type": "string"},
            "性能指标": {"type": "string"},
            "应用前景": {"type": "string"},
            "进展状态": {"type": "string"}
        },
        "required": ["技术/产品类别", "具体成果", "性能指标", "应用前景", "进展状态"],
        "additionalProperties": False
    }
}

COMPETITIVENESS_SCHEMA = {
    "type": "array",
    "items": {
        "type": "object",
        "properties": {
            "竞争维度": {"type": "string"},
            "核心优势": {"type": "string"},
            "具体表现": {"type": "string"},
            "与同行对比": {"type": "string"}
        },
        "required": ["竞争维度", "核心优势", "具体表现", "与同行对比"],
        "additionalProperties": False
    }
}


class LLMCombinedAnalyzer:
    """LLM合并分析器"""

    def __init__(self, model="gemma3:1b", fallback_models=None,
                 ollama_url="http://localhost:11434",
                 provider="ollama", api_key=None):
        self.model = model
        self.fallback_models = fallback_models or ["deepseek-r1:1.5b", "qwen3:0.6b"]
        self.provider = provider.lower()
        # 支持 Moonshot 和 DeepSeek
        self.moonshot_key = api_key or os.environ.get("KIMI_API_KEY", "")
        self.deepseek_key = os.environ.get("DEEPSEEK_API_KEY", "")

        if self.provider == "moonshot":
            self.api_endpoint = "https://api.moonshot.cn/v1/chat/completions"
            self.[REDACTED_SECRET]
        elif self.provider == "deepseek":
            self.api_endpoint = "https://api.deepseek.com/v1/chat/completions"
            self.[REDACTED_SECRET]
        else:
            self.ollama_url = ollama_url
            self.api_endpoint = f"{ollama_url}/api/chat"

    def read_text_file(self, file_path: str) -> str:
        """读取文本文件"""
        try:
            with open(file_path, 'r', encoding='utf-8') as f:
                return f.read()
        except Exception as e:
            print(f"❌ 读取文件失败: {e}")
            return ""

    def clean_and_fix_json(self, json_str: str) -> str:
        """清理和修复JSON字符串"""
        if not json_str:
            return None

        json_str = json_str.strip()

        # 移除 markdown 代码块包裹
        json_str = re.sub(r'^\s*```(?:json)?\s*', '', json_str, flags=re.IGNORECASE)
        json_str = re.sub(r'\s*```\s*$', '', json_str)

        # 移除所有换行符
        json_str = json_str.replace('\n', ' ').replace('\r', ' ')

        # 移除控制字符
        json_str = ''.join(char for char in json_str if ord(char) >= 32 or char in '\t')

        # 移除截断标记
        json_str = re.sub(r'\.\.\.\s*$', '', json_str)
        json_str = re.sub(r'…\s*$', '', json_str)

        # 修复中文引号
        json_str = (
            json_str.replace('"', '"')
            .replace('"', '"')
            .replace(''', "'")
            .replace(''', "'")
        )
        
# 修复对象内部字段之间缺少逗号的情况（在移除换行符之前处理）
        # 匹配: "key": "value"[空白/换行]"key2":  → 补逗号
        json_str = re.sub(r'"\s*:\s*"([^"]*)"\s+(?=")', r'": "\1", ', json_str)
        json_str = re.sub(r'"\s*:\s*([\d\.]+)\s+(?=")', r'": \1, ', json_str)
        json_str = re.sub(r'"\s*:\s*([a-zA-Z]+)\s+(?=")', r'": \1, ', json_str)

        # 移除所有换行符
        json_str = json_str.replace('\n', ' ').replace('\r', ' ')

        # 去掉末尾逗号，先避免补括号后变成 ,] 或 ,}
        json_str = re.sub(r',\s*$', '', json_str)

        # 反复清理尾随逗号
        prev = None
        while prev != json_str:
            prev = json_str
            json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
            json_str = re.sub(r',\s*$', '', json_str)

        # 确保JSON完整性
        brace_count = json_str.count('{') - json_str.count('}')
        bracket_count = json_str.count('[') - json_str.count(']')

        if brace_count > 0:
            json_str += '}' * brace_count
        if bracket_count > 0:
            json_str += ']' * bracket_count

        # 补全后再清理一次尾随逗号
        json_str = re.sub(r',\s*([}\]])', r'\1', json_str)
        json_str = re.sub(r',\s*$', '', json_str)

        return json_str

    def _extract_complete_json_objects(self, text: str) -> list:
        """从任意文本中提取所有完整闭合的JSON对象"""
        if not text:
            return []

        objs = []
        start = -1
        depth = 0
        in_string = False
        escaped = False

        for i, char in enumerate(text):
            if in_string:
                if escaped:
                    escaped = False
                elif char == '\\':
                    escaped = True
                elif char == '"':
                    in_string = False
                continue

            if char == '"':
                in_string = True
                continue

            if char == '{':
                if depth == 0:
                    start = i
                depth += 1
            elif char == '}' and depth > 0:
                depth -= 1
                if depth == 0 and start != -1:
                    obj_str = text[start:i + 1]
                    for candidate in (obj_str, self.clean_and_fix_json(obj_str)):
                        if not candidate:
                            continue
                        try:
                            obj = json.loads(candidate)
                            if isinstance(obj, dict):
                                objs.append(obj)
                                break
                        except Exception:
                            continue
                    start = -1

        return objs

    def _get_truncation_reasons(self, text: str, raw_result: dict = None) -> list:
        """判断输出是否可能被截断，并返回原因"""
        reasons = []
        stripped = (text or "").rstrip()

        if not stripped:
            return reasons

        done_reason = None
        if isinstance(raw_result, dict):
            done_reason = raw_result.get("done_reason")
            if done_reason is None and isinstance(raw_result.get("message"), dict):
                done_reason = raw_result["message"].get("done_reason")

        if done_reason == "length":
            reasons.append("done_reason=length")

        if '[' in stripped and stripped.count('[') > stripped.count(']'):
            reasons.append("缺少 ]")
        if '{' in stripped and stripped.count('{') > stripped.count('}'):
            reasons.append("缺少 }")
        if re.search(r',\s*$', stripped):
            reasons.append("末尾逗号")
        if stripped.endswith("...") or stripped.endswith("…"):
            reasons.append("省略号结尾")

        return reasons

    def extract_json_from_text(self, text: str) -> list:
        """从文本中提取JSON数组"""
        if not text:
            return None

        def _try_parse(candidate: str):
            cleaned = self.clean_and_fix_json(candidate)
            if not cleaned:
                return None
            try:
                data = json.loads(cleaned)
                if isinstance(data, list):
                    return data
                if isinstance(data, dict):
                    return [data]
            except Exception:
                return None
            return None

        text = text.strip()

        # 策略1：先尝试找完整闭合数组
        start = text.find('[')
        if start != -1:
            bracket_count = 0
            end = -1
            in_string = False
            escaped = False

            for i, char in enumerate(text[start:], start):
                if in_string:
                    if escaped:
                        escaped = False
                    elif char == '\\':
                        escaped = True
                    elif char == '"':
                        in_string = False
                    continue

                if char == '"':
                    in_string = True
                    continue

                if char == '[':
                    bracket_count += 1
                elif char == ']':
                    bracket_count -= 1
                    if bracket_count == 0:
                        end = i
                        break

            if end != -1:
                data = _try_parse(text[start:end + 1])
                if isinstance(data, list) and len(data) > 0:
                    return data

            # 策略2：数组不完整时，从 [ 到末尾做修复
            data = _try_parse(text[start:])
            if isinstance(data, list) and len(data) > 0:
                return data

        # 补充尝试：直接解析整个文本
        data = _try_parse(text)
        if isinstance(data, list) and len(data) > 0:
            return data

        # 策略3：对象级恢复（丢弃最后一个不完整对象）
        recovered_objs = self._extract_complete_json_objects(text)
        if recovered_objs:
            return recovered_objs

        return None

    def analyze_business(self, text: str, stock_code: str, company_name: str) -> dict:
        """分析经营情况"""
        print(f"\n[{stock_code}] 正在分析经营情况...")

        prompt = f"""
    你是一个专门做上市公司年报信息抽取的助手。
    你的任务是从"经营情况讨论与分析"中，提取公司各业务板块或战略主题的关键信息。

    请严格遵守以下要求：

    【输出要求】
    1. 只能输出一个 JSON 数组，不要输出任何解释、前后缀、标题、markdown 代码块。
    2. JSON 数组中的每个对象必须且只能包含以下 4 个字段：
    - "业务板块"
    - "战略目标"
    - "现状描述"
    - "关键措施"
    3. 必须使用双引号，保证 JSON 格式可被 json.loads 直接解析。
    4. 如果某字段在原文中没有明确表述，可做"贴近原文的简要归纳"；若完全无法判断，填空字符串 ""。
    5. 不要编造原文没有的信息，不要加入财务推测、投资建议、评价性语言。
    6. 不要重复生成相同的内容

    【抽取口径】
    1. 优先抽取"明确业务板块"，例如：轨道交通、新能源汽车、半导体、工业板块、海洋板块等。
    2. 如果全文没有严格的业务板块划分，但存在清晰的经营战略主题，也允许将其作为"业务板块"抽取，
    例如：市场布局、科技创新能力提升、产业布局优化、治理能力建设、风险合规等。
    3. "战略目标"强调未来导向、发展方向、要实现的重点目标。
    4. "现状描述"强调当前成果、市场地位、订单/交付/项目进展、经营表现等。
    5. "关键措施"强调公司采取的行动路径、抓手、工程、平台建设、产品推进、布局动作等。
    6. 遇到大段行业背景时，只保留与公司自身经营直接相关的部分。
    7. 相近内容合并，避免重复拆分得过细。

    【示例1】
    输入文本：
    （一）轨道交通业务聚力突破
    公司抓住国铁集团铁路投资增长的契机，动车组和机车持续交付，市场地位稳固。公司成功实现永磁牵引技术在我国大功率新能源机车领域商业化应用，为重载运输场景的绿色低碳转型提供了全新解决方案。根据RT轨道交通网统计数据，2025年公司动车牵引系统国内市场占有率60.92%，连续14年行业领跑。通信信号业务持续推进，宁波8号线6月开通运营，实现首个自主FAO应用示范项目落地。

    输出：
    [
    {{
        "业务板块": "轨道交通",
        "战略目标": "巩固并提升轨道交通核心业务领先地位，推进关键系统自主化和商业化应用",
        "现状描述": "动车组和机车持续交付，牵引系统国内市占率60.92%，连续14年行业领跑",
        "关键措施": "推进永磁牵引技术商业化落地，持续交付动车组和机车，推动通信信号及自主FAO项目实施"
    }},
    {{
        "业务板块": "通信信号",
        "战略目标": "提升自主化系统解决方案能力并扩大示范应用",
        "现状描述": "宁波8号线开通运营，首个自主FAO应用示范项目落地",
        "关键措施": "推进FAO无人化拆分改造融合方案验证和项目落地"
    }}
    ]

    【示例2】
    输入文本：
    2025年重点工作情况如下：
    市场布局新进展：国内装备建设实现重大突破，产品市场占有率显著提升；民用航空产业高质量发展，应急国债项目实现全面交付；首次搭载广电5G空中应急通信基站投入实战应用。科技创新能力提升：全年研发投入达3.37亿元，重点突破11项核心技术瓶颈，新增7项技术成果转化为产品路线。治理能力持续增强：推进流程优化和AOS管理体系建设，采购业务实现归口统一管理，加速数字化转型升级。

    输出：
    [
    {{
        "业务板块": "市场布局",
        "战略目标": "扩大重点产品市场覆盖并推动重点项目落地应用",
        "现状描述": "国内装备建设实现突破，产品市占率提升，应急项目全面交付，5G空中应急通信投入实战应用",
        "关键措施": "推进民航与应急领域项目交付，拓展重点场景应用，强化市场开拓"
    }},
    {{
        "业务板块": "科技创新能力",
        "战略目标": "提升核心技术攻关和成果转化能力",
        "现状描述": "全年研发投入3.37亿元，突破11项核心技术瓶颈，新增7项技术成果转化为产品路线",
        "关键措施": "加大研发投入，完善技术路线图，推进核心技术攻关和成果转化"
    }},
    {{
        "业务板块": "治理能力",
        "战略目标": "提升管理效率和数字化运营能力",
        "现状描述": "流程优化持续推进，AOS管理体系建设深化，采购业务实现统一管理",
        "关键措施": "推进流程优化、管理体系建设和数字化转型升级"
    }}
    ]

    【现在请分析以下文本】
    公司名称：{company_name}
    股票代码：{stock_code}
    输入文本：
    {text[:12000]}
    """
        marker = "【现在请分析以下文本】"
        if marker in prompt:
            system_prompt, user_prompt = prompt.split(marker, 1)
            system_prompt = system_prompt.strip()
            user_prompt = marker + user_prompt
        else:
            system_prompt, user_prompt = "", prompt

        return self._call_llm(system_prompt, user_prompt, stock_code, "经营情况", BUSINESS_SCHEMA)

    def analyze_rd(self, text: str, stock_code: str, company_name: str) -> dict:
        """分析研发成果"""
        print(f"\n[{stock_code}] 正在分析研发成果...")

        prompt = f"""
你是一个专门做上市公司年报研发信息抽取的助手。
你的任务是从"报告期内获得的研发成果"中，提取关键研发信息，并输出标准 JSON。

请严格遵守以下要求：

【输出要求】
1. 只能输出一个 JSON 数组，不要输出任何解释、前后缀、标题、markdown 代码块。
2. JSON 数组中的每个对象必须且只能包含以下 5 个字段：
   - "技术/产品类别"
   - "具体成果"
   - "性能指标"
   - "应用前景"
   - "进展状态"
3. 必须使用双引号，保证 JSON 格式可被 json.loads 直接解析。
4. 如果原文没有明确出现某项内容：
   - "性能指标"没有就填 ""
   - "应用前景"没有就填 ""
   - "进展状态"尽量根据原文规范化为：已量产 / 已交付 / 已应用 / 已验证 / 试验完成 / 已发布 / 已投运 / 已获奖 / 已发表 / 已出版 / 已授权 / 研发中 / 未说明
5. 不要编造指标或场景，不要输出原文外的信息。
6. 不要重复生成相同的内容

【抽取口径】
1. 一条成果对应一个对象。
2. 可抽取的成果类型包括但不限于：
   - 芯片、模块、器件、平台、系统、装备、算法、工艺
   - 论文、专著、奖项、专利、标准
3. "具体成果"应尽量写清楚对象名称和核心突破点。
4. "性能指标"只保留原文明确出现的量化指标或可验证性能描述。
5. "应用前景"只写原文明确提到的应用领域、应用场景或行业方向。
6. 对高度相似的成果可适度合并，但不要把完全不同类别的成果混成一条。

【示例1】
输入文本：
在新兴装备业务板块，突破第四代沟槽栅SiCMOSFET芯片技术，比导通电阻≤2毫欧平方厘米，达到国际领先水平。突破1μmPitch超精细沟槽IGBT技术，实现出流能力提升7%。发布400kW+光伏逆变器"赤霄"与3.XMW储能PCS"云枢"。完成第五代产品研制，纯电高压SiC多合一总成功率密度达到1.42kW/kg，CLTC工况效率达93.52%。全球首台水下重载作业型电动ROV完成商业化交付，能效提升27%。

输出：
[
  {{
    "技术/产品类别": "SiCMOSFET芯片",
    "具体成果": "突破第四代沟槽栅SiCMOSFET芯片技术",
    "性能指标": "比导通电阻≤2毫欧平方厘米",
    "应用前景": "",
    "进展状态": "已验证"
  }},
  {{
    "技术/产品类别": "IGBT技术",
    "具体成果": "突破1μmPitch超精细沟槽IGBT技术",
    "性能指标": "出流能力提升7%",
    "应用前景": "",
    "进展状态": "已验证"
  }},
  {{
    "技术/产品类别": "光伏/储能装备",
    "具体成果": "发布400kW+光伏逆变器"赤霄"和3.XMW储能PCS"云枢"",
    "性能指标": "",
    "应用前景": "光伏与储能场景",
    "进展状态": "已发布"
  }},
  {{
    "技术/产品类别": "高压SiC多合一总成",
    "具体成果": "完成第五代产品研制",
    "性能指标": "功率密度1.42kW/kg，CLTC工况效率93.52%",
    "应用前景": "",
    "进展状态": "已验证"
  }},
  {{
    "技术/产品类别": "电动ROV",
    "具体成果": "全球首台水下重载作业型电动ROV完成商业化交付",
    "性能指标": "能效提升27%",
    "应用前景": "水下重载作业",
    "进展状态": "已交付"
  }}
]

【示例2】
输入文本：
2025年公司发表SCI论文2篇、EI论文4篇、核心期刊论文5篇，专著3本《中空长航时多任务无人机》《多域无人系统智能协同认知控制技术》《基于虚实结合的无人机编队飞行技术》。"边缘计算无人机智能计算系统"项目获得航空工业集团科技进步三等奖，联合申报"基于精准可函数据理论的空天飞行器高性能自主控制与决策技术"项目获得四川省技术发明奖二等奖，并有5项科技成果获得协会奖项。

输出：
[
  {{
    "技术/产品类别": "科研论文",
    "具体成果": "发表SCI论文2篇、EI论文4篇、核心期刊论文5篇",
    "性能指标": "SCI论文2篇，EI论文4篇，核心期刊论文5篇",
    "应用前景": "",
    "进展状态": "已发表"
  }},
  {{
    "技术/产品类别": "专著",
    "具体成果": "出版《中空长航时多任务无人机》《多域无人系统智能协同认知控制技术》《基于虚实结合的无人机编队飞行技术》3本专著",
    "性能指标": "专著3本",
    "应用前景": "无人机与多域无人系统技术方向",
    "进展状态": "已出版"
  }},
  {{
    "技术/产品类别": "获奖项目",
    "具体成果": ""边缘计算无人机智能计算系统"项目获得航空工业集团科技进步三等奖",
    "性能指标": "",
    "应用前景": "无人机智能计算",
    "进展状态": "已获奖"
  }},
  {{
    "技术/产品类别": "获奖项目",
    "具体成果": ""基于精准可函数据理论的空天飞行器高性能自主控制与决策技术"项目获得四川省技术发明奖二等奖",
    "性能指标": "",
    "应用前景": "空天飞行器自主控制与决策",
    "进展状态": "已获奖"
  }},
  {{
    "技术/产品类别": "科技成果奖项",
    "具体成果": "5项科技成果获得协会奖项",
    "性能指标": "5项",
    "应用前景": "",
    "进展状态": "已获奖"
  }}
]

【现在请分析以下文本】
公司名称：{company_name}
股票代码：{stock_code}
输入文本：
{text[:12000]}
"""
        marker = "【现在请分析以下文本】"
        if marker in prompt:
            system_prompt, user_prompt = prompt.split(marker, 1)
            system_prompt = system_prompt.strip()
            user_prompt = marker + user_prompt
        else:
            system_prompt, user_prompt = "", prompt

        return self._call_llm(system_prompt, user_prompt, stock_code, "研发成果", RD_SCHEMA)

    def _call_llm(self, system_prompt: str, user_prompt: str, stock_code: str, analysis_type: str, schema: dict = None) -> dict:
        """调用LLM，支持模型自动回退"""

        models_to_try = [self.model] + self.fallback_models
        last_error = None

        def _request_chat_once(model_name, messages, format_schema=None, temperature=0.1, top_p=0.9, num_predict=4000):
            if self.provider in ("moonshot", "deepseek"):
                # OpenAI 兼容格式 (Moonshot / DeepSeek)
                headers = {
                    "Authorization": f"Bearer {self.api_key}",
                    "Content-Type": "application/json"
                }
                payload = {
                    "model": model_name,
                    "messages": messages,
                    "temperature": temperature,
                    "top_p": top_p,
                    "max_tokens": num_predict,
                }
                if format_schema:
                    payload["response_format"] = {"type": "json_object"}

                response = requests.post(
                    self.api_endpoint,
                    headers=headers,
                    json=payload,
                    timeout=240
                )
                response.raise_for_status()
                result = response.json()
                answer = result["choices"][0]["message"]["content"]
                return answer, result
            else:
                # Ollama 格式
                payload = {
                    "model": model_name,
                    "stream": False,
                    "messages": messages,
                    "options": {
                        "temperature": temperature,
                        "top_p": top_p,
                        "num_predict": num_predict
                    }
                }
                if format_schema:
                    payload["format"] = format_schema

                response = requests.post(
                    self.api_endpoint,
                    json=payload,
                    timeout=240
                )
                response.raise_for_status()
                result = response.json()
                answer = (result.get("message") or {}).get("content", "") or result.get("response", "")
                return answer, result

        def _build_continue_messages(raw_answer: str):
            tail = raw_answer[-2500:] if raw_answer else ""
            continue_system = (
                "你是JSON续写器。你的任务是把被截断的JSON数组补全。"
                "只输出JSON续写片段，不要解释、不要markdown、不要重复已完成内容。"
            )
            continue_user = f"""
下面是一段被截断的JSON输出末尾。请从中断处继续输出，直到让整体成为一个可解析的完整JSON数组。

要求：
1. 只输出续写片段，不要输出解释文字。
2. 不要重复已有内容。
3. 最终应能与原内容直接拼接为合法JSON。
4. 若你判断原内容已完整，仅输出一个 ] 即可。

分析类型：{analysis_type}
原输出末尾：
{tail}
"""
            return [
                {"role": "system", "content": continue_system},
                {"role": "user", "content": continue_user}
            ]

        for model_name in models_to_try:
            try:
                print(f"  使用模型: {model_name}")

                messages = [
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": user_prompt},
                ]
                answer, result = _request_chat_once(
                    model_name=model_name,
                    messages=messages,
                    format_schema=schema,
                    temperature=0.1,
                    top_p=0.9,
                    num_predict=4000
                )

                print(f"  ✓ 收到响应，长度: {len(answer)} 字符")

                # 提取JSON
                data = self.extract_json_from_text(answer)

                if data:
                    print(f"  ✓ 成功解析 {len(data)} 条记录")
                    return {'success': True, 'data': data}

                # 疑似被截断时，自动续写一次
                truncation_reasons = self._get_truncation_reasons(answer, result)
                if truncation_reasons:
                    print(f"  ⚠ 可能截断，原因: {', '.join(truncation_reasons)}")
                    print(f"  ↻ 正在自动续写一次...")

                    continue_messages = _build_continue_messages(answer)
                    continue_answer, _ = _request_chat_once(
                        model_name=model_name,
                        messages=continue_messages,
                        format_schema=None,
                        temperature=0.0,
                        top_p=0.9,
                        num_predict=1200
                    )
                    print(f"  ✓ 续写响应长度: {len(continue_answer)} 字符")

                    merged_answer = (answer or "") + (continue_answer or "")
                    data = self.extract_json_from_text(merged_answer)
                    if data:
                        print(f"  ✓ 续写后成功解析 {len(data)} 条记录")
                        return {'success': True, 'data': data}

                print(f"  ❌ 无法解析JSON")
                last_error = Exception(f"模型 {model_name} 无法解析JSON")

            except Exception as e:
                print(f"  ❌ 模型 {model_name} 请求失败: {e}")
                last_error = e
                continue

        print(f"  ❌ 所有模型均失败")
        return {'success': False, 'error': str(last_error) if last_error else "所有模型失败"}

    def analyze_portrait_data(self, portrait_data: dict, stock_code: str, company_name: str) -> list:
        """
        分析 Portrait 结构化数据，生成三年趋势总结

        读取 portrait_data/*.json 中的财务指标，用 LLM 做定性分析。
        返回结构化 JSON 数组，每个维度一条记录。
        """
        print(f"\n[{stock_code}] 正在分析 Portrait 财务数据趋势...")

        # 处理数据结构：支持直接指标或嵌套在 metrics 中
        metrics = portrait_data.get('metrics', portrait_data)
        years = portrait_data.get('years', [])
        
        if not years and metrics.get('营业收入'):
            years = sorted(list(metrics.get('营业收入', {}).keys()))
        
        if not years:
            print(f"  ⚠️ 未找到年份数据")
            return []
        
        year_range = f"{min([int(y) for y in years])}-{max([int(y) for y in years])}" if years else ""

        # 构造精简的数据摘要（避免 token 过长）
        summary_text = f"""公司名称：{company_name}
股票代码：{stock_code}

以下为公司 {year_range} 年关键财务指标：

【营收与盈利能力】
营业收入：{metrics.get('营业收入', {})}
营业成本：{metrics.get('营业成本', {})}
毛利率：{metrics.get('毛利率', {})}%

【费用结构】
管理费用：{metrics.get('管理费用', {})}
销售费用：{metrics.get('销售费用', {})}
研发费用：{metrics.get('研发费用', {})}
财务费用：{metrics.get('财务费用', {})}
期间费用合计：{metrics.get('期间费用合计', {})}
管理费用率：{metrics.get('管理费用率', {})}%
销售费用率：{metrics.get('销售费用率', {})}%
研发费用率：{metrics.get('研发费用率', {})}%
期间费用率：{metrics.get('期间费用率', {})}%

【资产质量】
资产减值损失：{metrics.get('资产减值损失', {})}
存货：{metrics.get('存货', {})}
应收账款：{metrics.get('应收账款', {})}

【研发创新】
在研项目数量：{metrics.get('在研项目数量', {})}个
知识产权数量：{metrics.get('知识产权数量', {})}项

【供应链风险】
客户集中度：{metrics.get('客户集中度', {})}%
供应商集中度：{metrics.get('供应商集中度', {})}%"""

        prompt = f"""你是一个专业的上市公司财务分析助手。请根据以下三年的财务指标数据，从五个核心维度做趋势分析和总结。

{summary_text}

请严格按照以下要求输出：

【输出要求】
1. 只能输出一个 JSON 数组，不要输出任何解释、前后缀、标题、markdown 代码块。
2. 必须分析所有我提供了数据的维度（营收与盈利、费用结构、资产质量、研发创新、供应链风险）。
3. 如果某个维度的数据为空或缺失，可以跳过该维度。
4. 每个对象必须且只能包含以下字段：
   - "维度"（字符串）："营收与盈利" / "费用结构" / "资产质量" / "研发创新" / "供应链风险"
   - "三年趋势"（字符串）：如"持续增长" / "波动上升" / "先升后降" / "基本稳定"
   - "关键变化"（字符串）：用我提供的具体数字说明 1-2 个变化点
   - "风险提示"（字符串）：如有异常或风险简述；无则填"无"
   - "结论"（字符串）：一句话总结，不超过 30 字
5. 必须使用双引号，保证 JSON 格式可被 json.loads 直接解析。
6. 不要编造数据，所有分析必须基于提供的数据。
7. 请输出所有5个维度的分析（只要有数据），不要遗漏。
7. 只输出 JSON 数组，不要输出其他任何内容。
"""

        marker = "【输出要求】"
        if marker in prompt:
            system_prompt, user_prompt = prompt.split(marker, 1)
            system_prompt = system_prompt.strip()
            user_prompt = marker + user_prompt
        else:
            system_prompt, user_prompt = "", prompt

        # 调用 LLM
        result = self._call_llm(system_prompt, user_prompt, stock_code, "Portrait数据分析")

        if isinstance(result, dict) and result.get('success'):
            return result.get('data', [])
        elif isinstance(result, list):
            return result
        else:
            print(f"  ⚠️ Portrait 数据分析返回格式异常")
            return []

    def analyze_competitiveness(self, text: str, stock_code: str, company_name: str) -> dict:
        """分析核心竞争力"""
        print(f"\n[{stock_code}] 正在分析核心竞争力...")

        prompt = f"""
你是一个专门做上市公司年报核心竞争力分析的助手。
你的任务是从"核心竞争力分析"文本中，提取公司各竞争维度的关键信息。

请严格遵守以下要求：

【输出要求】
1. 只能输出一个 JSON 数组，不要输出任何解释、前后缀、标题、markdown 代码块。
2. JSON 数组中的每个对象必须且只能包含以下 4 个字段：
   - "竞争维度"
   - "核心优势"
   - "具体表现"
   - "与同行对比"
3. 必须使用双引号，保证 JSON 格式可被 json.loads 直接解析。
4. 如果某字段在原文中没有明确表述，可做"贴近原文的简要归纳"；若完全无法判断，填空字符串 ""。
5. 不要编造原文没有的信息，不要输出原文外的评价性语言。
6. 不要重复生成相同的内容。

【抽取口径】
1. 竞争维度包括但不限于：技术优势、产品优势、品牌优势、渠道优势、成本优势、人才优势、专利壁垒、规模效应等。
2. "核心优势"强调公司相对于竞争对手的独特能力和护城河。
3. "具体表现"强调具体数据、案例、成果、市占率、荣誉等可验证信息。
4. "与同行对比"强调公司与行业平均或主要竞争对手的差异和领先地位。
5. 相近内容合并，避免拆分得过细。

【现在请分析以下文本】
公司名称：{company_name}
股票代码：{stock_code}
输入文本：
{text[:12000]}
"""
        marker = "【现在请分析以下文本】"
        if marker in prompt:
            system_prompt, user_prompt = prompt.split(marker, 1)
            system_prompt = system_prompt.strip()
            user_prompt = marker + user_prompt
        else:
            system_prompt, user_prompt = "", prompt

        return self._call_llm(system_prompt, user_prompt, stock_code, "核心竞争力", COMPETITIVENESS_SCHEMA)

    def save_combined_results(self, business_data: list, rd_data: list, comp_data: list,
                              stock_code: str, company_name: str, output_dir: str = ".",
                              year_period: str = None, append_mode: bool = False) -> str:
        """保存合并结果到Excel

        Args:
            year_period: 报告期（如'2024年报'），为None时使用默认sheet名
            append_mode: 是否追加到已有Excel文件（多期合并时使用）
        """
        os.makedirs(output_dir, exist_ok=True)

        # 创建DataFrame
        df_business = pd.DataFrame(business_data)
        df_rd = pd.DataFrame(rd_data)
        df_comp = pd.DataFrame(comp_data)

        # 添加元信息
        meta_cols = [('股票代码', stock_code), ('公司名称', company_name)]
        if year_period:
            meta_cols.append(('报告期', year_period))
        meta_cols.append(('分析时间', datetime.now().strftime('%Y-%m-%d %H:%M:%S')))

        for df in [df_business, df_rd, df_comp]:
            if not df.empty:
                for col_name, col_value in reversed(meta_cols):
                    df.insert(0, col_name, col_value)

        # 生成文件名
        excel_file = os.path.join(output_dir, f"{stock_code}_{company_name}_LLM分析结果.xlsx")

        # 确定 sheet 名（Excel sheet 名限制 31 字符）
        if year_period:
            sheet_business = f'经营情况_{year_period}'[:31]
            sheet_rd = f'研发成果_{year_period}'[:31]
            sheet_comp = f'核心竞争力_{year_period}'[:31]
        else:
            sheet_business = '经营情况分析'
            sheet_rd = '研发成果分析'
            sheet_comp = '核心竞争力分析'

        # 保存到不同sheet
        if append_mode and os.path.exists(excel_file):
            writer = pd.ExcelWriter(excel_file, engine='openpyxl', mode='a', if_sheet_exists='replace')
        else:
            writer = pd.ExcelWriter(excel_file, engine='openpyxl', mode='w')

        with writer:
            if not df_business.empty:
                df_business.to_excel(writer, sheet_name=sheet_business, index=False)
            if not df_rd.empty:
                df_rd.to_excel(writer, sheet_name=sheet_rd, index=False)
            if not df_comp.empty:
                df_comp.to_excel(writer, sheet_name=sheet_comp, index=False)

        print(f"\n✓ 结果已保存: {excel_file}")
        if not df_business.empty:
            print(f"  - {sheet_business}: {len(df_business)} 条记录")
        if not df_rd.empty:
            print(f"  - {sheet_rd}: {len(df_rd)} 条记录")
        if not df_comp.empty:
            print(f"  - {sheet_comp}: {len(df_comp)} 条记录")

        return excel_file


def main():
    """主函数"""
    if len(sys.argv) < 5:
        print("用法: python LLM2Excel_合并版.py <股票代码> <公司名称> <经营情况文本> <研发成果文本> [输出目录] [模型]")
        print("\n示例:")
        print('  python LLM2Excel_合并版.py 688049 炬芯科技 "经营情况.txt" "研发成果.txt"')
        sys.exit(1)

    stock_code = sys.argv[1]
    company_name = sys.argv[2]
    business_file = sys.argv[3]
    rd_file = sys.argv[4]
    output_dir = sys.argv[5] if len(sys.argv) > 5 else "./LLM测试输出"
    model_name = sys.argv[6] if len(sys.argv) > 6 else "deepseek-r1:1.5b"

    print("="*80)
    print("【LLM语义分析 - 合并版】")
    print("="*80)
    print(f"股票代码: {stock_code}")
    print(f"公司名称: {company_name}")
    print(f"经营情况文本: {business_file}")
    print(f"研发成果文本: {rd_file}")
    print(f"输出目录: {output_dir}")
    print(f"使用模型: {model_name}")
    print()

    # 检查文件
    for f in [business_file, rd_file]:
        if not os.path.exists(f):
            print(f"❌ 文件不存在: {f}")
            sys.exit(1)

    # 创建分析器
    analyzer = LLMCombinedAnalyzer(model=model_name)

    # 读取文本
    business_text = analyzer.read_text_file(business_file)
    rd_text = analyzer.read_text_file(rd_file)

    if not business_text and not rd_text:
        print("❌ 两个文本文件都为空")
        sys.exit(1)

    # 分析
    business_result = analyzer.analyze_business(business_text, stock_code, company_name) if business_text else {'success': False}
    rd_result = analyzer.analyze_rd(rd_text, stock_code, company_name) if rd_text else {'success': False}

    # 保存结果
    if business_result['success'] or rd_result['success']:
        business_data = business_result.get('data', [])
        rd_data = rd_result.get('data', [])

        # 显示预览
        if business_data:
            print("\n经营情况分析预览:")
            print(pd.DataFrame(business_data).to_string())

        if rd_data:
            print("\n研发成果分析预览:")
            print(pd.DataFrame(rd_data).to_string())

        # 保存
        analyzer.save_combined_results(business_data, rd_data, stock_code, company_name, output_dir)

        print("\n" + "="*80)
        print("✓ 分析完成！")
        print("="*80)
    else:
        print("\n" + "="*80)
        print("❌ 分析失败")
        print("="*80)


def _detect_txt_type(folder_name: str) -> str:
    """根据文件夹名称判断txt分析类型"""
    name = folder_name.lower()
    # 优先级：先匹配更具体的，再匹配泛化的
    if '行业' in name or '趋势' in name or '产业' in name or '新技术' in name:
        return 'industry'
    elif '核心' in name or '竞争力' in name or '优势' in name:
        return 'competitiveness'
    elif '经营' in name or '业务与行业' not in name and '情况' in name:
        return 'business'
    elif '研发' in name or '成果' in name:
        return 'rd'
    return 'unknown'


def auto_locate_txt_files(base_dir: str, stock_code: str, company_name: str) -> dict:
    """根据股票代码和公司名自动扫描目录定位txt文件"""
    txt_map = {}

    if not os.path.exists(base_dir):
        return txt_map

    # 判断是否是具体公司目录（路径中包含股票代码和公司名）
    base_is_company_dir = stock_code in base_dir and company_name in base_dir

    for root, dirs, files in os.walk(base_dir):
        for file in files:
            if not file.lower().endswith('.txt'):
                continue
            # 匹配逻辑：如果是具体公司目录，则接受该目录下所有txt；否则要求文件名包含股票代码和公司名
            if base_is_company_dir or (stock_code in file and company_name in file):
                folder_name = os.path.basename(root)
                txt_type = _detect_txt_type(folder_name)
                if txt_type != 'unknown':
                    # 避免重复：如果已存在同类型，优先保留路径更匹配的
                    if txt_type not in txt_map or len(root) < len(os.path.dirname(txt_map[txt_type])):
                        txt_map[txt_type] = os.path.join(root, file)

    return txt_map


def batch_process(base_dir: str, model_name: str = "gemma3:1b", max_workers: int = 1, provider: str = "ollama"):
    """
    批量处理所有公司的文本文件（自动扫描目录）
    同一公司的不同年份合并到同一个Excel文件的不同sheet中

    改进点：
    1. 按公司实时保存：分析完一个公司立即写入Excel，避免全部完成前崩溃导致全部丢失
    2. 增强健壮性：单个任务失败不影响其他任务
    3. 默认并发降为1，避免本地Ollama过载
    4. 实时进度显示

    目录结构预期:
        base_dir/
          688002_睿创微纳_2023年报/11_管理层讨论与分析/01_业务与行业/xxx.txt
          688002_睿创微纳_2024年报/11_管理层讨论与分析/01_业务与行业/xxx.txt
          ...

    输出结构:
        base_dir/
          688002_睿创微纳/
            688002_睿创微纳_LLM分析结果.xlsx  (包含多个sheet)
    """
    import time
    from concurrent.futures import ThreadPoolExecutor, as_completed
    import threading

    print_lock = threading.Lock()

    # --- 1. 扫描文件并按 (stock_code, company_name) -> year_period -> type 分组 ---
    company_files = {}  # {(code, name): {year_period: {type: path}}}

    for root, dirs, files in os.walk(base_dir):
        for file in files:
            if not file.lower().endswith('.txt'):
                continue
            file_path = os.path.join(root, file)

            rel_path = os.path.relpath(root, base_dir)
            parts = rel_path.split(os.sep)

            stock_code = None
            company_name = None
            year_period = None

            for part in parts:
                match = re.match(r'^(\d{6})_(.+)$', part)
                if match and not re.search(r'\d+年(?:报|半年报)$', part):
                    stock_code = match.group(1)
                    company_name = match.group(2)
                    file_match = re.search(r'_(\d+年(?:报|半年报))(?:_\d+)?\.txt$', file, re.IGNORECASE)
                    year_period = file_match.group(1) if file_match else "未知"
                    break
            else:
                for part in parts:
                    match = re.match(r'^(\d{6})_(.+?)_(.+?年报)$', part)
                    if match:
                        stock_code = match.group(1)
                        company_name = match.group(2)
                        year_period = match.group(3)
                        break

            if not stock_code:
                continue

            folder_name = os.path.basename(root)
            txt_type = _detect_txt_type(folder_name)
            if txt_type == 'unknown':
                continue

            company_key = (stock_code, company_name)
            if company_key not in company_files:
                company_files[company_key] = {}
            if year_period not in company_files[company_key]:
                company_files[company_key][year_period] = {}
            company_files[company_key][year_period][txt_type] = file_path

    # --- 2. 准备任务列表 ---
    all_tasks = []
    for company_key, year_map in sorted(company_files.items()):
        stock_code, company_name = company_key
        for year_period, txt_map in sorted(year_map.items()):
            all_tasks.append((stock_code, company_name, year_period, txt_map))

    total_tasks = len(all_tasks)
    total_companies = len(company_files)

    print("="*80)
    print("【批量LLM语义分析 - 多期合并版】")
    print("="*80)
    print(f"基础目录: {base_dir}")
    print(f"主模型: {model_name}")
    print(f"备选模型: deepseek-r1:1.5b, qwen3:0.6b")
    print(f"并发数: {max_workers}")
    print(f"扫描到 {total_companies} 个公司，共 {total_tasks} 个(公司,年份)任务")
    for company_key, year_map in sorted(company_files.items()):
        years = ', '.join(sorted(year_map.keys()))
        print(f"  {company_key[0]} {company_key[1]}: {years}")
    print()

    # --- 3. 创建分析器 ---
    analyzer = LLMCombinedAnalyzer(model=model_name, provider=provider)

    # --- 4. 并发执行 LLM 分析，按公司实时保存 ---
    company_results = {}      # {(code, name): [(year_period, business_data, rd_data, comp_data), ...]}
    company_locks = {key: threading.Lock() for key in company_files}
    company_completed_years = {key: 0 for key in company_files}
    company_total_years = {key: len(v) for key, v in company_files.items()}
    saved_companies = set()
    completed_tasks = 0

    def analyze_one_task(args):
        stock_code, company_name, year_period, txt_map = args

        try:
            business_text = analyzer.read_text_file(txt_map.get('business', '')) if 'business' in txt_map else ""
            industry_text = analyzer.read_text_file(txt_map.get('industry', '')) if 'industry' in txt_map else ""
            if industry_text:
                business_text = f"【行业发展趋势】\n{industry_text}\n\n【经营情况】\n{business_text}" if business_text else industry_text
            rd_text = analyzer.read_text_file(txt_map.get('rd', '')) if 'rd' in txt_map else ""
            comp_text = analyzer.read_text_file(txt_map.get('competitiveness', '')) if 'competitiveness' in txt_map else ""

            start_time = time.time()

            business_result = analyzer.analyze_business(business_text, stock_code, company_name) if business_text else {'success': False}
            rd_result = analyzer.analyze_rd(rd_text, stock_code, company_name) if rd_text else {'success': False}
            comp_result = analyzer.analyze_competitiveness(comp_text, stock_code, company_name) if comp_text else {'success': False}

            elapsed = time.time() - start_time

            business_data = business_result.get('data', []) if business_result['success'] else []
            rd_data = rd_result.get('data', []) if rd_result['success'] else []
            comp_data = comp_result.get('data', []) if comp_result['success'] else []

            status = 'success' if (business_data or rd_data or comp_data) else 'no_data'

            with print_lock:
                if status == 'success':
                    print(f"✓ {stock_code} {company_name} [{year_period}] ({elapsed:.1f}s) - 经营:{len(business_data)}条 研发:{len(rd_data)}条 核心:{len(comp_data)}条")
                elif status == 'no_data':
                    print(f"⚠ {stock_code} {company_name} [{year_period}] ({elapsed:.1f}s) - 无数据")

            return {
                'stock_code': stock_code,
                'company_name': company_name,
                'year_period': year_period,
                'business_data': business_data,
                'rd_data': rd_data,
                'comp_data': comp_data,
                'status': status,
                'time': elapsed
            }

        except Exception as e:
            with print_lock:
                print(f"❌ {stock_code} {company_name} [{year_period}] - 错误: {e}")
            return {
                'stock_code': stock_code,
                'company_name': company_name,
                'year_period': year_period,
                'status': 'error',
                'error': str(e)
            }

    def _try_save_company(company_key):
        """检查并保存已完成的公司"""
        with company_locks[company_key]:
            if company_key in saved_companies:
                return
            if company_completed_years[company_key] < company_total_years[company_key]:
                return

            year_results = company_results.get(company_key, [])
            if not year_results:
                return

            stock_code, company_name = company_key
            year_results.sort(key=lambda x: x[0])

            company_output_dir = os.path.join(base_dir, f"{stock_code}_{company_name}")
            os.makedirs(company_output_dir, exist_ok=True)
            excel_file = os.path.join(company_output_dir, f"{stock_code}_{company_name}_LLM分析结果.xlsx")

            try:
                if os.path.exists(excel_file):
                    os.remove(excel_file)

                with pd.ExcelWriter(excel_file, engine='openpyxl') as writer:
                    for year_period, business_data, rd_data, comp_data in year_results:
                        df_business = pd.DataFrame(business_data)
                        df_rd = pd.DataFrame(rd_data)
                        df_comp = pd.DataFrame(comp_data)

                        meta_cols = [
                            ('股票代码', stock_code),
                            ('公司名称', company_name),
                            ('报告期', year_period),
                            ('分析时间', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
                        ]

                        for df in [df_business, df_rd, df_comp]:
                            if not df.empty:
                                for col_name, col_value in reversed(meta_cols):
                                    df.insert(0, col_name, col_value)

                        if not df_business.empty:
                            df_business.to_excel(writer, sheet_name=f'经营情况_{year_period}'[:31], index=False)
                        if not df_rd.empty:
                            df_rd.to_excel(writer, sheet_name=f'研发成果_{year_period}'[:31], index=False)
                        if not df_comp.empty:
                            df_comp.to_excel(writer, sheet_name=f'核心竞争力_{year_period}'[:31], index=False)

                    # --- 追加 Portrait 数据分析 ---
                    portrait_json = os.path.join(company_output_dir, "portrait_data", f"{stock_code}_{company_name}_portrait_data.json")
                    if os.path.exists(portrait_json):
                        try:
                            with open(portrait_json, 'r', encoding='utf-8') as f:
                                portrait_data = json.load(f)
                            analyzer = LLMCombinedAnalyzer(model_name)
                            portrait_summary = analyzer.analyze_portrait_data(portrait_data, stock_code, company_name)
                            if portrait_summary:
                                df_portrait = pd.DataFrame(portrait_summary)
                                meta_portrait = [
                                    ('股票代码', stock_code),
                                    ('公司名称', company_name),
                                    ('分析类型', 'Portrait财务趋势分析'),
                                    ('分析时间', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
                                ]
                                for col_name, col_value in reversed(meta_portrait):
                                    df_portrait.insert(0, col_name, col_value)
                                df_portrait.to_excel(writer, sheet_name='Portrait数据分析'[:31], index=False)
                                with print_lock:
                                    print(f"  📊 Portrait 数据分析已追加")
                        except Exception as e:
                            with print_lock:
                                print(f"  ⚠️ Portrait 数据分析失败: {e}")

                with print_lock:
                    print(f"  💾 已保存: {stock_code}_{company_name}_LLM分析结果.xlsx ({len(year_results)}个年份)")
                saved_companies.add(company_key)

                # 清理内存
                if company_key in company_results:
                    del company_results[company_key]

            except Exception as e:
                with print_lock:
                    print(f"  ❌ 写入 {excel_file} 失败: {e}")

    start_time = time.time()

    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {executor.submit(analyze_one_task, task): task for task in all_tasks}

        for future in as_completed(futures):
            result = future.result()
            if not result:
                continue

            company_key = (result['stock_code'], result['company_name'])

            with company_locks[company_key]:
                company_completed_years[company_key] += 1
                if result.get('status') == 'success':
                    company_results.setdefault(company_key, []).append((
                        result['year_period'],
                        result['business_data'],
                        result['rd_data'],
                        result['comp_data']
                    ))

            completed_tasks += 1
            _try_save_company(company_key)

            progress_pct = completed_tasks / total_tasks * 100 if total_tasks else 0
            with print_lock:
                print(f"  >>> 总进度: {completed_tasks}/{total_tasks} ({progress_pct:.1f}%) | 已保存: {len(saved_companies)}/{total_companies} 家公司")

    total_time = time.time() - start_time

    print()
    print("="*80)
    print("【处理完成】")
    print("="*80)
    print(f"总计任务: {total_tasks} 个(公司,年份)")
    print(f"已保存公司: {len(saved_companies)}/{total_companies}")
    print(f"总耗时: {total_time:.1f} 秒")
    if total_tasks > 0:
        print(f"平均: {total_time/total_tasks:.1f} 秒/任务")
    print()
    print(f"结果保存在: 各公司目录下")
    print("="*80)


def main():
    """主函数"""
    # 检查是否是批量模式
    if len(sys.argv) >= 2 and sys.argv[1] == '--batch':
        # 批量模式: python LLM2Excel3.py --batch [基础目录] [模型名称] [并发数]
        # 默认基础目录为 output2
        base_dir = sys.argv[2] if len(sys.argv) > 2 else "output2"
        model_name = sys.argv[3] if len(sys.argv) > 3 else "gemma3:1b"
        max_workers = int(sys.argv[4]) if len(sys.argv) > 4 else 2

        batch_process(base_dir, model_name, max_workers)
        return

    # 自动模式: 根据股票代码和名称自动扫描目录
    if len(sys.argv) >= 2 and sys.argv[1] == '--auto':
        # 自动模式: python LLM2Excel3.py --auto <股票代码> <公司名称> [基础目录] [输出目录] [模型]
        if len(sys.argv) < 4:
            print("自动模式用法: python LLM2Excel3.py --auto <股票代码> <公司名称> [基础目录] [输出目录] [模型]")
            print("\n示例:")
            print('  python LLM2Excel3.py --auto 688031 星环科技')
            print('  python LLM2Excel3.py --auto 688009 中国通号 "output2"')
            sys.exit(1)

        stock_code = sys.argv[2]
        company_name = sys.argv[3]
        base_dir = sys.argv[4] if len(sys.argv) > 4 else "output2"
        output_dir = sys.argv[5] if len(sys.argv) > 5 else "./LLM测试输出"
        model_name = sys.argv[6] if len(sys.argv) > 6 else "gemma3:1b"

        print("="*80)
        print("【LLM语义分析 - 自动定位版】")
        print("="*80)
        print(f"股票代码: {stock_code}")
        print(f"公司名称: {company_name}")
        print(f"扫描目录: {base_dir}")
        print(f"输出目录: {output_dir}")
        print(f"主模型: {model_name}")
        print(f"备选模型: deepseek-r1:1.5b, qwen3:0.6b")
        print()

        # 自动定位txt文件
        txt_map = auto_locate_txt_files(base_dir, stock_code, company_name)

        if not txt_map:
            print(f"❌ 未在 {base_dir} 下找到 {stock_code} {company_name} 的txt文件")
            sys.exit(1)

        print(f"✓ 找到 {len(txt_map)} 个txt文件:")
        for t, p in txt_map.items():
            print(f"  - {t}: {p}")
        print()

        # 创建分析器
        analyzer = LLMCombinedAnalyzer(model=model_name)

        # 读取文本（industry 类型追加到 business 中一起分析）
        business_text = analyzer.read_text_file(txt_map.get('business', '')) if 'business' in txt_map else ""
        industry_text = analyzer.read_text_file(txt_map.get('industry', '')) if 'industry' in txt_map else ""
        if industry_text:
            business_text = f"【行业发展趋势】\n{industry_text}\n\n【经营情况】\n{business_text}" if business_text else industry_text
        rd_text = analyzer.read_text_file(txt_map.get('rd', '')) if 'rd' in txt_map else ""
        comp_text = analyzer.read_text_file(txt_map.get('competitiveness', '')) if 'competitiveness' in txt_map else ""

        if not business_text and not rd_text and not comp_text:
            print("❌ 所有文本文件都为空")
            sys.exit(1)

        # 分析
        business_result = analyzer.analyze_business(business_text, stock_code, company_name) if business_text else {'success': False}
        rd_result = analyzer.analyze_rd(rd_text, stock_code, company_name) if rd_text else {'success': False}
        comp_result = analyzer.analyze_competitiveness(comp_text, stock_code, company_name) if comp_text else {'success': False}

        # 保存结果
        business_data = business_result.get('data', []) if business_result['success'] else []
        rd_data = rd_result.get('data', []) if rd_result['success'] else []
        comp_data = comp_result.get('data', []) if comp_result['success'] else []

        # 显示预览
        if business_data:
            print("\n经营情况分析预览:")
            print(pd.DataFrame(business_data).to_string())

        if rd_data:
            print("\n研发成果分析预览:")
            print(pd.DataFrame(rd_data).to_string())

        if comp_data:
            print("\n核心竞争力分析预览:")
            print(pd.DataFrame(comp_data).to_string())

        # 保存
        analyzer.save_combined_results(business_data, rd_data, comp_data, stock_code, company_name, output_dir)

        print("\n" + "="*80)
        print("✓ 分析完成！")
        print("="*80)
        return

    # 单文件模式（兼容旧版）
    if len(sys.argv) < 5:
        print("用法:")
        print("  自动模式:   python LLM2Excel3.py --auto <股票代码> <公司名称> <基础目录> [输出目录] [模型]")
        print("  批量模式:   python LLM2Excel3.py --batch <基础目录> [模型名称] [并发数]")
        print("  单文件模式: python LLM2Excel3.py <股票代码> <公司名称> <经营情况文本> <研发成果文本> [输出目录] [模型]")
        print("\n示例:")
        print('  python LLM2Excel3.py --auto 688031 星环科技 "output KC/提取结果"')
        print('  python LLM2Excel3.py --batch "llm"')
        print('  python LLM2Excel3.py 688049 炬芯科技 "经营情况.txt" "研发成果.txt"')
        sys.exit(1)

    stock_code = sys.argv[1]
    company_name = sys.argv[2]
    business_file = sys.argv[3]
    rd_file = sys.argv[4]
    output_dir = sys.argv[5] if len(sys.argv) > 5 else "./LLM测试输出"
    model_name = sys.argv[6] if len(sys.argv) > 6 else "gemma3:1b"

    print("="*80)
    print("【LLM语义分析 - 合并版】")
    print("="*80)
    print(f"股票代码: {stock_code}")
    print(f"公司名称: {company_name}")
    print(f"经营情况文本: {business_file}")
    print(f"研发成果文本: {rd_file}")
    print(f"输出目录: {output_dir}")
    print(f"主模型: {model_name}")
    print(f"备选模型: deepseek-r1:1.5b, qwen3:0.6b")
    print()

    # 检查文件
    for f in [business_file, rd_file]:
        if not os.path.exists(f):
            print(f"❌ 文件不存在: {f}")
            sys.exit(1)

    # 创建分析器
    analyzer = LLMCombinedAnalyzer(model=model_name)

    # 读取文本
    business_text = analyzer.read_text_file(business_file)
    rd_text = analyzer.read_text_file(rd_file)

    if not business_text and not rd_text:
        print("❌ 两个文本文件都为空")
        sys.exit(1)

    # 分析
    business_result = analyzer.analyze_business(business_text, stock_code, company_name) if business_text else {'success': False}
    rd_result = analyzer.analyze_rd(rd_text, stock_code, company_name) if rd_text else {'success': False}

    # 保存结果
    if business_result['success'] or rd_result['success']:
        business_data = business_result.get('data', [])
        rd_data = rd_result.get('data', [])

        # 显示预览
        if business_data:
            print("\n经营情况分析预览:")
            print(pd.DataFrame(business_data).to_string())

        if rd_data:
            print("\n研发成果分析预览:")
            print(pd.DataFrame(rd_data).to_string())

        # 保存
        analyzer.save_combined_results(business_data, rd_data, [], stock_code, company_name, output_dir)

        print("\n" + "="*80)
        print("✓ 分析完成！")
        print("="*80)
    else:
        print("\n" + "="*80)
        print("❌ 分析失败")
        print("="*80)


if __name__ == '__main__':
    main()


path: scripts/CompanyPortrait.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Portrait - 综合财务数据可视化工具

包含图表（每行两图，3行布局）：
1. 按账龄披露的应收账款期末账面余额
2. 境内外毛利率对比
3. 营业收入与营业成本堆积柱状图
4. 财务费用（利息收支）分组柱状图
5. 管理费用分类（前5项）堆积柱状图
6. 销售费用分类（前5项）堆积柱状图

用法示例：
    python Portrait.py 688002 --save
    python Portrait.py --all
"""

import os
import sys
import re
import json
from pathlib import Path
from dataclasses import dataclass
from typing import Dict, List, Optional

import pandas as pd
import numpy as np
from PIL import Image
import matplotlib.pyplot as plt
from matplotlib import rcParams

# ==================== 配色规范（参考 图表格式及配色规范指引202406.xlsx）====================
COLORS_BAR = [
    '#3B65AE',  # 1 深蓝  RGB(59,101,174)
    '#D1DDF1',  # 2 浅蓝  RGB(209,221,241)
    '#0B318F',  # 3 更深蓝 RGB(11,49,143)
    '#6E87B8',  # 4 中蓝  RGB(110,135,184)
    '#D0CECE',  # 5 浅灰  RGB(208,206,206)
    '#A7BCE4',  # 6 中浅蓝 RGB(167,188,228)
    '#4472C4',  # 7 中蓝  RGB(68,114,196)
    '#BFBFBF',  # 8 灰色  RGB(191,191,191)
]
COLORS_LINE = [
    '#0B318F',  # 1
    '#A7BCE4',  # 2
    '#BA9866',  # 3 金色 RGB(186,152,102)
    '#D0CECE',  # 4
    '#3B65AE',  # 5
    '#BFBFBF',  # 6
]
COLOR_AXIS = '#D9D9D9'       # 坐标轴 RGB(217,217,217)
COLOR_TEXT = '#3E3A39'       # 文字 RGB(62,58,57)

import matplotlib
from matplotlib import font_manager

# ==================== 字体加载 ====================
# 优先 skill assets/fonts/ 目录，其次系统路径
_script_dir = Path(__file__).parent
_local_font_dir = _script_dir.parent / "assets" / "fonts"

font_files = []
if _local_font_dir.exists():
    font_files += [
        str(_local_font_dir / "NotoSansCJK-Regular.ttc"),
        str(_local_font_dir / "NotoSansCJK-Bold.ttc"),
        str(_local_font_dir / "NotoSerifCJK-Regular.ttc"),
    ]
font_files += [
    '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
    '/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc',
    '/usr/share/fonts/opentype/noto/NotoSerifCJK-Regular.ttc',
]

for font_file in font_files:
    try:
        if Path(font_file).exists():
            font_manager.fontManager.addfont(font_file)
    except Exception:
        pass

# 预定义字体属性对象（供局部 text 使用）
_font_candidates = []
if _local_font_dir.exists():
    _font_candidates += [
        str(_local_font_dir / "NotoSansCJK-Regular.ttc"),
        str(_local_font_dir / "NotoSansCJK-Bold.ttc"),
    ]
_font_candidates += [
    '/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
    '/usr/share/fonts/opentype/noto/NotoSansCJK-Bold.ttc',
]

font_sans = None
font_sans_bold = None
for fp in _font_candidates:
    if Path(fp).exists():
        if font_sans is None and 'Regular' in fp:
            font_sans = font_manager.FontProperties(fname=fp)
        if font_sans_bold is None and 'Bold' in fp:
            font_sans_bold = font_manager.FontProperties(fname=fp)
    if font_sans and font_sans_bold:
        break

# ==================== Matplotlib 全局设置 ====================
rcParams['font.family'] = 'sans-serif'
rcParams['font.sans-serif'] = ['Noto Sans CJK SC', 'Noto Sans CJK JP', 'Noto Sans CJK TC', 'DejaVu Sans']
rcParams['axes.unicode_minus'] = False
rcParams['axes.edgecolor'] = COLOR_AXIS
rcParams['axes.linewidth'] = 0.75
rcParams['xtick.color'] = COLOR_TEXT
rcParams['ytick.color'] = COLOR_TEXT
rcParams['axes.labelcolor'] = COLOR_TEXT
rcParams['text.color'] = COLOR_TEXT
rcParams['axes.spines.top'] = False
rcParams['axes.spines.right'] = False

# ==================== 数据解析工具函数 ====================
def parse_money(value) -> Optional[float]:
    """将金额字符串转为浮点数，处理 None/NaN/逗号/空字符串等情况"""
    if pd.isna(value):
        return None
    if isinstance(value, (int, float)):
        return float(value)
    if isinstance(value, str):
        s = value.strip()
        if s in ('', 'None', 'none', '—', '-', 'nan', 'NaN'):
            return None
        s = s.replace(',', '').replace(' ', '')
        try:
            return float(s)
        except ValueError:
            return None
    return None


def parse_percent(value) -> Optional[float]:
    """将百分比字符串或数值转为小数，如 '85.32%' -> 0.8532, 29.28 -> 0.2928"""
    if pd.isna(value):
        return None
    if isinstance(value, (int, float)):
        v = float(value)
        # 如果数值明显大于1，假设是百分比数值（如29.28表示29.28%）
        if v > 1:
            return v / 100
        return v
    if isinstance(value, str):
        s = value.strip().replace('%', '').replace(' ', '')
        try:
            return float(s) / 100
        except ValueError:
            return None
    return None


# ==================== 股票索引类 ====================
@dataclass
class StockIndex:
    dirname: str
    code: str
    name: str
    dir_path: Path
    aging_file: Optional[Path] = None
    revenue_cost_file: Optional[Path] = None       # 营业收入和营业成本情况（主营业务，用于图1/图3）
    revenue_cost_detail_file: Optional[Path] = None # 营业收入、营业成本的分解信息（境内外，用于图2）
    finance_expense_file: Optional[Path] = None
    management_expense_file: Optional[Path] = None
    sales_expense_file: Optional[Path] = None
    rd_expense_file: Optional[Path] = None         # 研发费用（图8）
    impairment_file: Optional[Path] = None         # 资产减值损失（图3）
    non_operating_income_file: Optional[Path] = None  # 营业外收入（图4）
    inventory_file: Optional[Path] = None          # 存货分类（图10）
    employee_compensation_file: Optional[Path] = None # 应付职工薪酬列示（图11）
    fixed_asset_file: Optional[Path] = None        # 固定资产情况（图12）
    ip_file: Optional[Path] = None                 # 知识产权列表（图13）
    rnd_project_file: Optional[Path] = None        # 在研项目情况（图14）
    top5_customer_file: Optional[Path] = None      # 公司前五名客户（图15）
    top5_supplier_file: Optional[Path] = None      # 公司前五名供应商（图16）


class DataIndex:
    """全量数据索引，一次性扫描，后续快速查找"""
    def __init__(self, output_dir: Path):
        self.output_dir = output_dir
        self.stocks: List[StockIndex] = []
        self._code_map: Dict[str, StockIndex] = {}
        self._name_map: Dict[str, StockIndex] = {}
        self._build_index()

    def _build_index(self):
        if not self.output_dir.exists():
            return
        for entry in self.output_dir.iterdir():
            if not entry.is_dir() or entry.name.startswith('.'):
                continue
            parts = entry.name.split('_', 1)
            if len(parts) != 2:
                continue
            code, name = parts
            stock = StockIndex(
                dirname=entry.name,
                code=code,
                name=name,
                dir_path=entry
            )
            # 查找按账龄披露文件（兼容 output 和 output2 的命名方式）
            aging_base = entry / "01_资产负债表附注" / "01_流动资产"
            if aging_base.exists():
                for f in aging_base.iterdir():
                    if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                        if "按账龄披露" in f.name or "应收账款" in f.name:
                            stock.aging_file = f
                            break
            # 查找收入成本文件：
            #   - "营业收入和营业成本情况" 用于图3（主营业务收入/成本）
            #   - "营业收入、营业成本的分解信息" 用于图2（境内外毛利率）
            revenue_base = entry / "02_利润表附注" / "01_收入成本"
            if revenue_base.exists():
                for f in revenue_base.iterdir():
                    if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                        if "营业收入和营业成本情况" in f.name:
                            stock.revenue_cost_file = f
                        elif "营业收入、营业成本的分解信息" in f.name:
                            stock.revenue_cost_detail_file = f
            # 查找期间费用文件
            expense_base = entry / "02_利润表附注" / "02_期间费用"
            if expense_base.exists():
                for f in expense_base.iterdir():
                    if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                        if "财务费用" in f.name:
                            stock.finance_expense_file = f
                        elif "管理费用" in f.name:
                            stock.management_expense_file = f
                        elif "销售费用" in f.name:
                            stock.sales_expense_file = f
                        elif "研发费用" in f.name:
                            stock.rd_expense_file = f
            # 查找其他损益文件
            other_profit_base = entry / "02_利润表附注" / "03_其他损益"
            if other_profit_base.exists():
                for f in other_profit_base.iterdir():
                    if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                        if "资产减值损失" in f.name:
                            stock.impairment_file = f
            # 查找营业外收支文件
            non_operating_base = entry / "02_利润表附注" / "04_营业外收支"
            if non_operating_base.exists():
                for f in non_operating_base.iterdir():
                    if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                        if "营业外收入" in f.name:
                            stock.non_operating_income_file = f
            # 查找存货分类文件
            inventory_base = entry / "01_资产负债表附注" / "01_流动资产"
            if inventory_base.exists():
                for f in inventory_base.iterdir():
                    if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                        if "存货分类" in f.name:
                            stock.inventory_file = f
            # 查找应付职工薪酬列示文件
            liability_base = entry / "01_资产负债表附注" / "03_流动负债"
            if liability_base.exists():
                for f in liability_base.iterdir():
                    if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                        if "应付职工薪酬列示" in f.name:
                            stock.employee_compensation_file = f
            # 查找固定资产情况文件
            non_current_asset_base = entry / "01_资产负债表附注" / "02_非流动资产"
            if non_current_asset_base.exists():
                for f in non_current_asset_base.iterdir():
                    if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                        if "固定资产情况" in f.name:
                            stock.fixed_asset_file = f
            # 查找管理层讨论与分析文件
            management_base = entry / "11_管理层讨论与分析"
            if management_base.exists():
                core_competence_base = management_base / "03_核心竞争力"
                if core_competence_base.exists():
                    for f in core_competence_base.iterdir():
                        if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                            if "知识产权列表" in f.name:
                                stock.ip_file = f
                            elif "在研项目情况" in f.name:
                                stock.rnd_project_file = f
                business_base = management_base / "05_主要经营情况"
                if business_base.exists():
                    for f in business_base.iterdir():
                        if f.is_file() and f.suffix in ('.xlsx', '.xls'):
                            if "公司前五名客户" in f.name:
                                stock.top5_customer_file = f
                            elif "公司前五名供应商" in f.name:
                                stock.top5_supplier_file = f
            self.stocks.append(stock)
            self._code_map[code] = stock
            self._name_map[name] = stock

    def search(self, query: str) -> Optional[StockIndex]:
        """根据代码或名称查找，支持精确匹配和模糊匹配"""
        query = query.strip()
        if not query:
            return None
        if query in self._code_map:
            return self._code_map[query]
        if query in self._name_map:
            return self._name_map[query]
        matches = []
        for s in self.stocks:
            if s.code.startswith(query) or query in s.name:
                matches.append(s)
        if len(matches) == 1:
            return matches[0]
        elif len(matches) > 1:
            print(f"找到多个匹配结果，请输入更精确的名称或代码：")
            for m in matches:
                print(f"  - {m.code} {m.name}")
            return None
        return None

    def list_all(self) -> List[StockIndex]:
        return self.stocks


# ==================== 数据加载：按账龄披露 ====================
def normalize_age_label(label) -> Optional[str]:
    """规范化账龄标签"""
    if pd.isna(label):
        return None
    label = str(label).strip()
    if label.startswith(('其中：', '减：', '加：', '202', '20')):
        return None
    non_age_keywords = ('借款', '往来款', '押金', '保证金', '备用金', '拆借', '代垫', '坏账准备',
                        '第一阶段', '第二阶段', '第三阶段', '转入', '转出')
    if any(kw in label for kw in non_age_keywords):
        return None
    if re.match(r'^(1年以内|6个月以内|6個月以內)', label):
        return '1年以内'
    if '小计' in label or label == '合计':
        return None
    if label in ('其他',):
        return None
    for pattern, std in [
        (r'^1至2年', '1至2年'),
        (r'^2至3年', '2至3年'),
        (r'^3年以上', '3年以上'),
        (r'^3至4年', '3至4年'),
        (r'^4至5年', '4至5年'),
        (r'^5年以上', '5年以上'),
    ]:
        if re.match(pattern, label):
            return std
    return label


def load_aging_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """
    读取按账龄披露 Excel，返回 DataFrame
    行索引：账龄类别；列：各年份；值：期末账面余额（元）
    """
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    all_sheets = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=0)
        except Exception:
            continue
        age_col = None
        val_col = None
        for c in df.columns:
            col_str = str(c)
            if '账龄' in col_str:
                age_col = c
            if '期末账面余额' in col_str:
                val_col = c
        if age_col is None or val_col is None:
            continue
        records = []
        for _, row in df.iterrows():
            label = normalize_age_label(row.get(age_col))
            if label is None:
                continue
            val = parse_money(row.get(val_col))
            if val is None:
                continue
            records.append({'账龄': label, '期末账面余额': val})
        if records:
            all_sheets[year] = records

    if not all_sheets:
        return None

    merged = {}
    for year, records in all_sheets.items():
        seen = {}
        for r in records:
            label = r['账龄']
            val = r['期末账面余额']
            if label not in seen:
                seen[label] = val
            elif val > seen[label]:
                seen[label] = val
        for label, val in seen.items():
            merged.setdefault(label, {})[year] = val

    result_df = pd.DataFrame.from_dict(merged, orient='index')
    if result_df.empty:
        return None
    result_df = result_df[sorted(result_df.columns)]
    result_df.index.name = '账龄'
    return result_df


# ==================== 数据加载：境内外毛利率 ====================
def load_region_gross_margin(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """
    读取营业收入、营业成本的分解信息，提取境内外毛利率。
    兼容 output / output2 两种数据结构：
      - 标准格式：列0=项目，列1=收入，列2=成本
      - output2 格式：列0=合同分类，列1~N 成对出现（收入, 成本），
        可能有多个分类（主营业务/其他业务/合计等），优先取"合计"列对，
        否则取第一个非空的有效列对。
    返回 DataFrame：行=['境内毛利率','境外毛利率']，列=各年份，值=毛利率（小数）
    """
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None

    domestic_labels = {'境内', '国内', '境内销售'}
    foreign_labels = {'境外', '国外', '境外销售'}

    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue

        # 1) 定位“按经营地区分类”标题行
        region_header_idx = None
        for i, row in df.iterrows():
            cell0 = str(row[0]) if pd.notna(row[0]) else ''
            if ('经营地' in cell0 and ('分类' in cell0 or '区' in cell0)) or '境内外' in cell0:
                region_header_idx = i
                break
        if region_header_idx is None:
            continue

        def _label_match(label: str, targets: set) -> bool:
            """模糊匹配标签，如 '其中:境内' -> '境内'"""
            pure = label.strip().lstrip('其中:').lstrip('其中：').strip()
            return pure in targets

        def _extract_rev_cost(row) -> tuple:
            """
            从一行中提取 (revenue, cost)。
            策略：遍历所有可能的（奇数列收入, 偶数列成本）对，
            收集 revenue>0 且 cost>=0 的有效对，取 revenue 最大的一对。
            在数据结构正常时，"合计"列对的 revenue 通常最大，因此会被自然选中；
            若出现数据错位导致"合计"列数值异常偏小，也能自动回退到正确列对。
            """
            candidates = []
            for rev_col in range(1, len(row), 2):  # 1, 3, 5, ...
                cost_col = rev_col + 1
                if cost_col >= len(row):
                    continue
                rev = parse_money(row[rev_col])
                cost = parse_money(row[cost_col])
                if rev is not None and rev > 0 and cost is not None and cost >= 0:
                    candidates.append((rev, cost))

            if candidates:
                return max(candidates, key=lambda x: x[0])
            return None, None

        domestic_revenue = None
        domestic_cost = None
        foreign_revenue = None
        foreign_cost = None

        for i in range(region_header_idx + 1, min(region_header_idx + 15, len(df))):
            row = df.iloc[i]
            label = str(row[0]).strip() if pd.notna(row[0]) else ''
            if not label:
                continue
            # 遇到新的分类标题（如"按商品转让的时间分类"）则停止
            if label.startswith(('按', '合')) and '经营地区' not in label and '境内外' not in label:
                break

            rev, cost = _extract_rev_cost(row)
            if rev is None:
                continue

            if _label_match(label, domestic_labels):
                domestic_revenue = rev
                domestic_cost = cost
            elif _label_match(label, foreign_labels):
                foreign_revenue = rev
                foreign_cost = cost

        dm = None
        fm = None
        if domestic_revenue is not None and domestic_revenue > 0 and domestic_cost is not None:
            dm = (domestic_revenue - domestic_cost) / domestic_revenue
        if foreign_revenue is not None and foreign_revenue > 0 and foreign_cost is not None:
            fm = (foreign_revenue - foreign_cost) / foreign_revenue

        if dm is not None or fm is not None:
            years_data[year] = {'境内毛利率': dm, '境外毛利率': fm}

    if not years_data:
        return None

    df = pd.DataFrame.from_dict(years_data, orient='index').T
    df = df[sorted(df.columns)]
    df.index.name = '类别'
    return df


# ==================== 数据加载：营业收入与营业成本 ====================
def load_revenue_cost_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """
    读取 "营业收入和营业成本情况" 文件，提取主营业务收入/成本。
    文件结构（每个 sheet 含两年数据）：
      Row 0: ['项目', '本期发生额_收入', '本期发生额_成本', '上期发生额_收入', '上期发生额_成本']
      Row 1: ['主营业务', 本期收入, 本期成本, 上期收入, 上期成本]
      Row 2: ['其他业务', ...]
      Row 3: ['合计', ...]
    本期 = sheet 年份，上期 = sheet 年份 - 1。
    优先使用本期数据；若某年份已存在，跳过上期重复数据。
    返回 DataFrame：行=['营业收入','营业成本']，列=年份，值=元
    """
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None

    years_data = {}

    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        sheet_year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue

        if len(df) == 0 or len(df.columns) < 3:
            continue

        # 定位列索引
        header_vals = [str(v) if pd.notna(v) else '' for v in df.iloc[0]]
        current_rev_col = None
        current_cost_col = None
        prev_rev_col = None
        prev_cost_col = None
        for idx, hv in enumerate(header_vals):
            if '本期' in hv and '收入' in hv:
                current_rev_col = idx
            elif '本期' in hv and '成本' in hv:
                current_cost_col = idx
            elif '上期' in hv and '收入' in hv:
                prev_rev_col = idx
            elif '上期' in hv and '成本' in hv:
                prev_cost_col = idx

        # 如果没找到标准表头，尝试固定列（兼容无表头的情况）
        if current_rev_col is None:
            current_rev_col = 1
        if current_cost_col is None:
            current_cost_col = 2
        if prev_rev_col is None:
            prev_rev_col = 3 if len(df.columns) > 3 else None
        if prev_cost_col is None:
            prev_cost_col = 4 if len(df.columns) > 4 else None

        # 读取主营业务行（仅本期发生额）
        for i in range(1, len(df)):
            label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            if '主营' in label:
                rev = parse_money(df.iloc[i, current_rev_col])
                cst = parse_money(df.iloc[i, current_cost_col])
                if rev is not None and cst is not None and rev > 0:
                    years_data[sheet_year] = {'营业收入': rev, '营业成本': cst}
                break

    if not years_data:
        return None

    df = pd.DataFrame.from_dict(years_data, orient='index').T
    df = df[sorted(df.columns)]
    df.index.name = '类别'
    return df


# ==================== 数据加载：财务费用（利息收支） ====================
def load_finance_expense_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None

    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue

        interest_expense = None
        interest_income = None

        first_cell = str(df.iloc[0, 0]) if pd.notna(df.iloc[0, 0]) else ''
        if '项目_' in first_cell or ('本期发生额' in first_cell and '项目' in first_cell):
            for i in range(len(df)):
                cell0 = str(df.iloc[i, 0]) if pd.notna(df.iloc[i, 0]) else ''
                if '项目_' in cell0:
                    label = cell0.split('项目_', 1)[1].strip()
                else:
                    label = cell0.strip()
                val = parse_money(df.iloc[i, 1])
                if val is None:
                    continue
                if ('利息支出' in label or '利息费用' in label) and '租赁负债' not in label:
                    interest_expense = abs(val)
                elif '利息收入' in label and '租赁负债' not in label:
                    interest_income = abs(val)
        else:
            for i in range(1, len(df)):
                label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
                if not label:
                    continue
                if label == '合计':
                    break
                val = parse_money(df.iloc[i, 1])
                if val is None:
                    continue
                if ('利息支出' in label or '利息费用' in label) and '租赁负债' not in label:
                    interest_expense = abs(val)
                elif '利息收入' in label and '租赁负债' not in label:
                    interest_income = abs(val)

        if interest_expense is not None or interest_income is not None:
            years_data[year] = {
                '利息支出': interest_expense if interest_expense is not None else 0,
                '利息收入': interest_income if interest_income is not None else 0,
            }

    if not years_data:
        return None

    df = pd.DataFrame.from_dict(years_data, orient='index').T
    df = df[sorted(df.columns)]
    df.index.name = '类别'
    return df


# ==================== 项目名称归一化 ====================
def normalize_expense_label(label: str) -> str:
    """将费用项目名称归一化，统一不同年份的类似表述"""
    label = label.strip()
    if re.search(r'股份支付|股权激励', label):
        return '股份支付'
    if re.search(r'折旧.*摊销|摊销.*折旧|使用权资产折旧|固定资产折旧|无形资产摊销|低值易耗品摊销|折旧费', label):
        return '折旧及摊销'
    if re.search(r'房租|物业|租赁|租金', label):
        return '房租物业费'
    if re.search(r'人工|职工|人员|员工|薪酬|工资', label):
        return '人工成本'
    if re.search(r'市场|推广|广告|宣传|营销|展览|参展', label):
        return '市场推广费'
    if re.search(r'维修|保修|售后|质保|产品质量保证|预计产品质量保证|客户端维护|修理费', label):
        return '维修及售后费'
    if re.search(r'差旅|交通', label):
        return '差旅交通费'
    if re.search(r'业务招待|招待费', label):
        return '业务招待费'
    if re.search(r'咨询|中介', label):
        return '咨询及中介费'
    if re.search(r'销售服务', label):
        return '销售服务费'
    if re.search(r'办公|会务|会议', label):
        return '办公会务费'
    if re.search(r'机物料|物料消耗|低值易耗品|原材料|耗用的原材料', label):
        return '物料消耗'
    if re.search(r'水电|燃料动力', label):
        return '水电费'
    if re.search(r'技术服务', label):
        return '技术服务费'
    if re.search(r'外部服务|外采服务', label):
        return '外部服务费'
    if re.search(r'仓储|物流|运输', label):
        return '物流仓储费'
    if re.search(r'邮电|通讯', label):
        return '邮电通讯费'
    if re.search(r'软件|IT', label):
        return '软件及IT费'
    if re.search(r'车辆', label):
        return '车辆费'
    if re.search(r'保险', label):
        return '保险费'
    if re.search(r'专利', label):
        return '专利费'
    if re.search(r'招聘', label):
        return '招聘费'
    if re.search(r'样品', label):
        return '样品费'
    if re.search(r'装修', label):
        return '装修费'
    if re.search(r'诉讼', label):
        return '诉讼费'
    if re.search(r'佣金', label):
        return '佣金'
    if re.search(r'安全生产', label):
        return '安全生产费'
    if re.search(r'残保金|残疾人就业保障金', label):
        return '残保金'
    if re.search(r'顾问', label):
        return '顾问费'
    if re.search(r'投标|招标', label):
        return '招投标费'
    if re.search(r'特许权使用', label):
        return '特许权使用费'
    if re.search(r'劳保', label):
        return '劳保费'
    if re.search(r'厂务杂费', label):
        return '厂务杂费'
    if re.search(r'存货报废', label):
        return '存货报废'
    if re.search(r'税金|税费', label):
        return '税金'
    if re.search(r'服务费', label):
        return '服务费'
    return label


# ==================== 数据加载：费用分类（前5项） ====================
def load_expense_top5_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None

    all_years_data = {}
    item_totals = {}

    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue

        year_data = {}
        header_detected = False

        for i in range(len(df)):
            cell0 = str(df.iloc[i, 0]) if pd.notna(df.iloc[i, 0]) else ''
            if not cell0 or cell0.strip() == '':
                continue
            if '项目' in cell0:
                header_detected = True
                continue
            if not header_detected:
                continue

            label = cell0.strip()
            if label == '合计':
                break
            if label in ('其他',):
                continue

            val = parse_money(df.iloc[i, 1])
            if val is not None and val != 0:
                norm_label = normalize_expense_label(label)
                year_data[norm_label] = year_data.get(norm_label, 0) + val
                item_totals[norm_label] = item_totals.get(norm_label, 0) + val

        if year_data:
            all_years_data[year] = year_data

    if not all_years_data or not item_totals:
        return None

    top5_items = sorted(item_totals.keys(), key=lambda x: item_totals[x], reverse=True)[:5]

    result = {}
    for year, data in all_years_data.items():
        result[year] = {item: data.get(item, 0) for item in top5_items}

    df = pd.DataFrame.from_dict(result, orient='index').T
    df = df[sorted(df.columns)]
    df.index.name = '项目'
    return df


# ==================== 新增数据加载函数 ====================
def load_revenue_gross_margin_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """基于营业收入和营业成本计算主营毛利率，同时保留收入/成本金额（单位：元）"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        sheet_year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        if len(df) == 0 or len(df.columns) < 3:
            continue
        header_vals = [str(v) if pd.notna(v) else '' for v in df.iloc[0]]
        rev_col = None
        cost_col = None
        for idx, hv in enumerate(header_vals):
            if '本期' in hv and '收入' in hv:
                rev_col = idx
            elif '本期' in hv and '成本' in hv:
                cost_col = idx
        if rev_col is None:
            rev_col = 1
        if cost_col is None:
            cost_col = 2
        for i in range(1, len(df)):
            label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            if '主营' in label:
                rev = parse_money(df.iloc[i, rev_col])
                cst = parse_money(df.iloc[i, cost_col])
                if rev is not None and cst is not None and rev > 0:
                    years_data[sheet_year] = {
                        '营业收入': rev,
                        '营业成本': cst,
                        '主营毛利率': (rev - cst) / rev,
                    }
                break
    if not years_data:
        return None
    df = pd.DataFrame.from_dict(years_data, orient='index').T
    df = df[sorted(df.columns)]
    return df


def load_impairment_top3_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """资产减值损失前三项本期发生额"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    all_years_data = {}
    all_items = set()
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        year_data = {}
        for i in range(1, len(df)):
            label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            if not label or '合计' in label:
                continue
            val = parse_money(df.iloc[i, 1])
            if val is not None and val != 0:
                # 简写过长的标签
                short_label = label[:8] + '...' if len(label) > 10 else label
                year_data[short_label] = abs(val)
                all_items.add(short_label)
        if year_data:
            all_years_data[year] = year_data
    if not all_years_data or not all_items:
        return None
    result = {}
    for year, data in all_years_data.items():
        result[year] = {item: data.get(item, 0) for item in all_items}
    df = pd.DataFrame.from_dict(result, orient='index').T.fillna(0)
    df = df[sorted(df.columns)]
    df.index.name = '项目'
    return df


def load_non_operating_income_top3_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """营业外收入前三项本期发生额"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    all_years_data = {}
    all_items = set()
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        year_data = {}
        for i in range(1, len(df)):
            label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            if not label or '合计' in label or label.startswith('其中：') or label.startswith('非流动资产'):
                continue
            val = parse_money(df.iloc[i, 1])
            if val is not None and val > 0:
                short_label = label[:8] + '...' if len(label) > 10 else label
                year_data[short_label] = val
                all_items.add(short_label)
        if year_data:
            all_years_data[year] = year_data
    if not all_years_data or not all_items:
        return None
    result = {}
    for year, data in all_years_data.items():
        result[year] = {item: data.get(item, 0) for item in all_items}
    df = pd.DataFrame.from_dict(result, orient='index').T.fillna(0)
    df = df[sorted(df.columns)]
    df.index.name = '项目'
    return df


def load_inventory_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """存货分类期末账面余额"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    all_categories = set()
    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=0)
        except Exception:
            continue
        # 找到"期末余额_账面余额"列
        val_col = None
        for c in df.columns:
            col_str = str(c)
            if '期末余额' in col_str and '账面余额' in col_str:
                val_col = c
                break
        if val_col is None:
            continue
        year_data = {}
        for _, row in df.iterrows():
            label = str(row.iloc[0]).strip() if pd.notna(row.iloc[0]) else ''
            if not label or '合计' in label:
                continue
            val = parse_money(row.get(val_col))
            if val is not None and val > 0:
                short_label = label[:6] + '...' if len(label) > 8 else label
                year_data[short_label] = val
                all_categories.add(short_label)
        if year_data:
            years_data[year] = year_data
    if not years_data:
        return None
    result = {}
    for year, data in years_data.items():
        result[year] = {cat: data.get(cat, 0) for cat in sorted(all_categories)}
    df = pd.DataFrame.from_dict(result, orient='index').T
    df = df[sorted(df.columns)]
    df.index.name = '存货类别'
    return df


def load_employee_compensation_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """本期辞退福利 / 应付职工薪酬合计"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        severance = None
        total = None
        for i in range(1, len(df)):
            label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            if '辞退福利' in label:
                severance = parse_money(df.iloc[i, 2])
            elif label == '合计':
                total = parse_money(df.iloc[i, 2])
        if total is not None and total > 0:
            ratio = (severance / total * 100) if severance is not None else 0
            years_data[year] = ratio
    if not years_data:
        return None
    df = pd.DataFrame({'辞退福利占比': list(years_data.values())}, index=list(years_data.keys())).T
    df = df[sorted(df.columns)]
    return df


def load_fixed_asset_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """固定资产减值准备 / 账面原值"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        original_cost = None
        impairment = None
        current_section = None  # one of: original / depreciation / impairment / book_value
        for i in range(len(df)):
            cell0 = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            normalized_cell0 = re.sub(r'[\s_：:]+', '', cell0)

            # 先按当前章节抓取“期末余额”，避免“2.期末余额_四、账面价值”这类同格拼接被漏读
            if '期末余额' in normalized_cell0:
                last_col = len(df.columns) - 1
                value = parse_money(df.iloc[i, last_col])
                if value is not None:
                    if current_section == 'original' and original_cost is None:
                        original_cost = value
                    elif current_section == 'impairment' and impairment is None:
                        impairment = value

                    if original_cost is not None and impairment is not None:
                        break

            if '一、账面原值' in normalized_cell0:
                current_section = 'original'
                continue
            if '二、累计折旧' in normalized_cell0:
                current_section = 'depreciation'
                continue
            if '三、减值准备' in normalized_cell0:
                current_section = 'impairment'
                continue
            if '四、账面价值' in normalized_cell0:
                current_section = 'book_value'
                # 进入下一节后，若减值值已取到即可提前结束
                if impairment is not None:
                    break
                continue
        if original_cost is not None and original_cost > 0:
            ratio = (impairment / original_cost * 100) if impairment is not None else 0
            years_data[year] = ratio
    if not years_data:
        return None
    df = pd.DataFrame({'减值准备占比': list(years_data.values())}, index=list(years_data.keys())).T
    df = df[sorted(df.columns)]
    return df


def load_ip_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """发明专利/合计（累计获得数）"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        invention = None
        total = None
        for i in range(1, len(df)):
            label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            if label == '发明专利':
                invention = parse_money(df.iloc[i, 4])
            elif label == '合计':
                total = parse_money(df.iloc[i, 4])
        if invention is not None and total is not None and total > 0:
            years_data[year] = {'发明专利': invention, '合计': total}
    if not years_data:
        return None
    df = pd.DataFrame.from_dict(years_data, orient='index').T
    df = df[sorted(df.columns)]
    return df


def load_rnd_projects_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """在研项目数量及投资金额（本期投入金额合计）"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        count = max(0, len(df) - 1)
        total_investment = 0
        for i in range(1, len(df)):
            val = parse_money(df.iloc[i, 3])
            if val is not None:
                total_investment += val
        years_data[year] = {'项目数量': count, '投资金额': total_investment}
    if not years_data:
        return None
    df = pd.DataFrame.from_dict(years_data, orient='index').T
    df = df[sorted(df.columns)]
    return df


def load_customer_concentration_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """客户集中度（前五大客户销售占比）"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        ratio = None
        for i in range(1, len(df)):
            label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            if label == '合计':
                ratio = parse_percent(df.iloc[i, 3])
                if ratio is None:
                    ratio = parse_money(df.iloc[i, 3])
                    if ratio is not None and ratio > 1:
                        ratio = ratio / 100
                break
        if ratio is not None:
            # ratio 此时是分数形式（如 0.1591 = 15.91%）
            # 如果结果异常大（>1），说明原始数据被错误放大，尝试修正
            if ratio > 1:
                money_val = parse_money(df.iloc[i, 3])
                if money_val is not None:
                    if money_val > 10000:
                        ratio = money_val / 10000 / 100
                    elif money_val > 100:
                        ratio = money_val / 100 / 100
            years_data[year] = ratio * 100  # 转为百分比数值（如 15.91）供图表显示
    if not years_data:
        return None
    df = pd.DataFrame({'客户集中度': list(years_data.values())}, index=list(years_data.keys())).T
    df = df[sorted(df.columns)]
    return df


def load_supplier_concentration_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """供应商集中度（前五大供应商采购占比）"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    years_data = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        ratio = None
        for i in range(1, len(df)):
            label = str(df.iloc[i, 0]).strip() if pd.notna(df.iloc[i, 0]) else ''
            if label == '合计':
                ratio = parse_percent(df.iloc[i, 3])
                if ratio is None:
                    ratio = parse_money(df.iloc[i, 3])
                    if ratio is not None and ratio > 1:
                        ratio = ratio / 100
                break
        if ratio is not None:
            # ratio 此时是分数形式（如 0.1591 = 15.91%）
            # 如果结果异常大（>1），说明原始数据被错误放大，尝试修正
            if ratio > 1:
                money_val = parse_money(df.iloc[i, 3])
                if money_val is not None:
                    if money_val > 10000:
                        ratio = money_val / 10000 / 100
                    elif money_val > 100:
                        ratio = money_val / 100 / 100
            years_data[year] = ratio * 100  # 转为百分比数值（如 15.91）供图表显示
    if not years_data:
        return None
    df = pd.DataFrame({'供应商集中度': list(years_data.values())}, index=list(years_data.keys())).T
    df = df[sorted(df.columns)]
    return df


# ==================== LLM 语义分析数据加载 ====================

def load_llm_competitiveness_summary(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """读取 LLM 核心竞争力分析，返回最新年份的精简摘要"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None

    # 只取最新年份
    target_year = None
    for sheet_name in xl.sheet_names:
        if '核心竞争力' not in sheet_name:
            continue
        year_match = re.search(r'(\d{4})', sheet_name)
        if year_match:
            y = int(year_match.group(1))
            if target_year is None or y > target_year:
                target_year = y

    if target_year is None:
        return None

    rows = []
    for sheet_name in xl.sheet_names:
        if '核心竞争力' not in sheet_name:
            continue
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match or int(year_match.group(1)) != target_year:
            continue
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name)
        except Exception:
            continue
        for _, row in df.iterrows():
            dim = str(row.get('竞争维度', '')).strip()
            adv = str(row.get('核心优势', '')).strip()
            if not dim:
                continue
            adv_short = adv[:55] + '...' if len(adv) > 55 else adv
            rows.append({'年份': str(target_year), '竞争维度': dim, '核心优势': adv_short})
    if not rows:
        return None
    return pd.DataFrame(rows)


def load_llm_business_summary(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """读取 LLM 经营情况分析，返回最新年份的业务板块摘要"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None

    # 只取最新年份
    target_year = None
    for sheet_name in xl.sheet_names:
        if '经营情况' not in sheet_name:
            continue
        year_match = re.search(r'(\d{4})', sheet_name)
        if year_match:
            y = int(year_match.group(1))
            if target_year is None or y > target_year:
                target_year = y

    if target_year is None:
        return None

    rows = []
    for sheet_name in xl.sheet_names:
        if '经营情况' not in sheet_name:
            continue
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match or int(year_match.group(1)) != target_year:
            continue
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name)
        except Exception:
            continue
        for _, row in df.iterrows():
            biz = str(row.get('业务板块', '')).strip()
            goal = str(row.get('战略目标', '')).strip()
            if not biz or biz in ['研发', '销售', '市场营销', '生产', '财务']:
                continue
            goal_short = goal[:45] + '...' if len(goal) > 45 else goal
            rows.append({'年份': str(target_year), '业务板块': biz, '战略目标': goal_short})
    if not rows:
        return None
    return pd.DataFrame(rows)


def plot_portrait_trend_header(portrait_summary_items: list, stock_name: str, stock_code: str):
    """
    绘制 Portrait 财务趋势分析头部图
    5 个维度卡片式横向排列 - 优化版
    """
    import textwrap
    
    if not portrait_summary_items:
        return Image.new('RGB', (1, 1), 'white')

    # 配色：5 个维度各一种颜色
    dim_colors = {
        '营收与盈利': '#1565C0',
        '费用结构': '#6A1B9A',
        '资产质量': '#C62828',
        '研发创新': '#2E7D32',
        '供应链风险': '#E65100',
    }
    dim_bg_colors = {
        '营收与盈利': '#E3F2FD',
        '费用结构': '#F3E5F5',
        '资产质量': '#FFEBEE',
        '研发创新': '#E8F5E9',
        '供应链风险': '#FFF3E0',
    }

    # 增加画布高度，避免重叠
    fig_height = 5.2
    fig, ax = plt.subplots(figsize=(14, fig_height))
    fig.patch.set_facecolor('#FAFAFA')
    ax.set_xlim(0, 14)
    ax.set_ylim(0, fig_height)
    ax.axis('off')

    # 标题区域（顶部，不占卡片空间）
    title_kwargs = {'fontproperties': font_sans} if font_sans else {}
    ax.text(7, fig_height - 0.3, f'{stock_code} {stock_name}',
            ha='center', va='top', fontsize=20, fontweight='bold',
            color='#1A1A1A', **title_kwargs)
    ax.text(7, fig_height - 0.75, '三年财务趋势摘要 · LLM智能分析',
            ha='center', va='top', fontsize=12, fontweight='normal',
            color='#666666', **title_kwargs)

    # 卡片区域（标题下方）
    n_items = len(portrait_summary_items)
    card_width = 13.2 / n_items
    gap = 0.15
    card_height = 3.6
    y_start = 0.25  # 卡片起始位置

    for i, item in enumerate(portrait_summary_items):
        dim = item.get('维度', '')
        trend = item.get('三年趋势', '')
        change = item.get('关键变化', '')
        risk = item.get('风险提示', '')
        conclusion = item.get('结论', '')

        color = dim_colors.get(dim, '#555555')
        bg_color = dim_bg_colors.get(dim, '#F5F5F5')

        x0 = 0.4 + i * (card_width + gap)
        y0 = y_start
        cw = card_width - gap
        ch = card_height

        # 卡片背景（圆角效果）
        rect = plt.Rectangle((x0, y0), cw, ch, facecolor=bg_color,
                              edgecolor=color, linewidth=2.5, 
                              joinstyle='round', zorder=1)
        ax.add_patch(rect)

        # 顶部色条
        bar_height = 0.45
        bar = plt.Rectangle((x0, y0 + ch - bar_height), cw, bar_height, 
                            facecolor=color, zorder=2)
        ax.add_patch(bar)

        # 维度名称（白色，在色条上）
        ax.text(x0 + cw / 2, y0 + ch - bar_height/2, dim, 
                ha='center', va='center',
                fontsize=10, fontweight='bold', color='white', 
                **title_kwargs)

        # 内容区域起始位置
        content_y = y0 + ch - bar_height - 0.25
        line_height = 0.38
        
        # 三年趋势（彩色标签）
        trend_color = '#2E7D32' if '增长' in trend or '升' in trend else '#C62828' if '降' in trend else '#555555'
        ax.text(x0 + 0.12, content_y, f'趋势: {trend}', 
                ha='left', va='top',
                fontsize=9, color=trend_color, fontweight='bold', 
                **title_kwargs)
        
        # 关键变化（自动换行）
        content_y -= line_height
        max_chars = int(cw * 8)  # 根据卡片宽度计算最大字符数
        change_text = change[:80] + '...' if len(change) > 80 else change
        wrapped_lines = textwrap.wrap(change_text, width=max_chars//2)
        n_lines = min(len(wrapped_lines), 2)  # 实际显示行数
        for j, line in enumerate(wrapped_lines[:2]):  # 最多显示2行
            ax.text(x0 + 0.12, content_y - j*0.32, line, 
                    ha='left', va='top',
                    fontsize=8, color='#333333', 
                    **title_kwargs)
        content_y -= (n_lines - 1) * 0.32 + 0.45  # 更新 content_y，为下一项留出空间
        
        # 风险提示（如果有）
        if risk and risk != '无':
            risk_text = f'风险: {risk[:30]}' if len(risk) > 30 else f'风险: {risk}'
            ax.text(x0 + 0.12, content_y, risk_text, 
                    ha='left', va='top',
                    fontsize=8, color='#D32F2F', 
                    **title_kwargs)
            content_y -= 0.5  # 更新 content_y
        
        # 结论（底部，加粗，带背景）
        concl_text = conclusion[:35] + '...' if len(conclusion) > 35 else conclusion
        ax.text(x0 + cw / 2, y0 + 0.35, concl_text, 
                ha='center', va='center',
                fontsize=9, color=color, fontweight='bold',
                bbox=dict(boxstyle='round,pad=0.3', facecolor='white', 
                         edgecolor=color, linewidth=1.5, alpha=0.9),
                **title_kwargs)

    plt.tight_layout(pad=0.3)

    tmp = f'/tmp/portrait_header_{stock_code}.png'
    fig.savefig(tmp, dpi=300, bbox_inches='tight',
                facecolor='#FAFAFA', edgecolor='none')
    plt.close(fig)

    return Image.open(tmp)


def plot_llm_summary_figure(comp_df: pd.DataFrame, biz_df: pd.DataFrame,
                            stock_name: str, stock_code: str) -> 'PIL.Image.Image':
    """生成 LLM 语义分析摘要图，双栏文本布局，返回 PIL Image"""
    n_biz = len(biz_df) if biz_df is not None else 0
    n_comp = len(comp_df) if comp_df is not None else 0
    max_items = max(n_biz, n_comp)
    # 每行约 0.55 英寸，标题占 1.2 英寸
    height = max(5, max_items * 0.55 + 1.7)

    fig, (ax_left, ax_right) = plt.subplots(1, 2, figsize=(14, height))

    fig.patch.set_facecolor('#FAFAFA')
    ax_left.set_facecolor('#F0F7FF')
    ax_right.set_facecolor('#F0FFF4')

    for ax in [ax_left, ax_right]:
        ax.set_xlim(0, 1)
        ax.set_ylim(0, 1)
        ax.axis('off')

    fig.suptitle(f'{stock_code} {stock_name}  |  LLM 语义分析摘要',
                 fontsize=20, fontweight='bold', color='#1565C0', y=0.98)

    # ---- 左栏：经营战略 ----
    ax_left.text(0.5, 0.96, '经营战略',
                 transform=ax_left.transAxes,
                 fontsize=16, fontweight='bold', ha='center', va='top',
                 color='white',
                 bbox=dict(boxstyle='round,pad=0.35', facecolor='#1565C0',
                          edgecolor='#0D47A1', linewidth=2))

    y = 0.88
    step = 0.13

    if biz_df is not None and not biz_df.empty:
        for _, row in biz_df.iterrows():
            biz = row['业务板块']
            goal = row['战略目标'][:80] + ('…' if len(row['战略目标']) > 80 else '')
            ax_left.text(0.06, y, f'● {biz}',
                        transform=ax_left.transAxes,
                        fontsize=13, fontweight='bold', va='top', color='#0D47A1')
            ax_left.text(0.10, y - 0.035, goal,
                        transform=ax_left.transAxes,
                        fontsize=11, va='top', color='#333333',
                        linespacing=1.4)
            y -= step
    else:
        ax_left.text(0.5, 0.5, '暂无经营战略数据', transform=ax_left.transAxes,
                     fontsize=12, ha='center', va='center', color='#999999')

    # ---- 右栏：科研成果与核心优势 ----
    ax_right.text(0.5, 0.96, '科研成果与核心优势',
                  transform=ax_right.transAxes,
                  fontsize=16, fontweight='bold', ha='center', va='top',
                  color='white',
                  bbox=dict(boxstyle='round,pad=0.35', facecolor='#2E7D32',
                           edgecolor='#1B5E20', linewidth=2))

    y = 0.88
    if comp_df is not None and not comp_df.empty:
        for _, row in comp_df.iterrows():
            dim = row['竞争维度']
            adv = row['核心优势'][:100] + ('…' if len(row['核心优势']) > 100 else '')
            ax_right.text(0.06, y, f'● {dim}',
                         transform=ax_right.transAxes,
                         fontsize=13, fontweight='bold', va='top', color='#1B5E20')
            ax_right.text(0.10, y - 0.035, adv,
                         transform=ax_right.transAxes,
                         fontsize=11, va='top', color='#333333',
                         linespacing=1.4)
            y -= step
    else:
        ax_right.text(0.5, 0.5, '暂无核心竞争力数据', transform=ax_right.transAxes,
                      fontsize=12, ha='center', va='center', color='#999999')

    plt.tight_layout(rect=[0.02, 0.02, 0.98, 0.94])
    fig.savefig('/tmp/_llm_tmp.png', dpi=300, bbox_inches='tight',
                facecolor=fig.get_facecolor())
    plt.close(fig)
    from PIL import Image
    return Image.open('/tmp/_llm_tmp.png')


def normalize_rd_expense_label(label: str) -> str:
    """研发费用项目归一化"""
    label = label.strip()
    if re.search(r'股份支付', label):
        return '股份支付'
    if re.search(r'折旧.*摊销|摊销.*折旧|使用权资产折旧|固定资产折旧|无形资产摊销|低值易耗品摊销|折旧费', label):
        return '折旧及摊销'
    if re.search(r'人工|职工|人员|员工|薪酬|工资', label):
        return '人工成本'
    if re.search(r'机物料|物料消耗|低值易耗品|原材料', label):
        return '物料消耗'
    if re.search(r'差旅|交通', label):
        return '差旅费'
    if re.search(r'咨询|中介', label):
        return '咨询费'
    if re.search(r'技术服务', label):
        return '技术服务费'
    if re.search(r'测试|化验|加工', label):
        return '测试化验加工费'
    if re.search(r'房租|物业|租赁|租金', label):
        return '房租物业费'
    if re.search(r'燃料|动力|水电', label):
        return '燃料动力费'
    if re.search(r'办公|会务|会议', label):
        return '办公费'
    if re.search(r'软件|IT', label):
        return '软件及IT费'
    return label


def load_rd_expense_top5_data(xlsx_path: Path) -> Optional[pd.DataFrame]:
    """研发费用前5项"""
    if not xlsx_path or not xlsx_path.exists():
        return None
    try:
        xl = pd.ExcelFile(xlsx_path)
    except Exception:
        return None
    all_years_data = {}
    item_totals = {}
    for sheet_name in xl.sheet_names:
        year_match = re.search(r'(\d{4})', sheet_name)
        if not year_match:
            continue
        year = int(year_match.group(1))
        try:
            df = pd.read_excel(xl, sheet_name=sheet_name, header=None)
        except Exception:
            continue
        year_data = {}
        first_cell = str(df.iloc[0, 0]) if pd.notna(df.iloc[0, 0]) else ''
        malformed = '项目_' in first_cell
        for i in range(len(df)):
            cell0 = str(df.iloc[i, 0]) if pd.notna(df.iloc[i, 0]) else ''
            if malformed and '项目_' in cell0:
                label = cell0.split('项目_', 1)[1].strip()
                val = parse_money(df.iloc[i, 1])
            elif malformed:
                label = cell0.strip()
                val = parse_money(df.iloc[i, 1])
            else:
                if i == 0 or '项目' in cell0:
                    continue
                label = cell0.strip()
                val = parse_money(df.iloc[i, 1])
            if not label or label in ('合计', '其他'):
                continue
            if val is not None and val != 0:
                norm_label = normalize_rd_expense_label(label)
                year_data[norm_label] = year_data.get(norm_label, 0) + val
                item_totals[norm_label] = item_totals.get(norm_label, 0) + val
        if year_data:
            all_years_data[year] = year_data
    if not all_years_data or not item_totals:
        return None
    top5_items = sorted(item_totals.keys(), key=lambda x: item_totals[x], reverse=True)[:5]
    result = {}
    for year, data in all_years_data.items():
        result[year] = {item: data.get(item, 0) for item in top5_items}
    df = pd.DataFrame.from_dict(result, orient='index').T
    df = df[sorted(df.columns)]
    df.index.name = '项目'
    return df


# ==================== 绘图函数 ====================
def setup_chart_style(ax):
    ax.spines['top'].set_visible(False)
    ax.spines['right'].set_visible(False)
    ax.spines['left'].set_color(COLOR_AXIS)
    ax.spines['bottom'].set_color(COLOR_AXIS)
    ax.spines['left'].set_linewidth(0.75)
    ax.spines['bottom'].set_linewidth(0.75)
    ax.tick_params(axis='both', which='both', length=0)
    ax.grid(axis='y', linestyle='--', alpha=0.5, color=COLOR_AXIS)


def plot_aging_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """按账龄披露的应收账款期末账面余额"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无账龄披露数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n按账龄披露的应收账款期末账面余额', fontsize=14, fontweight="bold")
        ax.axis('off')
        return

    df_plot = df / 1e8
    years = df_plot.columns.tolist()
    x = np.arange(len(df_plot.index))
    width = 0.8 / len(years) if years else 0.4

    for i, year in enumerate(years):
        offset = width * (i - (len(years) - 1) / 2)
        color = COLORS_BAR[i % len(COLORS_BAR)]
        bars = ax.bar(
            x + offset,
            df_plot[year].fillna(0),
            width,
            label=f'{year}年',
            color=color,
            edgecolor='white',
            linewidth=0.5
        )
        for bar, val in zip(bars, df_plot[year]):
            if pd.notna(val) and val > 0:
                ax.text(
                    bar.get_x() + bar.get_width() / 2,
                    bar.get_height(),
                    f'{val:.2f}',
                    ha='center', va='bottom', fontsize=6, color=COLOR_TEXT
                )

    ax.set_xticks(x)
    ax.set_xticklabels(df_plot.index, rotation=30, ha='right', fontsize=8)
    ax.set_ylabel('期末账面余额（亿元）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n按账龄披露的应收账款期末账面余额', fontsize=14, fontweight="bold", pad=20)
    ax.legend(
        title='年份', title_fontsize=7, fontsize=7,
        loc='upper center', bbox_to_anchor=(0.5, 1.06),
        ncol=min(len(years), 4), frameon=False
    )
    setup_chart_style(ax)


def plot_margin_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """境内外毛利率对比"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无境内外毛利率数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n境内外毛利率对比', fontsize=14, fontweight="bold")
        ax.axis('off')
        return

    years = df.columns.tolist()
    categories = df.index.tolist()
    x = np.arange(len(years))
    width = 0.35

    colors = [COLORS_BAR[0], COLORS_LINE[2]]

    for i, cat in enumerate(categories):
        vals = df.loc[cat].values
        vals_display = [v * 100 if pd.notna(v) else 0 for v in vals]
        bars = ax.bar(
            x + (i - (len(categories) - 1) / 2) * width,
            vals_display,
            width,
            label=cat,
            color=colors[i % len(colors)],
            edgecolor='white',
            linewidth=0.5
        )
        for bar, val in zip(bars, vals):
            if pd.notna(val):
                ax.text(
                    bar.get_x() + bar.get_width() / 2,
                    bar.get_height(),
                    f'{val*100:.1f}%',
                    ha='center', va='bottom', fontsize=7, color=COLOR_TEXT
                )

    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('毛利率', fontsize=9)
    ax.set_ylim(0, min(100, ax.get_ylim()[1] * 1.15))
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0f}%'))
    ax.set_title(f'{stock_code} {stock_name}\n境内外毛利率对比', fontsize=14, fontweight="bold", pad=20)
    ax.legend(
        title='', fontsize=8,
        loc='upper center', bbox_to_anchor=(0.5, 1.06),
        ncol=len(categories), frameon=False
    )
    setup_chart_style(ax)


def plot_revenue_cost_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """营业收入与营业成本正负堆积柱状图
    营业收入在0轴上方（正值），营业成本在0轴下方（负值），
    0轴用深红色粗线标识为'重叠处'
    """
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n营业收入与营业成本', fontsize=14, fontweight="bold")
        ax.axis('off')
        return

    years = df.columns.tolist()
    revenue = df.loc['营业收入'].values
    cost = df.loc['营业成本'].values

    revenue_yi = revenue / 1e8
    cost_yi = cost / 1e8

    x = np.arange(len(years))
    width = 0.5

    # 营业成本：负值（0轴下方）
    ax.bar(
        x, -cost_yi, width,
        label='营业成本',
        color=COLORS_BAR[1],
        edgecolor='white',
        linewidth=0.5
    )
    # 营业收入：正值（0轴上方）
    ax.bar(
        x, revenue_yi, width,
        label='营业收入',
        color=COLORS_BAR[0],
        edgecolor='white',
        linewidth=0.5
    )

    # 0轴重叠处：深红色粗线
    ax.axhline(0, color='#920000', linewidth=2.5, zorder=5)

    for i, (rev, cst) in enumerate(zip(revenue_yi, cost_yi)):
        # 营业收入标注（正半轴顶部）
        ax.text(
            x[i], rev,
            f'{rev:.2f}',
            ha='center', va='bottom', fontsize=8, color=COLOR_TEXT, fontweight='bold'
        )
        # 营业成本标注（负半轴底部，显示为正数）
        ax.text(
            x[i], -cst,
            f'{cst:.2f}',
            ha='center', va='top', fontsize=8, color=COLOR_TEXT, fontweight='bold'
        )
        # 毛利标注（放在正半轴内部偏下位置）
        gross = rev - cst
        ax.text(
            x[i], rev * 0.15,
            f'毛利 {gross:.2f}',
            ha='center', va='center', fontsize=7, color='white', fontweight='bold'
        )

    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('金额（亿元）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n营业收入与营业成本', fontsize=14, fontweight="bold", pad=20)
    ax.legend(
        title='', fontsize=8,
        loc='upper center', bbox_to_anchor=(0.5, 1.06),
        ncol=2, frameon=False
    )
    setup_chart_style(ax)
    ymax = max(ax.get_ylim()[1], max(revenue_yi) * 1.15)
    ymin = min(ax.get_ylim()[0], -max(cost_yi) * 1.15)
    ax.set_ylim(ymin, ymax)


def plot_finance_expense_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """财务费用（利息收支）分组柱状图
    利息支出为正值（向上），利息收入为负值（向下），直观显示净财务费用
    """
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n财务费用（利息收支）', fontsize=14, fontweight="bold")
        ax.axis('off')
        return

    years = df.columns.tolist()
    expense = df.loc['利息支出'].values
    income = df.loc['利息收入'].values

    # 利息收入始终显示为负值（向下）
    expense_wan = expense / 1e4
    income_wan = -np.abs(income) / 1e4

    x = np.arange(len(years))
    width = 0.35

    bars_exp = ax.bar(
        x - width / 2, expense_wan, width,
        label='利息支出',
        color=COLORS_BAR[0],
        edgecolor='white',
        linewidth=0.5
    )
    bars_inc = ax.bar(
        x + width / 2, income_wan, width,
        label='利息收入',
        color=COLORS_BAR[1],
        edgecolor='white',
        linewidth=0.5
    )

    # 0轴标识线
    ax.axhline(0, color='#920000', linewidth=1.5, zorder=5)

    for bar, val in zip(bars_exp, expense_wan):
        if val != 0:
            ax.text(bar.get_x() + bar.get_width() / 2, val, f'{val:.0f}',
                    ha='center', va='bottom', fontsize=7, color=COLOR_TEXT)
    for bar, val in zip(bars_inc, income_wan):
        if val != 0:
            ax.text(bar.get_x() + bar.get_width() / 2, val, f'{abs(val):.0f}',
                    ha='center', va='top', fontsize=7, color=COLOR_TEXT)

    # 标注净利息费用（放在利息支出柱体内部偏下，仅在支出不为0时显示）
    for i in range(len(years)):
        net = expense_wan[i] + income_wan[i]
        if expense_wan[i] != 0:
            ax.text(
                x[i] - width / 2, expense_wan[i] * 0.15,
                f'净额 {net:.0f}',
                ha='center', va='center', fontsize=7, color='white', fontweight='bold'
            )

    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('金额（万元）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n财务费用（利息收支）', fontsize=14, fontweight="bold", pad=20)
    ax.legend(
        title='', fontsize=8,
        loc='upper center', bbox_to_anchor=(0.5, 1.06),
        ncol=2, frameon=False
    )
    setup_chart_style(ax)
    ymax = max(ax.get_ylim()[1], max(expense_wan) * 1.15) if len(expense_wan) > 0 else ax.get_ylim()[1]
    ymin = min(ax.get_ylim()[0], min(income_wan) * 1.15) if len(income_wan) > 0 else ax.get_ylim()[0]
    ax.set_ylim(ymin, ymax)


def plot_expense_stacked_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str, title: str):
    """费用分类堆积柱状图（前5项）"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n{title}', fontsize=14, fontweight="bold")
        ax.axis('off')
        return

    years = df.columns.tolist()
    items = df.index.tolist()

    x = np.arange(len(years))
    width = 0.5

    bottom = np.zeros(len(years))
    colors = [COLORS_BAR[i % len(COLORS_BAR)] for i in range(len(items))]

    for idx, item in enumerate(items):
        vals = df.loc[item].values / 1e4
        ax.bar(
            x, vals, width,
            bottom=bottom,
            label=item,
            color=colors[idx],
            edgecolor='white',
            linewidth=0.5
        )
        bottom += vals

    for i, total in enumerate(bottom):
        ax.text(
            x[i], total,
            f'{total:.0f}',
            ha='center', va='bottom', fontsize=8, color=COLOR_TEXT, fontweight='bold'
        )

    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('金额（万元）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n{title}（前5项）', fontsize=14, fontweight="bold", pad=20)
    ax.legend(
        title='', fontsize=7,
        loc='upper center', bbox_to_anchor=(0.5, 1.06),
        ncol=min(len(items), 3), frameon=False
    )
    setup_chart_style(ax)
    ymax = ax.get_ylim()[1]
    ax.set_ylim(0, ymax * 1.1)


# ==================== 新增绘图函数 ====================
def plot_revenue_gross_margin_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """主营毛利率柱状图"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n主营毛利率', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    x = np.arange(len(years))

    # 兼容旧格式（仅毛利率）
    if '营业收入' not in df.index or '营业成本' not in df.index:
        vals = df.loc['主营毛利率'].values * 100
        bars = ax.bar(x, vals, 0.5, color=COLORS_BAR[0], edgecolor='white', linewidth=0.5)
        for bar, val in zip(bars, vals):
            ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), f'{val:.1f}%',
                    ha='center', va='bottom', fontsize=8, color=COLOR_TEXT, fontweight='bold')
        ax.set_xticks(x)
        ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
        ax.set_ylabel('毛利率', fontsize=9)
        ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0f}%'))
        setup_chart_style(ax)
        ax.set_ylim(0, max(vals) * 1.2 if len(vals) > 0 else 1)
        ax.set_title(f'{stock_code} {stock_name}\n主营毛利率', fontsize=14, fontweight="bold", pad=20)
        return

    # 新格式：堆积柱状图（成本+毛利）+ 毛利率折线（双Y轴）
    revenue = df.loc['营业收入'].values / 1e8
    cost = df.loc['营业成本'].values / 1e8
    margin = df.loc['主营毛利率'].values * 100
    profit = revenue - cost

    ax2 = ax.twinx()
    ax2.spines['top'].set_visible(False)

    # 堆积柱状图：成本（浅蓝）+ 毛利（深蓝）
    ax.bar(x, cost, 0.5, label='营业成本', color=COLORS_BAR[1],
           edgecolor='white', linewidth=0.5)
    bars_profit = ax.bar(x, profit, 0.5, bottom=cost, label='毛利', color=COLORS_BAR[0],
                         edgecolor='white', linewidth=0.5)

    # 柱顶标注总收入（亿元）
    for i, (bar, total) in enumerate(zip(bars_profit, revenue)):
        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height() + cost[i],
                f'{total:.1f}', ha='center', va='bottom', fontsize=7,
                color=COLOR_TEXT, fontweight='bold')

    # 右轴折线：毛利率
    ax2.plot(x, margin, color=COLORS_LINE[2], marker='o', linewidth=2, label='毛利率', zorder=5)
    for i, val in enumerate(margin):
        ax2.text(x[i], val, f'{val:.1f}%', ha='center', va='bottom', fontsize=7,
                 color=COLOR_TEXT, fontweight='bold')

    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('金额（亿元）', fontsize=9)
    ax2.set_ylabel('毛利率', fontsize=9)
    ax2.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0f}%'))

    # 合并图例
    lines1, labels1 = ax.get_legend_handles_labels()
    lines2, labels2 = ax2.get_legend_handles_labels()
    ax.legend(lines1 + lines2, labels1 + labels2, fontsize=7, loc='upper center',
              bbox_to_anchor=(0.5, 1.10), ncol=3, frameon=False)

    ax.set_title(f'{stock_code} {stock_name}\n主营毛利率', fontsize=14, fontweight="bold", pad=20)
    setup_chart_style(ax)
    ax.set_ylim(0, max(revenue) * 1.25 if len(revenue) > 0 else 1)
    ax2.set_ylim(0, max(margin) * 1.25 if len(margin) > 0 else 1)


def plot_margin_line_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """境内外毛利率折线图"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无境内外毛利率数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n境内外毛利率对比', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    categories = df.index.tolist()
    colors = [COLORS_BAR[0], COLORS_LINE[2]]
    for i, cat in enumerate(categories):
        vals = pd.to_numeric(df.loc[cat], errors='coerce').values * 100
        ax.plot(years, vals, marker='o', color=colors[i % len(colors)], linewidth=2, label=cat)
        for j, val in enumerate(vals):
            if pd.notna(val):
                ax.text(years[j], val, f'{val:.1f}%', ha='center', va='bottom', fontsize=7, color=COLOR_TEXT)
    ax.set_xticks(years)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('毛利率', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n境内外毛利率对比', fontsize=14, fontweight="bold", pad=20)
    ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda y, _: f'{y:.0f}%'))
    ax.legend(fontsize=8, loc='upper center', bbox_to_anchor=(0.5, 1.06), ncol=len(categories), frameon=False)
    setup_chart_style(ax)
    ax.set_ylim(0, ax.get_ylim()[1] * 1.15)


def plot_impairment_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """资产减值损失前三项"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n资产减值损失前三项', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    x = np.arange(len(years))
    width = 0.22
    rank_labels = ['Top1', 'Top2', 'Top3']
    rank_values = {idx: [] for idx in range(3)}
    top_items_by_year = []

    for year in years:
        s = pd.to_numeric(df[year], errors='coerce').fillna(0)
        top3 = s[s > 0].sort_values(ascending=False).head(3)
        top_items_by_year.append(top3.index.tolist())
        values = top3.values.tolist()
        while len(values) < 3:
            values.append(0)
        for idx in range(3):
            rank_values[idx].append(values[idx] / 1e4)

    for idx in range(3):
        offset = width * (idx - 1)
        bars = ax.bar(
            x + offset,
            rank_values[idx],
            width,
            label=rank_labels[idx],
            color=COLORS_BAR[idx % len(COLORS_BAR)],
            edgecolor='white',
            linewidth=0.5,
        )
        for bar, val in zip(bars, rank_values[idx]):
            if val > 0:
                ax.text(
                    bar.get_x() + bar.get_width() / 2,
                    bar.get_height(),
                    f'{val:.0f}',
                    ha='center',
                    va='bottom',
                    fontsize=6,
                    color=COLOR_TEXT,
                )

    xticklabels = []
    for year, items in zip(years, top_items_by_year):
        padded = items + ['无'] * (3 - len(items))
        xticklabels.append(
            f'{year}年\n1.{padded[0]}\n2.{padded[1]}\n3.{padded[2]}'
        )

    ax.set_xticks(x)
    ax.set_xticklabels(xticklabels, fontsize=8)
    ax.set_ylabel('金额（万元）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n资产减值损失前三项', fontsize=14, fontweight="bold", pad=20)
    ax.legend(fontsize=7, loc='upper center', bbox_to_anchor=(0.5, 1.06), ncol=3, frameon=False)
    setup_chart_style(ax)


def plot_non_operating_income_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """营业外收入前三项"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n营业外收入前三项', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    x = np.arange(len(years))
    width = 0.22
    rank_labels = ['Top1', 'Top2', 'Top3']
    rank_values = {idx: [] for idx in range(3)}
    top_items_by_year = []

    for year in years:
        s = pd.to_numeric(df[year], errors='coerce').fillna(0)
        top3 = s[s > 0].sort_values(ascending=False).head(3)
        top_items_by_year.append(top3.index.tolist())
        values = top3.values.tolist()
        while len(values) < 3:
            values.append(0)
        for idx in range(3):
            rank_values[idx].append(values[idx] / 1e4)

    for idx in range(3):
        offset = width * (idx - 1)
        bars = ax.bar(
            x + offset,
            rank_values[idx],
            width,
            label=rank_labels[idx],
            color=COLORS_BAR[idx % len(COLORS_BAR)],
            edgecolor='white',
            linewidth=0.5,
        )
        for bar, val in zip(bars, rank_values[idx]):
            if val > 0:
                ax.text(
                    bar.get_x() + bar.get_width() / 2,
                    bar.get_height(),
                    f'{val:.0f}',
                    ha='center',
                    va='bottom',
                    fontsize=6,
                    color=COLOR_TEXT,
                )

    xticklabels = []
    for year, items in zip(years, top_items_by_year):
        padded = items + ['无'] * (3 - len(items))
        xticklabels.append(
            f'{year}年\n1.{padded[0]}\n2.{padded[1]}\n3.{padded[2]}'
        )

    ax.set_xticks(x)
    ax.set_xticklabels(xticklabels, fontsize=8)
    ax.set_ylabel('金额（万元）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n营业外收入前三项', fontsize=14, fontweight="bold", pad=20)
    ax.legend(fontsize=7, loc='upper center', bbox_to_anchor=(0.5, 1.06), ncol=3, frameon=False)
    setup_chart_style(ax)


def plot_inventory_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """存货分类堆积柱状图"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n存货分类', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    items = df.index.tolist()
    x = np.arange(len(years))
    width = 0.5
    bottom = np.zeros(len(years))
    colors = [COLORS_BAR[i % len(COLORS_BAR)] for i in range(len(items))]
    for idx, item in enumerate(items):
        vals = df.loc[item].values / 1e8
        ax.bar(x, vals, width, bottom=bottom, label=item, color=colors[idx], edgecolor='white', linewidth=0.5)
        bottom += vals
    for i, total in enumerate(bottom):
        ax.text(x[i], total, f'{total:.2f}', ha='center', va='bottom', fontsize=8, color=COLOR_TEXT, fontweight='bold')
    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('金额（亿元）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n存货分类', fontsize=14, fontweight="bold", pad=20)
    ax.legend(fontsize=6, loc='upper center', bbox_to_anchor=(0.5, 1.06), ncol=min(len(items), 4), frameon=False)
    setup_chart_style(ax)
    ymax = ax.get_ylim()[1]
    ax.set_ylim(0, ymax * 1.1)


def plot_employee_comp_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """辞退福利占比"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n辞退福利占比', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    vals = df.loc['辞退福利占比'].values
    x = np.arange(len(years))
    bars = ax.bar(x, vals, 0.5, color=COLORS_BAR[0], edgecolor='white', linewidth=0.5)
    for bar, val in zip(bars, vals):
        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), f'{val:.2f}%',
                ha='center', va='bottom', fontsize=8, color=COLOR_TEXT, fontweight='bold')
    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('占比（%）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n辞退福利/应付职工薪酬合计', fontsize=14, fontweight="bold", pad=20)
    setup_chart_style(ax)
    ax.set_ylim(0, max(vals) * 1.2 if len(vals) > 0 else 1)


def plot_fixed_asset_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """固定资产减值准备占比"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n固定资产减值准备占比', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    vals = df.loc['减值准备占比'].values
    if len(vals) == 0 or np.all(np.isclose(vals, 0)):
        ax.text(0.5, 0.5, '无固定资产减值准备/账面原值', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n固定资产减值准备/账面原值', fontsize=14, fontweight="bold", pad=20)
        ax.axis('off')
        return
    x = np.arange(len(years))
    bars = ax.bar(x, vals, 0.5, color=COLORS_BAR[0], edgecolor='white', linewidth=0.5)

    def fmt_pct(v: float) -> str:
        if abs(v) >= 0.01:
            return f'{v:.2f}%'
        if abs(v) >= 0.0001:
            return f'{v:.4f}%'
        return f'{v:.6f}%'

    for bar, val in zip(bars, vals):
        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), fmt_pct(val),
                ha='center', va='bottom', fontsize=8, color=COLOR_TEXT, fontweight='bold')
    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('占比（%）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n固定资产减值准备/账面原值', fontsize=14, fontweight="bold", pad=20)
    setup_chart_style(ax)
    ymax = max(vals) if len(vals) > 0 else 0
    ax.set_ylim(0, ymax * 1.2 if ymax > 0 else 1)


def plot_ip_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """发明专利/合计"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n发明专利/合计', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    categories = df.index.tolist()
    x = np.arange(len(years))
    width = 0.35
    colors = [COLORS_BAR[0], COLORS_BAR[1]]
    for i, cat in enumerate(categories):
        vals = df.loc[cat].values
        bars = ax.bar(x + (i - (len(categories) - 1) / 2) * width, vals, width,
                      label=cat, color=colors[i % len(colors)], edgecolor='white', linewidth=0.5)
        for bar, val in zip(bars, vals):
            ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), f'{int(val)}',
                    ha='center', va='bottom', fontsize=7, color=COLOR_TEXT)
    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('数量（个）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n发明专利/合计（累计获得数）', fontsize=14, fontweight="bold", pad=20)
    ax.legend(fontsize=8, loc='upper center', bbox_to_anchor=(0.5, 1.06), ncol=len(categories), frameon=False)
    setup_chart_style(ax)


def plot_rnd_projects_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """在研项目数量及投资金额"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n在研项目数量及投资金额', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    project_counts = df.loc['项目数量'].values
    investments = df.loc['投资金额'].values / 1e8
    x = np.arange(len(years))
    width = 0.35
    ax2 = ax.twinx()
    ax2.spines['top'].set_visible(False)
    bars = ax.bar(x - width / 2, project_counts, width, label='项目数量', color=COLORS_BAR[0],
                  edgecolor='white', linewidth=0.5)
    for bar, val in zip(bars, project_counts):
        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), f'{int(val)}',
                ha='center', va='bottom', fontsize=8, color=COLOR_TEXT)
    line = ax2.plot(x + width / 2, investments, color=COLORS_LINE[2], marker='o', linewidth=2, label='投资金额')
    for j, val in enumerate(investments):
        ax2.text(x[j] + width / 2, val, f'{val:.2f}', ha='center', va='bottom', fontsize=7, color=COLOR_TEXT)
    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('项目数量', fontsize=9)
    ax2.set_ylabel('投资金额（亿元）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n在研项目数量及投资金额', fontsize=14, fontweight="bold", pad=20)
    lines1, labels1 = ax.get_legend_handles_labels()
    lines2, labels2 = ax2.get_legend_handles_labels()
    ax.legend(lines1 + lines2, labels1 + labels2, fontsize=7, loc='upper center',
              bbox_to_anchor=(0.5, 1.08), ncol=2, frameon=False)
    setup_chart_style(ax)
    ax.set_ylim(0, ax.get_ylim()[1] * 1.2)
    ax2.set_ylim(0, ax2.get_ylim()[1] * 1.2)


def plot_customer_concentration_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """客户集中度"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n客户集中度', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    vals = df.loc['客户集中度'].values
    x = np.arange(len(years))
    bars = ax.bar(x, vals, 0.5, color=COLORS_BAR[0], edgecolor='white', linewidth=0.5)
    for bar, val in zip(bars, vals):
        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), f'{val:.1f}%',
                ha='center', va='bottom', fontsize=8, color=COLOR_TEXT, fontweight='bold')
    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('占比（%）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n客户集中度（前五大）', fontsize=14, fontweight="bold", pad=20)
    setup_chart_style(ax)
    ax.set_ylim(0, max(vals) * 1.2 if len(vals) > 0 else 1)


def plot_supplier_concentration_chart(ax, df: pd.DataFrame, stock_name: str, stock_code: str):
    """供应商集中度"""
    if df is None or df.empty:
        ax.text(0.5, 0.5, '无数据', ha='center', va='center', transform=ax.transAxes)
        ax.set_title(f'{stock_code} {stock_name}\n供应商集中度', fontsize=14, fontweight="bold")
        ax.axis('off')
        return
    years = df.columns.tolist()
    vals = df.loc['供应商集中度'].values
    x = np.arange(len(years))
    bars = ax.bar(x, vals, 0.5, color=COLORS_BAR[0], edgecolor='white', linewidth=0.5)
    for bar, val in zip(bars, vals):
        ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), f'{val:.1f}%',
                ha='center', va='bottom', fontsize=8, color=COLOR_TEXT, fontweight='bold')
    ax.set_xticks(x)
    ax.set_xticklabels([f'{y}年' for y in years], fontsize=9)
    ax.set_ylabel('占比（%）', fontsize=9)
    ax.set_title(f'{stock_code} {stock_name}\n供应商集中度（前五大）', fontsize=14, fontweight="bold", pad=20)
    setup_chart_style(ax)
    ax.set_ylim(0, max(vals) * 1.2 if len(vals) > 0 else 1)


# ==================== 综合绘图 ====================
def plot_all_charts(stock: StockIndex, save_path: Optional[Path] = None):
    """绘制 4×4 综合图表"""
    gross_margin_df = load_revenue_gross_margin_data(stock.revenue_cost_file)
    margin_line_df = load_region_gross_margin(stock.revenue_cost_detail_file)
    impairment_df = load_impairment_top3_data(stock.impairment_file)
    non_op_income_df = load_non_operating_income_top3_data(stock.non_operating_income_file)
    finance_df = load_finance_expense_data(stock.finance_expense_file)
    mgmt_df = load_expense_top5_data(stock.management_expense_file)
    sales_df = load_expense_top5_data(stock.sales_expense_file)
    rd_df = load_rd_expense_top5_data(stock.rd_expense_file)
    aging_df = load_aging_data(stock.aging_file)
    inventory_df = load_inventory_data(stock.inventory_file)
    emp_comp_df = load_employee_compensation_data(stock.employee_compensation_file)
    fixed_asset_df = load_fixed_asset_data(stock.fixed_asset_file)
    ip_df = load_ip_data(stock.ip_file)
    rnd_project_df = load_rnd_projects_data(stock.rnd_project_file)
    customer_df = load_customer_concentration_data(stock.top5_customer_file)
    supplier_df = load_supplier_concentration_data(stock.top5_supplier_file)

    fig, axes = plt.subplots(8, 2, figsize=(14, 40))
    fig.suptitle(f'{stock.code} {stock.name}  ┊  财务状况深度分析', fontsize=24, fontweight='bold', y=0.99)

    plot_revenue_gross_margin_chart(axes[0, 0], gross_margin_df, stock.name, stock.code)
    plot_margin_line_chart(axes[0, 1], margin_line_df, stock.name, stock.code)
    plot_impairment_chart(axes[1, 0], impairment_df, stock.name, stock.code)
    plot_non_operating_income_chart(axes[1, 1], non_op_income_df, stock.name, stock.code)

    plot_finance_expense_chart(axes[2, 0], finance_df, stock.name, stock.code)
    plot_expense_stacked_chart(axes[2, 1], mgmt_df, stock.name, stock.code, "管理费用")
    plot_expense_stacked_chart(axes[3, 0], sales_df, stock.name, stock.code, "销售费用")
    plot_expense_stacked_chart(axes[3, 1], rd_df, stock.name, stock.code, "研发费用")

    plot_aging_chart(axes[4, 0], aging_df, stock.name, stock.code)
    plot_inventory_chart(axes[4, 1], inventory_df, stock.name, stock.code)
    plot_employee_comp_chart(axes[5, 0], emp_comp_df, stock.name, stock.code)
    plot_fixed_asset_chart(axes[5, 1], fixed_asset_df, stock.name, stock.code)

    plot_ip_chart(axes[6, 0], ip_df, stock.name, stock.code)
    plot_rnd_projects_chart(axes[6, 1], rnd_project_df, stock.name, stock.code)
    plot_customer_concentration_chart(axes[7, 0], customer_df, stock.name, stock.code)
    plot_supplier_concentration_chart(axes[7, 1], supplier_df, stock.name, stock.code)

    plt.tight_layout(rect=[0, 0, 1, 0.96])

    # 生成 LLM 语义分析摘要并拼接
    if save_path:
        # 先保存 Portrait 到临时文件
        portrait_tmp = '/tmp/_portrait_tmp.png'
        fig.savefig(portrait_tmp, dpi=300, bbox_inches='tight')
        plt.close(fig)

        # --- 加载 Portrait 数据并生成趋势分析头部 ---
        header_img = None
        portrait_json = stock.dir_path.parent / "portrait_data" / f"{stock.code}_{stock.name}_portrait_data.json"
        if portrait_json.exists():
            try:
                with open(portrait_json, 'r', encoding='utf-8') as f:
                    portrait_data = json.load(f)
                # 调用 LLM 生成 5 维度总结
                sys.path.insert(0, str(Path(__file__).parent))
                from LLM2Excel3 import LLMCombinedAnalyzer
                # 自动选择 LLM provider
                if os.environ.get('DEEPSEEK_API_KEY'):
                    provider = 'deepseek'
                    model = 'deepseek-chat'
                elif os.environ.get('KIMI_API_KEY'):
                    provider = 'moonshot'
                    model = 'kimi-k2p5'
                else:
                    provider = 'ollama'
                    model = 'gemma3:1b'
                analyzer = LLMCombinedAnalyzer(model=model, provider=provider)
                portrait_summary = analyzer.analyze_portrait_data(portrait_data, stock.code, stock.name)
                if portrait_summary:
                    header_img = plot_portrait_trend_header(portrait_summary, stock.name, stock.code)
                    print(f"  📊 Portrait 趋势头部已生成 ({len(portrait_summary)} 维度)")
            except Exception as e:
                print(f"  ⚠️ Portrait 趋势头部生成失败: {e}")

        llm_file = stock.dir_path / f'{stock.code}_{stock.name}_LLM分析结果.xlsx'
        comp_df = load_llm_competitiveness_summary(llm_file)
        biz_df = load_llm_business_summary(llm_file)

        # 收集所有图层
        layers = []
        if header_img:
            layers.append(header_img)
        layers.append(Image.open(portrait_tmp))
        if comp_df is not None or biz_df is not None:
            llm_img = plot_llm_summary_figure(comp_df, biz_df, stock.name, stock.code)
            if llm_img and llm_img.size != (1, 1):
                layers.append(llm_img)

        # 三层垂直拼接
        if len(layers) > 1:
            w = max(img.width for img in layers)
            h = sum(img.height for img in layers)
            combined = Image.new('RGB', (w, h), 'white')
            y = 0
            for img in layers:
                combined.paste(img, (0, y))
                y += img.height
            combined.save(save_path, dpi=(300, 300))
            print(f"图表已保存至：{save_path}（含趋势头部 + {len(layers)-1} 层）")
        else:
            layers[0].save(save_path, dpi=(300, 300))
            print(f"图表已保存至：{save_path}")

        # 同时保存结构化数据（懒加载避免循环导入）
        try:
            sys.path.insert(0, str(Path(__file__).parent))
            from portrait_data_export import save_portrait_data
            save_portrait_data(stock, stock.dir_path.parent)
        except ImportError:
            pass  # 可选模块，不存在时跳过
    else:
        plt.show()
        plt.close(fig)


# ==================== 交互主程序 ====================
def main(output_dir: Path):
    index = DataIndex(output_dir)
    stocks = index.list_all()
    if not stocks:
        print(f"未在 {output_dir} 目录中找到股票数据")
        return

    print("=" * 60)
    print("Portrait - 综合财务数据可视化工具")
    print("=" * 60)
    print(f"\n已发现 {len(stocks)} 只股票的数据，示例：")
    for s in stocks[:5]:
        flags = []
        if s.aging_file:
            flags.append('账龄')
        if s.revenue_cost_file or s.revenue_cost_detail_file:
            flags.append('收入成本')
        if s.finance_expense_file:
            flags.append('财务费用')
        if s.management_expense_file:
            flags.append('管理费用')
        if s.sales_expense_file:
            flags.append('销售费用')
        print(f"  {s.code}  {s.name}  ({', '.join(flags) if flags else '无'})")
    if len(stocks) > 5:
        print(f"  ... 等共 {len(stocks)} 只")

    while True:
        print("\n" + "-" * 60)
        query = input("请输入股票代码或股票名称（输入 q 退出）：").strip()
        if query.lower() in ('q', 'quit', 'exit'):
            print("已退出")
            break

        stock = index.search(query)
        if stock is None:
            print("未找到匹配的股票，请检查后重新输入")
            continue

        print(f"\n已选中：{stock.code} {stock.name}")

        save_input = input("是否保存图表？（y/n，默认 y）：").strip().lower()
        if save_input in ('', 'y', 'yes'):
            portraits_dir = output_dir.parent / "portraits"
            portraits_dir.mkdir(exist_ok=True)
            save_path = portraits_dir / f"Portrait_{stock.code}_{stock.name}.png"
            plot_all_charts(stock, save_path=save_path)
        else:
            plot_all_charts(stock)


if __name__ == "__main__":
    import argparse
    parser = argparse.ArgumentParser(description='Portrait - 综合财务数据可视化')
    parser.add_argument('query', nargs='?', help='股票代码或股票名称（可选，不提供则进入交互模式）')
    parser.add_argument('--save', '-s', action='store_true', help='直接保存图表，不弹窗显示')
    parser.add_argument('--all', '-a', action='store_true', help='批量生成所有股票的图表')
    parser.add_argument('--output-dir', '-o', type=str, default='output2', help='数据目录（默认 output2）')
    args = parser.parse_args()

    output_dir = Path(args.output_dir)

    index = DataIndex(output_dir)
    stocks = index.list_all()
    if not stocks:
        print(f"未在 {output_dir} 目录中找到股票数据")
        sys.exit(1)

    if args.all:
        print(f"开始批量生成 {len(stocks)} 只股票的图表...")
        success_count = 0
        portraits_dir = output_dir.parent / "portraits"
        portraits_dir.mkdir(exist_ok=True)
        for stock in stocks:
            save_path = portraits_dir / f"Portrait_{stock.code}_{stock.name}.png"
            plot_all_charts(stock, save_path=save_path)
            success_count += 1
        print(f"\n批量生成完成，共 {success_count} 只")
        sys.exit(0)

    if args.query:
        stock = index.search(args.query)
        if stock is None:
            print(f"未找到匹配的股票：{args.query}")
            sys.exit(1)

        print(f"已选中：{stock.code} {stock.name}")

        if args.save:
            portraits_dir = output_dir.parent / "portraits"
            portraits_dir.mkdir(exist_ok=True)
            save_path = portraits_dir / f"Portrait_{stock.code}_{stock.name}.png"
            plot_all_charts(stock, save_path=save_path)
        else:
            plot_all_charts(stock)
    else:
        main(output_dir)


path: scripts/portrait_data_export.py
content: #!/usr/bin/env python3
"""
Portrait 数据导出模块 - 从财务数据文件中提取关键指标
支持从多个年度文件中提取数据
"""

import json
import re
from pathlib import Path
import pandas as pd


def parse_amount(val):
    """解析金额字符串为数字"""
    if pd.isna(val):
        return None
    if isinstance(val, (int, float)):
        return float(val)
    # 处理带逗号的字符串，如 "890,900,112.98"
    s = str(val).replace(',', '').replace(' ', '')
    try:
        return float(s)
    except:
        return None


def extract_year_from_pdf_filename(filename: str) -> str:
    """从PDF文件名中提取年份"""
    match = re.search(r'(20\d{2})', filename)
    return match.group(1) if match else None


def get_years_from_pdfs(stock_dir: Path) -> list:
    """从PDF文件名中获取所有年份"""
    years = set()
    pdf_dir = stock_dir.parent.parent / "RAWPDF" / f"{stock_dir.name.split('_')[1]}PDF"
    if pdf_dir.exists():
        for pdf in pdf_dir.glob("*.pdf"):
            year = extract_year_from_pdf_filename(pdf.name)
            if year:
                years.add(year)
    return sorted(list(years), reverse=True)  # 降序排列，最新年份在前


def load_revenue_cost_data(stock, years: list) -> dict:
    """加载营业收入和成本数据"""
    metrics = {}
    revenue_cost_dir = stock.dir_path / "02_利润表附注" / "01_收入成本"
    
    if not revenue_cost_dir.exists():
        return metrics
    
    files = list(revenue_cost_dir.glob("*.xlsx"))
    if not files:
        return metrics
    
    try:
        df = pd.read_excel(files[0], sheet_name=0)
        cols = df.columns.tolist()
        
        # 查找收入列和成本列
        revenue_cols = [c for c in cols if '收入' in str(c) and '发生额' in str(c)]
        cost_cols = [c for c in cols if '成本' in str(c) and '发生额' in str(c)]
        
        # 查找合计行
        for idx, row in df.iterrows():
            item = str(row.iloc[0]) if pd.notna(row.iloc[0]) else ""
            if '合计' in item or '总计' in item:
                revenue_data = {}
                cost_data = {}
                
                # 按列顺序对应年份（本期=最新年份，上期=次新年份）
                for i, col_name in enumerate(['本期发生额', '上期发生额']):
                    if i < len(years):
                        year = years[i]
                        # 查找匹配的列
                        rev_col = next((c for c in revenue_cols if col_name in str(c)), None)
                        cost_col = next((c for c in cost_cols if col_name in str(c)), None)
                        
                        if rev_col and pd.notna(row.get(rev_col)):
                            revenue_data[year] = parse_amount(row[rev_col])
                        if cost_col and pd.notna(row.get(cost_col)):
                            cost_data[year] = parse_amount(row[cost_col])
                
                if revenue_data:
                    metrics['营业收入'] = revenue_data
                if cost_data:
                    metrics['营业成本'] = cost_data
                break
    except Exception as e:
        print(f"    读取收入成本数据失败: {e}")
    
    return metrics


def load_expense_data(stock, years: list) -> dict:
    """加载期间费用数据"""
    metrics = {}
    expense_dir = stock.dir_path / "02_利润表附注" / "02_期间费用"
    
    if not expense_dir.exists():
        return metrics
    
    expense_mapping = {
        '管理费用': '管理费用',
        '销售费用': '销售费用',
        '研发费用': '研发费用',
        '财务费用': '财务费用'
    }
    
    for expense_keyword, metric_name in expense_mapping.items():
        files = list(expense_dir.glob(f"*{expense_keyword}*.xlsx"))
        if not files:
            continue
        
        try:
            df = pd.read_excel(files[0], sheet_name=0)
            expense_data = {}
            
            # 查找合计行
            for idx, row in df.iterrows():
                item = str(row.iloc[0]) if pd.notna(row.iloc[0]) else ""
                if '合计' in item:
                    # 按本期/上期对应年份
                    for i, col_name in enumerate(['本期发生额', '上期发生额']):
                        if i < len(years):
                            year = years[i]
                            # 查找匹配的列
                            matching_cols = [c for c in df.columns if col_name in str(c)]
                            if matching_cols:
                                val = row.get(matching_cols[0])
                                if pd.notna(val):
                                    expense_data[year] = parse_amount(val)
                    break
            
            if expense_data:
                metrics[metric_name] = expense_data
        except Exception as e:
            print(f"    读取{metric_name}数据失败: {e}")
    
    return metrics


def load_balance_sheet_data(stock, years: list) -> dict:
    """加载资产负债表数据"""
    metrics = {}
    bs_dir = stock.dir_path / "01_资产负债表附注"
    
    if not bs_dir.exists():
        return metrics
    
    # 应收账款
    ar_files = list(bs_dir.glob("*应收*账龄*.xlsx"))
    if ar_files:
        try:
            df = pd.read_excel(ar_files[0], sheet_name=0)
            # 取最后一行作为合计
            last_row = df.iloc[-1]
            ar_data = {}
            
            # 查找年份列（通常是数字列名）
            year_cols = [c for c in df.columns if str(c).isdigit() and len(str(c)) == 4]
            for i, col in enumerate(year_cols):
                if i < len(years):
                    year = years[i]
                    val = last_row.get(col)
                    if pd.notna(val):
                        ar_data[year] = parse_amount(val)
            
            if ar_data:
                metrics['应收账款'] = ar_data
        except Exception as e:
            print(f"    读取应收账款数据失败: {e}")
    
    # 存货
    inv_files = list(bs_dir.glob("*存货*.xlsx"))
    if inv_files:
        try:
            df = pd.read_excel(inv_files[0], sheet_name=0)
            last_row = df.iloc[-1]
            inv_data = {}
            
            year_cols = [c for c in df.columns if str(c).isdigit() and len(str(c)) == 4]
            for i, col in enumerate(year_cols):
                if i < len(years):
                    year = years[i]
                    val = last_row.get(col)
                    if pd.notna(val):
                        inv_data[year] = parse_amount(val)
            
            if inv_data:
                metrics['存货'] = inv_data
        except Exception as e:
            print(f"    读取存货数据失败: {e}")
    
    # 5. 研发创新数据
    mda_dir = stock.dir_path / "11_管理层讨论与分析"
    if mda_dir.exists():
        # 在研项目数量 - 使用最新年份的数据
        rnd_files = list(mda_dir.glob("**/*在研项目*.xlsx"))
        if rnd_files:
            try:
                df = pd.read_excel(rnd_files[0], sheet_name=0)
                # 统计总行数作为项目数量（简化处理）
                rnd_count = {}
                for year in years[:2]:  # 给最近两年相同的数据
                    rnd_count[year] = len(df)
                if rnd_count:
                    metrics['在研项目数量'] = rnd_count
                    print(f"    在研项目: {len(df)} 个")
            except Exception as e:
                print(f"    读取在研项目数据失败: {e}")
        
        # 知识产权数量
        ip_files = list(mda_dir.glob("**/*知识产权*.xlsx"))
        if ip_files:
            try:
                df = pd.read_excel(ip_files[0], sheet_name=0)
                ip_count = {}
                for year in years[:2]:
                    ip_count[year] = len(df)
                if ip_count:
                    metrics['知识产权数量'] = ip_count
                    print(f"    知识产权: {len(df)} 项")
            except Exception as e:
                print(f"    读取知识产权数据失败: {e}")
    
    # 6. 供应链风险数据（客户/供应商集中度）
    if mda_dir.exists():
        # 客户集中度
        customer_files = list(mda_dir.glob("**/*客户*.xlsx"))
        if customer_files:
            try:
                df = pd.read_excel(customer_files[0], sheet_name=0)
                print(f"    客户文件列名: {df.columns.tolist()[:5]}")
                # 查找占比数据
                customer_conc = {}
                for i, year in enumerate(years[:2]):
                    # 简化：取第一行的占比值
                    ratio_col = None
                    for col in df.columns:
                        if '比例' in str(col) or '占比' in str(col) or '%' in str(col):
                            ratio_col = col
                            break
                    
                    if ratio_col:
                        val = df[ratio_col].iloc[0]
                        if pd.notna(val):
                            customer_conc[year] = parse_amount(val)
                            print(f"      {year} 客户集中度: {val}")
                if customer_conc:
                    metrics['客户集中度'] = customer_conc
            except Exception as e:
                print(f"    读取客户集中度数据失败: {e}")
        
        # 供应商集中度
        supplier_files = list(mda_dir.glob("**/*供应商*.xlsx"))
        if supplier_files:
            try:
                df = pd.read_excel(supplier_files[0], sheet_name=0)
                print(f"    供应商文件列名: {df.columns.tolist()[:5]}")
                supplier_conc = {}
                for i, year in enumerate(years[:2]):
                    ratio_col = None
                    for col in df.columns:
                        if '比例' in str(col) or '占比' in str(col) or '%' in str(col):
                            ratio_col = col
                            break
                    
                    if ratio_col:
                        val = df[ratio_col].iloc[0]
                        if pd.notna(val):
                            supplier_conc[year] = parse_amount(val)
                            print(f"      {year} 供应商集中度: {val}")
                if supplier_conc:
                    metrics['供应商集中度'] = supplier_conc
            except Exception as e:
                print(f"    读取供应商集中度数据失败: {e}")
    
    return metrics


def calculate_derived_metrics(metrics: dict) -> dict:
    """计算派生指标"""
    # 计算毛利率
    if '营业收入' in metrics and '营业成本' in metrics:
        gross_margin = {}
        for year in metrics['营业收入'].keys():
            revenue = metrics['营业收入'].get(year)
            cost = metrics['营业成本'].get(year)
            if revenue and cost and revenue > 0:
                gross_margin[year] = round((revenue - cost) / revenue * 100, 2)
        if gross_margin:
            metrics['毛利率'] = gross_margin
    
    # 计算期间费用率
    if '营业收入' in metrics:
        for expense_type in ['管理费用', '销售费用', '研发费用']:
            if expense_type in metrics:
                expense_rate = {}
                for year in metrics['营业收入'].keys():
                    revenue = metrics['营业收入'].get(year)
                    expense = metrics[expense_type].get(year)
                    if revenue and expense and revenue > 0:
                        expense_rate[year] = round(expense / revenue * 100, 2)
                if expense_rate:
                    metrics[f'{expense_type}率'] = expense_rate
    
    # 计算总期间费用
    period_expenses = {}
    for expense_type in ['管理费用', '销售费用', '研发费用', '财务费用']:
        if expense_type in metrics:
            for year, val in metrics[expense_type].items():
                if year not in period_expenses:
                    period_expenses[year] = 0
                if val:
                    period_expenses[year] += val
    
    if period_expenses:
        metrics['期间费用合计'] = period_expenses
        
        # 计算期间费用率
        if '营业收入' in metrics:
            period_expense_rate = {}
            for year in metrics['营业收入'].keys():
                revenue = metrics['营业收入'].get(year)
                expense = period_expenses.get(year)
                if revenue and expense and revenue > 0:
                    period_expense_rate[year] = round(expense / revenue * 100, 2)
            if period_expense_rate:
                metrics['期间费用率'] = period_expense_rate
    
    return metrics


def load_financial_metrics(stock):
    """从各个财务数据文件中提取关键指标"""
    # 获取年份列表
    years = get_years_from_pdfs(stock.dir_path)
    if not years:
        years = ['2025', '2024', '2023']  # 默认年份
    
    print(f"    检测到年份: {years}")
    
    # 加载各类数据
    metrics = {}
    metrics.update(load_revenue_cost_data(stock, years))
    metrics.update(load_expense_data(stock, years))
    metrics.update(load_balance_sheet_data(stock, years))
    
    # 计算派生指标
    metrics = calculate_derived_metrics(metrics)
    
    return metrics, years


def save_portrait_data(stock, output_dir: Path):
    """
    从股票数据中提取关键指标并保存为 JSON
    """
    print(f"  📊 正在提取 {stock.code} {stock.name} 的财务数据...")
    
    metrics, years = load_financial_metrics(stock)
    
    portrait_data = {
        "code": stock.code,
        "name": stock.name,
        "years": years,
        "metrics": metrics
    }
    
    # 保存 JSON
    portrait_dir = output_dir / "portrait_data"
    portrait_dir.mkdir(exist_ok=True)
    json_file = portrait_dir / f"{stock.code}_{stock.name}_portrait_data.json"
    
    with open(json_file, 'w', encoding='utf-8') as f:
        json.dump(portrait_data, f, ensure_ascii=False, indent=2)
    
    print(f"  💾 Portrait 数据已保存: {json_file} ({len(years)} 年, {len(metrics)} 项指标)")
    for metric_name in sorted(metrics.keys()):
        print(f"      - {metric_name}")
    
    return json_file


if __name__ == "__main__":
    # 测试代码
    import sys
    sys.path.insert(0, str(Path(__file__).parent))
    from CompanyPortrait import DataIndex
    
    output_dir = Path("output2")
    index = DataIndex(output_dir)
    stocks = index.list_all()
    
    for stock in stocks[:1]:
        save_portrait_data(stock, output_dir)


path: scripts/fn_pipeline.py
content: #!/usr/bin/env python3
"""
FN Portrait Pipeline - OpenClaw Skill Entry Point
================================================
Usage: python fn_pipeline.py <stock_code> <stock_name> [years] [plate]
"""

import sys
import os
import shutil
import argparse
from pathlib import Path

# Get skill directory
SKILL_DIR = Path(__file__).parent.parent
SCRIPTS_DIR = SKILL_DIR / "scripts"
sys.path.insert(0, str(SCRIPTS_DIR))

# Import modules
from ReportLinkCrawler2 import CrawlerConfig, CNINFOClient, PLATE_NAME_MAP
from TextAnalysis9 import run as ta_run
from LLM2Excel3 import batch_process
from CompanyPortrait import DataIndex, plot_all_charts


def check_llm(model_name="gemma3:1b"):
    """Check if LLM service is ready"""
    if os.environ.get('DEEPSEEK_API_KEY'):
        print(f"✅ DeepSeek API ready")
        return True
    if os.environ.get('KIMI_API_KEY'):
        print(f"✅ Moonshot API ready")
        return True

    import urllib.request
    import json
    try:
        req = urllib.request.Request('http://127.0.0.1:11434/api/tags', method='GET')
        with urllib.request.urlopen(req, timeout=5) as resp:
            data = json.loads(resp.read())
            models = [m['name'] for m in data.get('models', [])]
            if model_name in models:
                print(f"✅ Ollama ready, model {model_name} available")
                return True
            else:
                print(f"❌ Model {model_name} not found, installed: {models}")
                return False
    except Exception as e:
        print(f"❌ Ollama not ready: {e}")
        return False


def download_pdfs(stock_code: str, stock_name: str, years: list, plate: str, rawpdf_dir: Path):
    """Download annual report PDFs from CNINFO"""
    print(f"\n{'='*60}")
    print(f"📥 Step 1: Download Annual Report PDFs")
    print(f"{'='*60}")
    print(f"Stock: {stock_code} {stock_name}")
    print(f"Years: {', '.join(map(str, years))}")
    print(f"Plate: {plate}")

    stock_pdf_dir = rawpdf_dir / f"{stock_name}PDF"
    stock_pdf_dir.mkdir(parents=True, exist_ok=True)
    print(f"PDF save directory: {stock_pdf_dir}")

    plate_code = PLATE_NAME_MAP.get(plate, 'shkcp')
    downloaded = []

    for year in years:
        config = CrawlerConfig(
            target_year=year,
            report_type='年报',
            plate=plate_code,
            output_dir=str(stock_pdf_dir),
            delay_between_downloads=1.0,
        )
        client = CNINFOClient(config)

        start_date = f"{year}-03-01"
        end_date = f"{year+1}-04-30"

        print(f"\n  Searching {year} annual report ({start_date} ~ {end_date})...")
        results = client.search_announcements(start_date, end_date)

        stock_results = [
            r for r in results
            if r.get("secCode") == stock_code or stock_name in r.get("secName", "")
        ]

        if not stock_results:
            print(f"  ⚠️ No {year} annual report found")
            continue

        EXCLUDE_KEYWORDS = ['英文', '摘要', '已取消', '修订版', '更正后', 'XBRL']
        result = None
        for r in stock_results:
            if r.get("secCode") == stock_code:
                title = r.get("announcementTitle", "")
                if not any(kw in title for kw in EXCLUDE_KEYWORDS):
                    result = r
                    break

        if not result:
            for r in stock_results:
                if r.get("secCode") == stock_code:
                    result = r
                    break

        if not result:
            result = stock_results[0]

        adjunct_url = result.get("adjunctUrl", "")
        if not adjunct_url:
            print(f"  ⚠️ No PDF link")
            continue

        filename = f"{stock_code}_{stock_name}_{year}年报.pdf"
        save_path = stock_pdf_dir / filename

        if save_path.exists():
            print(f"  ⏭️ Already exists: {filename}")
            downloaded.append(save_path)
            continue

        success = client.download_pdf(
            adjunct_url=adjunct_url,
            save_path=str(save_path),
            company_code=stock_code,
            company_name=stock_name,
        )

        if success:
            print(f"  ✅ Downloaded: {filename}")
            downloaded.append(save_path)
        else:
            print(f"  ❌ Download failed")

    return downloaded, stock_pdf_dir


def extract_data(rawpdf_dir: Path, output_dir: Path, stock_code: str, stock_name: str):
    """Extract structured data from PDFs"""
    print(f"\n{'='*60}")
    print(f"🔍 Step 2: PDF Structured Extraction (TextAnalysis9)")
    print(f"{'='*60}")

    pdf_files = list(rawpdf_dir.glob(f"{stock_code}_{stock_name}_*.pdf"))
    if not pdf_files:
        print(f"❌ No PDF files found for {stock_code}")
        return False

    print(f"Found {len(pdf_files)} PDF files")

    temp_input = output_dir / "_temp_input"
    temp_input.mkdir(exist_ok=True)
    for f in pdf_files:
        shutil.copy(f, temp_input / f.name)

    failed = ta_run(
        input_dir=str(temp_input),
        output_dir=str(output_dir),
        max_workers=2,
    )

    shutil.rmtree(temp_input, ignore_errors=True)

    if failed:
        print(f"\n⚠️ {len(failed)} files failed:")
        for p, e in failed.items():
            print(f"  {os.path.basename(p)}: {e[:200]}")
    else:
        print(f"\n✅ All PDFs extracted successfully")

    return len(failed) == 0


def llm_analysis(output_dir: Path, stock_code: str, stock_name: str):
    """LLM semantic analysis - 只分析当前股票的数据"""
    print(f"\n{'='*60}")
    print(f"🧠 Step 3: LLM Semantic Analysis (LLM2Excel3)")
    print(f"{'='*60}")

    if os.environ.get('DEEPSEEK_API_KEY'):
        provider = 'deepseek'
        model = 'deepseek-chat'
    elif os.environ.get('KIMI_API_KEY'):
        provider = 'moonshot'
        model = 'kimi-k2p5'
    else:
        provider = 'ollama'
        model = 'gemma3:1b'

    # 创建临时目录，只包含当前股票的数据
    company_dir = output_dir / f"{stock_code}_{stock_name}"
    if not company_dir.exists():
        print(f"⚠️ 未找到公司数据目录: {company_dir}")
        return

    # 调用 batch_process 分析当前股票
    # 注意：batch_process 会扫描 base_dir 下的所有公司
    # 我们需要修改逻辑，只处理当前股票
    from LLM2Excel3 import LLMCombinedAnalyzer
    
    analyzer = LLMCombinedAnalyzer(model=model, provider=provider)
    
    # 查找当前股票的文本文件
    txt_files = list(company_dir.rglob("*.txt"))
    
    if not txt_files:
        print(f"⚠️ 未找到文本文件: {company_dir}")
        return
    
    print(f"  找到 {len(txt_files)} 个文本文件")
    
    # 准备保存结果
    import pandas as pd
    all_business_results = []
    all_rd_results = []
    
    # 分析每个文本文件
    for txt_file in txt_files:
        print(f"  分析: {txt_file.name}")
        try:
            with open(txt_file, 'r', encoding='utf-8') as f:
                text = f.read()
            
            # 根据文件路径判断分析类型
            rel_path = txt_file.relative_to(company_dir)
            
            if '经营情况' in str(rel_path) or '业务与行业' in str(rel_path):
                result = analyzer.analyze_business(text, stock_code, stock_name)
                if result and result.get('success') and 'data' in result:
                    all_business_results.extend(result['data'])
                    print(f"    ✅ 经营分析完成: {len(result['data'])} 条记录")
                else:
                    print(f"    ⚠️ 经营分析未返回结果")
            elif '核心' in str(rel_path) or '研发' in str(rel_path):
                result = analyzer.analyze_rd(text, stock_code, stock_name)
                if result and result.get('success') and 'data' in result:
                    all_rd_results.extend(result['data'])
                    print(f"    ✅ 研发分析完成: {len(result['data'])} 条记录")
                else:
                    print(f"    ⚠️ 研发分析未返回结果")
            else:
                # 默认分析
                result = analyzer.analyze_business(text, stock_code, stock_name)
                if result and result.get('success') and 'data' in result:
                    all_business_results.extend(result['data'])
                    print(f"    ✅ 分析完成: {len(result['data'])} 条记录")
                else:
                    print(f"    ⚠️ 分析未返回结果")
                
        except Exception as e:
            print(f"    ❌ 分析失败: {e}")
    
    # 保存结果到 Excel
    if all_business_results or all_rd_results:
        output_file = company_dir / f"{stock_code}_{stock_name}_LLM分析结果.xlsx"
        with pd.ExcelWriter(output_file, engine='openpyxl') as writer:
            if all_business_results:
                df_business = pd.DataFrame(all_business_results)
                df_business.to_excel(writer, sheet_name='经营情况分析', index=False)
                print(f"    💾 经营情况分析: {len(all_business_results)} 条记录")
            if all_rd_results:
                df_rd = pd.DataFrame(all_rd_results)
                df_rd.to_excel(writer, sheet_name='研发成果分析', index=False)
                print(f"    💾 研发成果分析: {len(all_rd_results)} 条记录")
        print(f"  ✅ LLM分析结果已保存: {output_file.name}")
    else:
        print(f"  ⚠️ 没有分析结果需要保存")

    print(f"\n✅ LLM analysis complete")


def generate_portrait(output_dir: Path, stock_code: str, stock_name: str):
    """Generate Portrait chart"""
    print(f"\n{'='*60}")
    print(f"📊 Step 4: Generate Portrait Chart")
    print(f"{'='*60}")

    index = DataIndex(output_dir)
    stock = index.search(stock_code)

    if stock is None:
        print(f"❌ No data found for {stock_code}")
        return False

    portraits_dir = output_dir.parent / "portraits"
    portraits_dir.mkdir(exist_ok=True)
    save_path = portraits_dir / f"Portrait_{stock.code}_{stock.name}.png"

    plot_all_charts(stock, save_path=save_path)
    print(f"\n✅ Portrait saved: {save_path}")
    return True


def parse_years(years_str: str):
    """Parse year range string"""
    if not years_str:
        return [2023, 2024, 2025]
    if "-" in years_str:
        start, end = years_str.split("-", 1)
        return list(range(int(start), int(end) + 1))
    else:
        return [int(years_str)]


def main():
    parser = argparse.ArgumentParser(description='FN Portrait Pipeline')
    parser.add_argument('stock_code', help='Stock code (6 digits)')
    parser.add_argument('stock_name', help='Company name in Chinese')
    parser.add_argument('--years', '-y', default='2023-2025', help='Year range (default: 2023-2025)')
    parser.add_argument('--plate', '-p', default='科创板', help='Stock plate (default: 科创板)')
    parser.add_argument('--rawpdf-dir', default='RAWPDF', help='PDF download directory')
    parser.add_argument('--output-dir', default='output2', help='Output directory')
    parser.add_argument('--skip-download', action='store_true', help='Skip download step')
    parser.add_argument('--skip-extract', action='store_true', help='Skip extraction step')
    parser.add_argument('--skip-llm', action='store_true', help='Skip LLM analysis')
    parser.add_argument('--skip-portrait', action='store_true', help='Skip portrait generation')
    args = parser.parse_args()

    # Setup paths
    skill_dir = Path(__file__).parent.parent
    rawpdf_dir = skill_dir / args.rawpdf_dir
    output_dir = skill_dir / args.output_dir
    rawpdf_dir.mkdir(exist_ok=True)
    output_dir.mkdir(exist_ok=True)

    years = parse_years(args.years)
    print(f"🎯 Target: {args.stock_code} {args.stock_name}")
    print(f"📅 Years: {', '.join(map(str, years))}")

    # Step 1: Download PDFs
    stock_pdf_dir = rawpdf_dir / f"{args.stock_name}PDF"
    if not args.skip_download:
        downloaded, stock_pdf_dir = download_pdfs(
            args.stock_code, args.stock_name, years, args.plate, rawpdf_dir
        )
        if not downloaded:
            print("\n❌ Download failed, pipeline terminated")
            return 1
    else:
        print("\n⏭️ Skipping download step")

    # Step 2: Extract data
    if not args.skip_extract:
        success = extract_data(stock_pdf_dir, output_dir, args.stock_code, args.stock_name)
        if not success:
            print("\n⚠️ Extraction had failures, continuing...")
    else:
        print("\n⏭️ Skipping extraction step")

    # Step 3: LLM analysis
    if not args.skip_llm:
        if not check_llm():
            print("\n❌ LLM not ready, skipping LLM analysis")
            return 1
        llm_analysis(output_dir, args.stock_code, args.stock_name)
    else:
        print("\n⏭️ Skipping LLM analysis")

    # Step 4: Generate Portrait
    if not args.skip_portrait:
        generate_portrait(output_dir, args.stock_code, args.stock_name)
    else:
        print("\n⏭️ Skipping portrait generation")

    print(f"\n{'='*60}")
    print(f"🎉 Pipeline Complete!")
    print(f"{'='*60}")
    portrait_path = output_dir.parent / 'portraits' / f'Portrait_{args.stock_code}_{args.stock_name}.png'
    print(f"Portrait: {portrait_path}")

    return 0


if __name__ == '__main__':
    sys.exit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)