File size: 14,661 Bytes
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
feef6eb
7e94d17
 
 
feef6eb
 
 
 
 
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
 
 
feef6eb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e94d17
 
 
feef6eb
 
 
 
 
7e94d17
 
 
 
 
 
 
feef6eb
7e94d17
 
 
 
feef6eb
 
 
 
 
 
 
 
 
 
 
 
 
 
7e94d17
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
feef6eb
 
 
 
 
 
 
7e94d17
 
 
feef6eb
7e94d17
 
 
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
 
 
feef6eb
 
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
feef6eb
7e94d17
 
feef6eb
 
7e94d17
 
 
 
 
 
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
 
 
 
feef6eb
 
7e94d17
 
 
 
 
 
 
 
 
feef6eb
 
 
 
 
 
 
 
 
 
 
 
7e94d17
feef6eb
7e94d17
feef6eb
7e94d17
 
feef6eb
7e94d17
 
feef6eb
 
 
7e94d17
 
 
 
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
 
feef6eb
 
7e94d17
feef6eb
7e94d17
 
feef6eb
 
 
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
feef6eb
 
 
7e94d17
 
 
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
feef6eb
7e94d17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
"""
Google Sheets 回填模块
- 模糊匹配公司名
- 智能覆盖策略(优先级)
- 写入 AA/AB/AC/AD 列

所有配置值均通过 src.config 模块读取(支持运行时动态更新)。
"""
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional

import gspread
from google.oauth2.service_account import Credentials
from rapidfuzz import fuzz, process

from . import config as cfg

logger = logging.getLogger(__name__)

SCOPES = [
    "https://www.googleapis.com/auth/spreadsheets",
    "https://www.googleapis.com/auth/drive.file",
]


@dataclass
class MatchResult:
    """公司名匹配结果"""
    status: str                     # exact | fuzzy | ambiguous | not_found
    row_index: int = -1             # 1-based 行号(-1 表示未找到)
    matched_name: str = ""          # 表格中实际的公司名
    score: float = 0.0              # 匹配分数 (0-100)
    sheet_name: str = ""            # 所在工作表名称(如 West、South 等)
    candidates: list = field(default_factory=list)  # 候选列表(ambiguous 时)
    message: str = ""


@dataclass
class WriteResult:
    """写入结果"""
    success: bool
    row_index: int = -1
    action: str = ""        # written | skipped | overwritten
    reason: str = ""
    error: str = ""


def _get_priority(category: str) -> int:
    """获取分类优先级(数值越大优先级越高)"""
    if category in cfg.HIGH_PRIORITY_TYPES:
        return 2
    if category in cfg.LOW_PRIORITY_TYPES:
        return 1
    return 0


class SheetsUpdater:
    """Google Sheets 更新器(跨多个区域工作表查询与写入)"""

    def __init__(self):
        self._client = None
        self._spreadsheet = None
        # 工作表缓存 {sheet_name: worksheet_object}
        self._worksheets: dict = {}
        # 公司列表:每条记录包含 (company_name, row_index, sheet_name)
        self._company_list: list[tuple[str, int, str]] = []
        self._initialized = False

    def _get_credentials(self) -> Credentials:
        """获取 Google 凭证(每次调用读取最新配置)"""
        sa_info = cfg.get_service_account_info()
        if not sa_info:
            raise ValueError(
                "❌ 未配置 Google Service Account 凭证。\n"
                "请在设置面板中粘贴 Service Account JSON 内容。"
            )
        return Credentials.from_service_account_info(sa_info, scopes=SCOPES)

    def initialize(self) -> bool:
        """初始化连接并加载所有区域工作表的公司列表"""
        try:
            creds = self._get_credentials()
            self._client = gspread.authorize(creds)

            sheet_id = cfg.get_google_sheet_id()
            if not sheet_id:
                raise ValueError("❌ 未配置 Google Sheets ID。\n请在设置面板中填写 Google Sheet ID。")

            self._spreadsheet = self._client.open_by_key(sheet_id)
            self._worksheets = {}

            # 加载所有区域工作表
            sheet_names = cfg.get_google_sheet_names()
            loaded = []
            skipped = []
            for name in sheet_names:
                try:
                    ws = self._spreadsheet.worksheet(name)
                    self._worksheets[name] = ws
                    loaded.append(name)
                except gspread.exceptions.WorksheetNotFound:
                    skipped.append(name)
                    logger.warning(f"⚠️ 工作表 '{name}' 不存在,已跳过")

            if not self._worksheets:
                raise ValueError(
                    f"❌ 未能加载任何区域工作表。\n"
                    f"已尝试: {sheet_names},请确认工作表名称正确。"
                )

            self._load_company_list()
            self._initialized = True
            logger.info(
                f"✅ Google Sheets 连接成功,已加载工作表: {loaded},"
                f"共 {len(self._company_list)} 家公司"
                + (f"(跳过不存在的工作表: {skipped})" if skipped else "")
            )
            return True

        except Exception as e:
            logger.error(f"Google Sheets 初始化失败: {e}")
            raise

    def _load_company_list(self):
        """加载所有区域工作表中的公司名(用于跨表模糊匹配)"""
        indices = cfg.get_sheet_col_indices()
        col_idx = indices["company"] + 1  # gspread 用 1-based

        self._company_list = []
        for sheet_name, ws in self._worksheets.items():
            try:
                col_values = ws.col_values(col_idx)
                count = 0
                for row_idx, name in enumerate(col_values, start=1):
                    name = name.strip()
                    if name and row_idx > 1:  # 跳过表头
                        self._company_list.append((name, row_idx, sheet_name))
                        count += 1
                logger.debug(f"  [{sheet_name}] 已加载 {count} 家公司")
            except Exception as e:
                logger.warning(f"加载工作表 '{sheet_name}' 公司列表失败: {e}")

        logger.debug(f"全部公司列表已加载: {len(self._company_list)} 条(跨 {len(self._worksheets)} 张工作表)")

    def refresh_company_list(self):
        """刷新公司列表缓存"""
        self._load_company_list()

    def fuzzy_match_company(self, company_name: str) -> MatchResult:
        """模糊匹配公司名(跨所有区域工作表)"""
        if not self._company_list:
            return MatchResult(
                status="not_found",
                message="表格中无公司数据,请确认 Google Sheets 已正确配置"
            )

        # 提取名称列表,构建查找字典(同名公司保留首个——不同表可能重名)
        names = [name for name, _, _ in self._company_list]
        # 名称 -> (row, sheet_name),优先保留首次出现的
        name_to_info: dict[str, tuple[int, str]] = {}
        for name, row, sheet in self._company_list:
            if name not in name_to_info:
                name_to_info[name] = (row, sheet)

        matches = process.extract(
            company_name,
            list(name_to_info.keys()),
            scorer=fuzz.token_sort_ratio,
            limit=5,
        )

        if not matches:
            return MatchResult(status="not_found", message=f"未找到与 '{company_name}' 匹配的公司")

        best_name, best_score, _ = matches[0]
        best_row, best_sheet = name_to_info[best_name]

        # 完全匹配
        if company_name.lower() == best_name.lower():
            return MatchResult(
                status="exact",
                row_index=best_row,
                matched_name=best_name,
                score=100.0,
                sheet_name=best_sheet,
                message=f"精确匹配: '{best_name}' ({best_sheet} 表, 第 {best_row} 行)"
            )

        threshold = cfg.get_fuzzy_threshold()
        if best_score < threshold:
            return MatchResult(
                status="not_found",
                message=f"未找到匹配项 (最高相似度 {best_score:.1f}% < 阈值 {threshold}%)"
            )

        # 收集所有达标候选
        candidates = []
        for name, score, _ in matches:
            if score >= threshold:
                row, sheet = name_to_info[name]
                candidates.append({
                    "name": name,
                    "row": row,
                    "sheet": sheet,
                    "score": score
                })

        if len(candidates) >= 2:
            gap = candidates[0]["score"] - candidates[1]["score"]
            ambiguous_gap = cfg.get_fuzzy_ambiguous_gap()
            if gap < ambiguous_gap:
                return MatchResult(
                    status="ambiguous",
                    matched_name=best_name,
                    score=best_score,
                    sheet_name=best_sheet,
                    candidates=candidates[:5],
                    message=f"发现多个相似公司(差异 < {ambiguous_gap}%),需人工确认"
                )

        return MatchResult(
            status="fuzzy",
            row_index=best_row,
            matched_name=best_name,
            score=best_score,
            sheet_name=best_sheet,
            message=f"模糊匹配: '{best_name}' ({best_sheet} 表, 相似度 {best_score:.1f}%, 第 {best_row} 行)"
        )

    def _col_letter_to_1based(self, col_letter: str) -> int:
        col = col_letter.upper().strip()
        result = 0
        for ch in col:
            result = result * 26 + (ord(ch) - ord('A') + 1)
        return result

    def _get_worksheet(self, sheet_name: str):
        """根据工作表名获取 worksheet 对象,默认回退到第一个可用工作表"""
        if sheet_name and sheet_name in self._worksheets:
            return self._worksheets[sheet_name]
        # 回退:使用第一个可用工作表
        if self._worksheets:
            fallback = next(iter(self._worksheets))
            logger.warning(f"工作表 '{sheet_name}' 未找到,回退到 '{fallback}'")
            return self._worksheets[fallback]
        raise RuntimeError("没有可用的工作表,请重新初始化连接")

    def _get_cell_value(self, row: int, col_letter: str, sheet_name: str = "") -> str:
        try:
            ws = self._get_worksheet(sheet_name)
            col_idx = self._col_letter_to_1based(col_letter)
            cell = ws.cell(row, col_idx)
            return (cell.value or "").strip()
        except Exception as e:
            logger.warning(f"读取单元格失败 (sheet={sheet_name}, row={row}, col={col_letter}): {e}")
            return ""

    def _write_cells(self, row: int, data: dict, sheet_name: str = "") -> None:
        """批量写入单元格 {col_letter: value},写入指定工作表"""
        ws = self._get_worksheet(sheet_name)
        updates = []
        for col_letter, value in data.items():
            col_idx = self._col_letter_to_1based(col_letter)
            cell = gspread.utils.rowcol_to_a1(row, col_idx)
            updates.append({
                "range": cell,
                "values": [[str(value) if value is not None else ""]]
            })
        if updates:
            ws.batch_update(updates, value_input_option="USER_ENTERED")

    def write_email_data(
        self,
        row_index: int,
        category: str,
        body_text: str,
        date_str: str,
        force_overwrite: bool = False,
        sheet_name: str = ""
    ) -> WriteResult:
        """按优先级策略写入邮件数据到指定工作表"""
        try:
            cfg_dict = cfg.get_config_dict()
            existing_type = self._get_cell_value(
                row_index, cfg_dict["sheet_email_type_col"], sheet_name
            )

            if existing_type and not force_overwrite:
                new_priority = _get_priority(category)
                existing_priority = _get_priority(existing_type)

                if new_priority > existing_priority:
                    action_reason = f"覆盖({existing_type} -> {category},新邮件优先级更高)"
                elif new_priority <= existing_priority and existing_type == category:
                    return WriteResult(
                        success=True, row_index=row_index,
                        action="skipped",
                        reason=f"相同类型 '{category}' 已存在,保留原数据"
                    )
                elif new_priority < existing_priority:
                    return WriteResult(
                        success=True, row_index=row_index,
                        action="skipped",
                        reason=f"已有高优先级数据 '{existing_type}',新邮件 '{category}' 被忽略"
                    )
                else:
                    return WriteResult(
                        success=True, row_index=row_index,
                        action="skipped",
                        reason=f"同优先级数据已存在,保留原数据 '{existing_type}'"
                    )
            else:
                action_reason = "首次写入" if not existing_type else "强制覆盖(人工确认)"

            body_truncated = body_text[:cfg.get_email_body_max_chars()] if body_text else ""
            timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")

            write_data = {
                cfg_dict["sheet_email_type_col"]: category,
                cfg_dict["sheet_email_body_col"]: body_truncated,
                cfg_dict["sheet_email_date_col"]: date_str,
                cfg_dict["sheet_timestamp_col"]: timestamp,
            }

            self._write_cells(row_index, write_data, sheet_name)
            target = f"[{sheet_name}] " if sheet_name else ""
            logger.info(f"✅ {target}Row {row_index}: 写入成功 [{category}] - {action_reason}")
            return WriteResult(
                success=True,
                row_index=row_index,
                action="overwritten" if existing_type else "written",
                reason=action_reason
            )

        except Exception as e:
            logger.error(f"写入 Google Sheets 失败 (sheet={sheet_name}, row={row_index}): {e}", exc_info=True)
            return WriteResult(
                success=False,
                row_index=row_index,
                action="error",
                error=str(e)
            )

    def is_connected(self) -> bool:
        return self._initialized and bool(self._worksheets)

    def get_company_count(self) -> int:
        return len(self._company_list)

    def get_candidates_for_display(self, candidates: list) -> list[list]:
        return [
            [c["name"], c.get("sheet", ""), f"{c['score']:.1f}%", f"第 {c['row']} 行"]
            for c in candidates
        ]


_updater_instance = None


def get_sheets_updater(auto_init: bool = True):
    """获取 SheetsUpdater 单例(惰性初始化)"""
    global _updater_instance
    if _updater_instance is None:
        _updater_instance = SheetsUpdater()
    if auto_init and not _updater_instance.is_connected():
        try:
            _updater_instance.initialize()
        except Exception as e:
            logger.warning(f"Sheets 自动初始化失败(将在使用时重试): {e}")
    return _updater_instance


def reset_sheets_updater():
    """重置单例(配置变更后调用,下次使用时会重新初始化)"""
    global _updater_instance
    _updater_instance = None