tb-order-sync 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/setup.py CHANGED
@@ -7,9 +7,10 @@ Usage:
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import getpass
10
+ import os
11
11
  import re
12
12
  import shutil
13
+ import subprocess
13
14
  import sys
14
15
  import webbrowser
15
16
  from datetime import datetime
@@ -18,7 +19,8 @@ from typing import Any, Callable, Optional, Sequence
18
19
  from urllib.parse import parse_qs, urlparse
19
20
 
20
21
  try:
21
- from rich.console import Console
22
+ from rich.align import Align
23
+ from rich.console import Console, Group
22
24
  from rich.panel import Panel
23
25
  from rich.table import Table
24
26
  from rich.text import Text
@@ -28,7 +30,8 @@ except ImportError:
28
30
 
29
31
  from dotenv import dotenv_values
30
32
 
31
- from config.settings import APP_HOME, PACKAGE_ROOT
33
+ from config.settings import APP_HOME, APP_VERSION, PACKAGE_ROOT
34
+ from utils.sheet_selector import resolve_latest_month_sheet
32
35
 
33
36
  # ── UI 文案 ────────────────────────────────────────────────────────────────
34
37
  BANNER_TITLE = "多表格同步服务 — 配置向导"
@@ -58,6 +61,19 @@ FEISHU_TOKEN_DOC_URL = (
58
61
  "authentication-management/access-token/tenant_access_token_internal"
59
62
  )
60
63
  FEISHU_TOKEN_TUTORIAL_URL = "https://www.feishu.cn/content/000214591773"
64
+ _SETUP_LOGO = (
65
+ "[bold #8ecae6]████████╗██████╗ ██████╗ ██████╗ ██████╗ ███████╗██████╗[/bold #8ecae6]\n"
66
+ "[bold #38bdf8]╚══██╔══╝██╔══██╗ ██╔═══██╗██╔══██╗██╔══██╗██╔════╝██╔══██╗[/bold #38bdf8]\n"
67
+ "[bold #22d3ee] ██║ ██████╔╝ ██║ ██║██████╔╝██║ ██║█████╗ ██████╔╝[/bold #22d3ee]\n"
68
+ "[bold #2dd4bf] ██║ ██╔══██╗ ██║ ██║██╔══██╗██║ ██║██╔══╝ ██╔══██╗[/bold #2dd4bf]\n"
69
+ "[bold #86efac] ██║ ██████╔╝ ╚██████╔╝██║ ██║██████╔╝███████╗██║ ██║[/bold #86efac]\n"
70
+ "[bold #bbf7d0] ╚═╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝╚═════╝ ╚══════╝╚═╝ ╚═╝[/bold #bbf7d0]"
71
+ )
72
+
73
+
74
+ def _build_setup_version_badge() -> Panel:
75
+ text = Text(f"v{APP_VERSION}", style="bold #0f172a", justify="center")
76
+ return Panel(text, border_style="#94d2bd", box=box.ROUNDED, padding=(0, 2), title="版本")
61
77
 
62
78
 
63
79
  # ── Validators ─────────────────────────────────────────────────────────────
@@ -100,6 +116,33 @@ def _mask_secret(value: str) -> str:
100
116
  return value[:4] + "****"
101
117
 
102
118
 
119
+ def resolve_link_selection(raw: str, link_count: int) -> list[int]:
120
+ """Resolve numeric selection for link-opening prompts.
121
+
122
+ Rules:
123
+ - 0 / empty / /skip => skip
124
+ - 1 => open all
125
+ - 2..N+1 => open one specific link
126
+ """
127
+ value = raw.strip().lower()
128
+ if value in {"", "0", "/skip", "skip", "n", "no", "q", "quit"}:
129
+ return []
130
+ try:
131
+ choice = int(value)
132
+ except ValueError as exc:
133
+ raise ValueError("请输入数字编号") from exc
134
+
135
+ if choice == 0:
136
+ return []
137
+ if choice == 1:
138
+ return list(range(link_count))
139
+
140
+ index = choice - 2
141
+ if 0 <= index < link_count:
142
+ return [index]
143
+ raise ValueError("编号超出范围")
144
+
145
+
103
146
  def parse_tencent_sheet_reference(raw: str) -> tuple[str, str]:
104
147
  """Parse a Tencent Docs sheet URL or raw file id.
105
148
 
@@ -122,6 +165,10 @@ def parse_tencent_sheet_reference(raw: str) -> tuple[str, str]:
122
165
  return file_id, sheet_id
123
166
 
124
167
 
168
+ class SetupInputTerminated(RuntimeError):
169
+ """Raised when the setup input stream is unexpectedly closed."""
170
+
171
+
125
172
  # ── Setup Wizard ───────────────────────────────────────────────────────────
126
173
  class SetupWizard:
127
174
  def __init__(self, console: Optional[Console] = None) -> None:
@@ -137,6 +184,18 @@ class SetupWizard:
137
184
 
138
185
  # ── Prompt helpers ─────────────────────────────────────────────────────
139
186
 
187
+ @staticmethod
188
+ def _read_line(prompt: str = " > ") -> str:
189
+ """Read one input line using the plain stdlib input path.
190
+
191
+ This is intentionally not using `Console.input`, because the Windows
192
+ packaged runtime is more stable with the built-in `input()` behavior.
193
+ """
194
+ try:
195
+ return input(prompt).strip()
196
+ except EOFError as exc:
197
+ raise SetupInputTerminated("输入流已结束") from exc
198
+
140
199
  def _prompt(
141
200
  self,
142
201
  label: str,
@@ -145,24 +204,35 @@ class SetupWizard:
145
204
  secret: bool = False,
146
205
  validator: Optional[Callable[[str], bool]] = None,
147
206
  error_msg: str = "输入无效,请重新输入",
207
+ allow_skip: bool = False,
148
208
  ) -> str:
149
209
  """Prompt user for a value with optional validation and masking."""
150
210
  existing = self._existing.get(key, "")
151
211
  effective_default = existing or default
152
212
 
153
213
  while True:
154
- if secret:
155
- hint = f" [当前: {_mask_secret(effective_default)}]" if effective_default else ""
156
- self.console.print(f" {label}{hint}")
157
- raw = getpass.getpass(" > ")
158
- if not raw and effective_default:
159
- raw = effective_default
160
- else:
161
- hint = f" [默认: {effective_default}]" if effective_default else ""
162
- self.console.print(f" {label}{hint}")
163
- raw = input(" > ").strip()
164
- if not raw and effective_default:
165
- raw = effective_default
214
+ hint_value = _mask_secret(effective_default) if secret and effective_default else effective_default
215
+ hint_label = "当前" if secret else "默认"
216
+ hint = f" [{hint_label}: {hint_value}]" if hint_value else ""
217
+ note = " [dim]可直接粘贴[/dim]"
218
+ if allow_skip:
219
+ note += " [dim]输入 /skip 暂时跳过[/dim]"
220
+
221
+ self.console.print(f" {label}{hint}{note}")
222
+ try:
223
+ raw = self._read_line()
224
+ except SetupInputTerminated:
225
+ if allow_skip:
226
+ self.console.print(" [yellow]输入已结束,已暂时跳过当前项[/yellow]")
227
+ return ""
228
+ raise
229
+
230
+ if allow_skip and raw.lower() == "/skip":
231
+ self.console.print(" [yellow]已暂时跳过,可稍后重新运行 setup 补充[/yellow]")
232
+ return ""
233
+
234
+ if not raw and effective_default:
235
+ raw = effective_default
166
236
 
167
237
  if validator and not validator(raw):
168
238
  self.console.print(f" [red]{error_msg}[/red]")
@@ -173,9 +243,15 @@ class SetupWizard:
173
243
  def _prompt_bool(self, label: str, default: bool = False) -> bool:
174
244
  hint = "Y/n" if default else "y/N"
175
245
  self.console.print(f" {label} [{hint}]")
176
- raw = input(" > ").strip().lower()
246
+ try:
247
+ raw = self._read_line().lower()
248
+ except SetupInputTerminated:
249
+ self.console.print(" [yellow]输入已结束,已使用默认选项[/yellow]")
250
+ return default
177
251
  if not raw:
178
252
  return default
253
+ if raw in {"/skip", "skip"}:
254
+ return default
179
255
  return raw in ("y", "yes", "是")
180
256
 
181
257
  def _offer_open_links(self, title: str, links: Sequence[tuple[str, str]]) -> None:
@@ -186,25 +262,74 @@ class SetupWizard:
186
262
  self.console.print(f" [dim]{title}:[/dim]")
187
263
  for idx, (label, url) in enumerate(links, start=1):
188
264
  self.console.print(f" {idx}. {label}: [cyan]{url}[/cyan]")
265
+ if len(links) == 1:
266
+ choice_help = "0=暂时跳过,1=打开全部,2=打开该链接"
267
+ else:
268
+ choice_help = f"0=暂时跳过,1=打开全部,2..{len(links) + 1}=打开单个链接"
269
+ self.console.print(f" [dim]输入编号:{choice_help},也可输入 /skip[/dim]")
189
270
 
190
- if not self._prompt_bool("是否现在用浏览器打开这些链接?", default=False):
271
+ while True:
272
+ try:
273
+ raw = self._read_line()
274
+ except SetupInputTerminated:
275
+ self.console.print(" [yellow]输入已结束,已跳过打开链接[/yellow]")
276
+ return
277
+ try:
278
+ indexes = resolve_link_selection(raw, len(links))
279
+ except ValueError as exc:
280
+ self.console.print(f" [red]{exc}[/red]")
281
+ continue
282
+ break
283
+
284
+ if not indexes:
285
+ self.console.print(" [dim]已跳过打开链接[/dim]")
191
286
  return
192
287
 
193
288
  opened = 0
194
- for _, url in links:
289
+ for index in indexes:
290
+ label, url = links[index]
195
291
  try:
196
- if webbrowser.open_new_tab(url):
292
+ if self._open_url(url):
197
293
  opened += 1
198
- except webbrowser.Error as exc:
199
- self.console.print(f" [yellow]打开失败: {url} ({exc})[/yellow]")
294
+ else:
295
+ self.console.print(f" [yellow]未能打开: {label} - {url}[/yellow]")
200
296
  except Exception as exc: # pragma: no cover - defensive guard
201
- self.console.print(f" [yellow]打开失败: {url} ({exc})[/yellow]")
297
+ self.console.print(f" [yellow]打开失败: {label} - {url} ({exc})[/yellow]")
202
298
 
203
299
  if opened:
204
300
  self.console.print(f" [green]已尝试打开 {opened} 个链接[/green]")
205
301
  else:
206
302
  self.console.print(" [yellow]未能自动打开浏览器,请手动复制上面的链接[/yellow]")
207
303
 
304
+ @staticmethod
305
+ def _open_url(url: str) -> bool:
306
+ """Open a URL in the default browser with platform fallbacks."""
307
+ try:
308
+ if sys.platform == "darwin":
309
+ subprocess.Popen(["open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
310
+ return True
311
+ if os.name == "nt":
312
+ try:
313
+ os.startfile(url) # type: ignore[attr-defined]
314
+ return True
315
+ except Exception:
316
+ pass
317
+ subprocess.Popen(
318
+ ["cmd", "/c", "start", "", url],
319
+ stdout=subprocess.DEVNULL,
320
+ stderr=subprocess.DEVNULL,
321
+ )
322
+ return True
323
+ subprocess.Popen(["xdg-open", url], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
324
+ return True
325
+ except Exception:
326
+ pass
327
+
328
+ try:
329
+ return bool(webbrowser.open_new_tab(url))
330
+ except webbrowser.Error:
331
+ return False
332
+
208
333
  def _show_tencent_guide(self) -> None:
209
334
  """Display beginner guidance for Tencent Docs Open API setup."""
210
335
  body = (
@@ -287,16 +412,20 @@ class SetupWizard:
287
412
  self.values["TENCENT_CLIENT_ID"] = self._prompt(
288
413
  "Client ID", "TENCENT_CLIENT_ID", secret=True, validator=_not_empty,
289
414
  error_msg="Client ID 不能为空",
415
+ allow_skip=True,
290
416
  )
291
417
  self.values["TENCENT_CLIENT_SECRET"] = self._prompt(
292
418
  "Client Secret(当前运行可留空)", "TENCENT_CLIENT_SECRET", secret=True,
419
+ allow_skip=True,
293
420
  )
294
421
  self.values["TENCENT_OPEN_ID"] = self._prompt(
295
422
  "Open ID(可选,部分接口需要)", "TENCENT_OPEN_ID", secret=True,
423
+ allow_skip=True,
296
424
  )
297
425
  self.values["TENCENT_ACCESS_TOKEN"] = self._prompt(
298
426
  "Access Token", "TENCENT_ACCESS_TOKEN", secret=True, validator=_not_empty,
299
427
  error_msg="Access Token 不能为空",
428
+ allow_skip=True,
300
429
  )
301
430
 
302
431
  def _step_sheet_ids(self) -> None:
@@ -311,7 +440,12 @@ class SetupWizard:
311
440
  ref = self._prompt(
312
441
  f"{name}链接或 File ID", file_key, validator=_not_empty,
313
442
  error_msg="请输入腾讯文档链接或 File ID",
443
+ allow_skip=True,
314
444
  )
445
+ if not ref:
446
+ self.values[file_key] = ""
447
+ self.values[sheet_key] = ""
448
+ break
315
449
  file_id, sheet_id = parse_tencent_sheet_reference(ref)
316
450
  if not file_id:
317
451
  self.console.print(" [red]无法从链接中解析 File ID,请重新输入完整链接或直接填 File ID[/red]\n")
@@ -322,12 +456,23 @@ class SetupWizard:
322
456
  self.values[sheet_key] = self._prompt(
323
457
  f"{name} Sheet ID", sheet_key, default=sheet_id, validator=_not_empty,
324
458
  error_msg="Sheet ID 不能为空",
459
+ allow_skip=True,
325
460
  )
326
461
  break
327
462
 
328
463
  prompt_sheet_target("A 表(订单表)", "TENCENT_A_FILE_ID", "TENCENT_A_SHEET_ID")
464
+ self.values["TENCENT_A_SHEET_NAME_KEYWORD"] = self._prompt(
465
+ "A 表按名称自动选最新月份(可选关键字,如 毛利率)",
466
+ "TENCENT_A_SHEET_NAME_KEYWORD",
467
+ default="",
468
+ )
329
469
  self.console.print("")
330
470
  prompt_sheet_target("B 表(退款表)", "TENCENT_B_FILE_ID", "TENCENT_B_SHEET_ID")
471
+ self.values["TENCENT_B_SHEET_NAME_KEYWORD"] = self._prompt(
472
+ "B 表按名称自动选最新月份(可选关键字,如 客户退款)",
473
+ "TENCENT_B_SHEET_NAME_KEYWORD",
474
+ default="",
475
+ )
331
476
 
332
477
  def _step_feishu_creds(self) -> None:
333
478
  self.console.print(f"\n[bold cyan]🐦 {STEP_FEISHU}[/bold cyan]")
@@ -343,15 +488,19 @@ class SetupWizard:
343
488
 
344
489
  self.values["FEISHU_APP_ID"] = self._prompt(
345
490
  "飞书 App ID", "FEISHU_APP_ID", secret=True, validator=_not_empty,
491
+ allow_skip=True,
346
492
  )
347
493
  self.values["FEISHU_APP_SECRET"] = self._prompt(
348
494
  "飞书 App Secret", "FEISHU_APP_SECRET", secret=True, validator=_not_empty,
495
+ allow_skip=True,
349
496
  )
350
497
  self.values["FEISHU_C_FILE_TOKEN"] = self._prompt(
351
498
  "C 表 File Token", "FEISHU_C_FILE_TOKEN", validator=_not_empty,
499
+ allow_skip=True,
352
500
  )
353
501
  self.values["FEISHU_C_SHEET_ID"] = self._prompt(
354
502
  "C 表 Sheet ID", "FEISHU_C_SHEET_ID", validator=_not_empty,
503
+ allow_skip=True,
355
504
  )
356
505
 
357
506
  def _step_runtime(self) -> None:
@@ -464,7 +613,9 @@ class SetupWizard:
464
613
  ]),
465
614
  ("表格 ID", [
466
615
  "TENCENT_A_FILE_ID", "TENCENT_A_SHEET_ID",
616
+ "TENCENT_A_SHEET_NAME_KEYWORD",
467
617
  "TENCENT_B_FILE_ID", "TENCENT_B_SHEET_ID",
618
+ "TENCENT_B_SHEET_NAME_KEYWORD",
468
619
  ]),
469
620
  ("飞书配置", [
470
621
  "FEISHU_APP_ID", "FEISHU_APP_SECRET",
@@ -573,20 +724,36 @@ class SetupWizard:
573
724
  access_token=self.values.get("TENCENT_ACCESS_TOKEN", ""),
574
725
  open_id=self.values.get("TENCENT_OPEN_ID", ""),
575
726
  )
727
+ a_target = resolve_latest_month_sheet(
728
+ conn,
729
+ file_id=self.values.get("TENCENT_A_FILE_ID", ""),
730
+ fallback_sheet_id=self.values.get("TENCENT_A_SHEET_ID", ""),
731
+ title_keyword=self.values.get("TENCENT_A_SHEET_NAME_KEYWORD", ""),
732
+ )
733
+ b_target = resolve_latest_month_sheet(
734
+ conn,
735
+ file_id=self.values.get("TENCENT_B_FILE_ID", ""),
736
+ fallback_sheet_id=self.values.get("TENCENT_B_SHEET_ID", ""),
737
+ title_keyword=self.values.get("TENCENT_B_SHEET_NAME_KEYWORD", ""),
738
+ )
576
739
  a_rows = conn.read_rows(
577
740
  self.values.get("TENCENT_A_FILE_ID", ""),
578
- self.values.get("TENCENT_A_SHEET_ID", ""),
741
+ a_target.sheet_id,
579
742
  start_row=0,
580
743
  end_row=2,
581
744
  )
582
745
  b_rows = conn.read_rows(
583
746
  self.values.get("TENCENT_B_FILE_ID", ""),
584
- self.values.get("TENCENT_B_SHEET_ID", ""),
747
+ b_target.sheet_id,
585
748
  start_row=0,
586
749
  end_row=2,
587
750
  )
588
751
  self.console.print(f" [bold green]✅ A 表可读:{len(a_rows)} 行[/bold green]")
589
752
  self.console.print(f" [bold green]✅ B 表可读:{len(b_rows)} 行[/bold green]")
753
+ if a_target.source != "fixed":
754
+ self.console.print(f" [bold green]✅ A 表自动选择:{a_target.title} ({a_target.sheet_id})[/bold green]")
755
+ if b_target.source != "fixed":
756
+ self.console.print(f" [bold green]✅ B 表自动选择:{b_target.title} ({b_target.sheet_id})[/bold green]")
590
757
  if a_rows:
591
758
  self.console.print(f" A 表表头: {a_rows[0][:5]}...")
592
759
  if b_rows:
@@ -693,8 +860,17 @@ class SetupWizard:
693
860
  def run_full(self) -> None:
694
861
  """Run the complete setup wizard."""
695
862
  try:
863
+ hero = Group(
864
+ Align.center(Text.from_markup(_SETUP_LOGO)),
865
+ Align.right(_build_setup_version_badge()),
866
+ )
696
867
  self.console.print(Panel(
697
- f"[bold]{BANNER_TITLE}[/bold]\n{BANNER_SUBTITLE}",
868
+ Group(
869
+ hero,
870
+ Text(""),
871
+ Text(BANNER_TITLE, style="bold", justify="center"),
872
+ Text(BANNER_SUBTITLE, style="default", justify="center"),
873
+ ),
698
874
  border_style="cyan",
699
875
  padding=(1, 2),
700
876
  ))
@@ -739,6 +915,9 @@ class SetupWizard:
739
915
  except KeyboardInterrupt:
740
916
  self.console.print("\n\n[yellow]已取消配置向导[/yellow]")
741
917
  sys.exit(0)
918
+ except SetupInputTerminated:
919
+ self.console.print("\n\n[yellow]输入已结束,配置向导已安全退出[/yellow]")
920
+ sys.exit(1)
742
921
 
743
922
 
744
923
  # ── CLI handlers ───────────────────────────────────────────────────────────
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import json
5
6
  import os
6
7
  import sys
7
8
  from enum import Enum
@@ -18,6 +19,25 @@ def _get_package_root() -> Path:
18
19
  return Path(__file__).resolve().parent.parent
19
20
 
20
21
 
22
+ def _read_app_version(package_root: Path) -> str:
23
+ """Read the app version from package.json when available."""
24
+ explicit = os.environ.get("TB_APP_VERSION", "").strip()
25
+ if explicit:
26
+ return explicit
27
+
28
+ package_json = package_root / "package.json"
29
+ if package_json.exists():
30
+ try:
31
+ payload = json.loads(package_json.read_text(encoding="utf-8"))
32
+ version = str(payload.get("version", "")).strip()
33
+ if version:
34
+ return version
35
+ except (OSError, json.JSONDecodeError, TypeError, ValueError):
36
+ pass
37
+
38
+ return "0.0.0"
39
+
40
+
21
41
  def _looks_like_global_node_package(root: Path) -> bool:
22
42
  """Best-effort detection for an npm global package install."""
23
43
  parts = {part.lower() for part in root.parts}
@@ -51,6 +71,7 @@ def _default_app_home() -> Path:
51
71
  PACKAGE_ROOT = _get_package_root()
52
72
  APP_HOME = _default_app_home()
53
73
  PROJECT_ROOT = PACKAGE_ROOT
74
+ APP_VERSION = _read_app_version(PACKAGE_ROOT)
54
75
 
55
76
 
56
77
  class AppEnv(str, Enum):
@@ -86,8 +107,10 @@ class Settings(BaseSettings):
86
107
  tencent_access_token: str = ""
87
108
  tencent_a_file_id: str = ""
88
109
  tencent_a_sheet_id: str = ""
110
+ tencent_a_sheet_name_keyword: str = ""
89
111
  tencent_b_file_id: str = ""
90
112
  tencent_b_sheet_id: str = ""
113
+ tencent_b_sheet_name_keyword: str = ""
91
114
 
92
115
  # ── 飞书(预留) ─────────────────────────────────────
93
116
  feishu_app_id: str = ""
@@ -21,6 +21,7 @@ from connectors.base import BaseSheetConnector, CellUpdate
21
21
  from config.mappings import col_index_to_letter
22
22
  from utils.logger import get_logger
23
23
  from utils.retry import default_retry
24
+ from utils.sheet_selector import SheetInfo
24
25
 
25
26
  logger = get_logger(__name__)
26
27
 
@@ -184,6 +185,22 @@ class TencentDocsConnector(BaseSheetConnector):
184
185
  return [str(v) if v is not None else "" for v in rows[0]]
185
186
  return []
186
187
 
188
+ @default_retry(max_attempts=5)
189
+ def list_sheets(self, file_id: str) -> list[SheetInfo]:
190
+ """List spreadsheet tabs for a Tencent Docs file.
191
+
192
+ Uses the file metadata endpoint and extracts sheet IDs/titles defensively
193
+ because the exact response nesting can vary.
194
+ """
195
+ url = f"/openapi/spreadsheet/v3/files/{file_id}"
196
+ logger.info("Listing sheets for file=%s", file_id)
197
+ data = self._unwrap_response(self._http.get(url))
198
+ sheets = self._extract_sheet_infos(data)
199
+ if not sheets:
200
+ raise RuntimeError("Tencent Docs API returned no sheet metadata for this file")
201
+ logger.info("Found %d sheets for file=%s: %s", len(sheets), file_id, ", ".join(item.title for item in sheets))
202
+ return sheets
203
+
187
204
  @default_retry(max_attempts=5)
188
205
  def update_row_style(
189
206
  self,
@@ -361,3 +378,73 @@ class TencentDocsConnector(BaseSheetConnector):
361
378
  "blue": int(value[4:6], 16),
362
379
  "alpha": 255,
363
380
  }
381
+
382
+ @staticmethod
383
+ def _extract_sheet_infos(data: Any) -> list[SheetInfo]:
384
+ sheets: list[SheetInfo] = []
385
+ seen: set[str] = set()
386
+
387
+ def walk(node: Any) -> None:
388
+ if isinstance(node, dict):
389
+ sheet_id = TencentDocsConnector._extract_sheet_id(node)
390
+ title = TencentDocsConnector._extract_sheet_title(node)
391
+ if sheet_id and title and sheet_id not in seen:
392
+ seen.add(sheet_id)
393
+ sheets.append(
394
+ SheetInfo(
395
+ sheet_id=sheet_id,
396
+ title=title,
397
+ index=TencentDocsConnector._extract_sheet_index(node, default=len(sheets)),
398
+ )
399
+ )
400
+ for value in node.values():
401
+ walk(value)
402
+ elif isinstance(node, list):
403
+ for item in node:
404
+ walk(item)
405
+
406
+ walk(data)
407
+ sheets.sort(key=lambda item: item.index)
408
+ return sheets
409
+
410
+ @staticmethod
411
+ def _extract_sheet_id(node: dict[str, Any]) -> str:
412
+ for key in ("sheetId", "sheetID", "sheet_id"):
413
+ value = node.get(key)
414
+ if isinstance(value, str) and value.strip():
415
+ return value.strip()
416
+ props = node.get("properties")
417
+ if isinstance(props, dict):
418
+ for key in ("sheetId", "sheetID", "sheet_id"):
419
+ value = props.get(key)
420
+ if isinstance(value, str) and value.strip():
421
+ return value.strip()
422
+ return ""
423
+
424
+ @staticmethod
425
+ def _extract_sheet_title(node: dict[str, Any]) -> str:
426
+ for key in ("title", "name", "sheetName", "sheetTitle"):
427
+ value = node.get(key)
428
+ if isinstance(value, str) and value.strip():
429
+ return value.strip()
430
+ props = node.get("properties")
431
+ if isinstance(props, dict):
432
+ for key in ("title", "name", "sheetName", "sheetTitle"):
433
+ value = props.get(key)
434
+ if isinstance(value, str) and value.strip():
435
+ return value.strip()
436
+ return ""
437
+
438
+ @staticmethod
439
+ def _extract_sheet_index(node: dict[str, Any], *, default: int) -> int:
440
+ for key in ("index", "sheetIndex", "order"):
441
+ value = node.get(key)
442
+ if isinstance(value, int):
443
+ return value
444
+ props = node.get("properties")
445
+ if isinstance(props, dict):
446
+ for key in ("index", "sheetIndex", "order"):
447
+ value = props.get(key)
448
+ if isinstance(value, int):
449
+ return value
450
+ return default
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tb-order-sync",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "description": "Tencent Docs order sync CLI with one-command npm install, gross-profit automation, refund marking, self-check, and daemon mode",
5
5
  "bin": {
6
6
  "tb": "bin/tb.js"
@@ -22,6 +22,7 @@ from services.state_service import StateService
22
22
  from utils.diff import row_fingerprint
23
23
  from utils.logger import get_logger
24
24
  from utils.parser import normalize_order_no, parse_number
25
+ from utils.sheet_selector import ResolvedSheetTarget, resolve_latest_month_sheet
25
26
 
26
27
  logger = get_logger(__name__)
27
28
 
@@ -55,7 +56,8 @@ class GrossProfitService:
55
56
 
56
57
  try:
57
58
  state = self._state_svc.load()
58
- rows = self._read_a_table()
59
+ a_target = self._resolve_a_target()
60
+ rows = self._read_a_table(a_target.sheet_id)
59
61
  result.rows_read = len(rows)
60
62
  if not rows:
61
63
  logger.warning("A table returned 0 data rows")
@@ -69,7 +71,7 @@ class GrossProfitService:
69
71
  result.rows_error = errors
70
72
 
71
73
  if updates and not dry_run:
72
- self._write_updates(updates)
74
+ self._write_updates(a_target.sheet_id, updates)
73
75
  state.last_run_at = datetime.now()
74
76
  self._state_svc.save(state)
75
77
 
@@ -89,14 +91,29 @@ class GrossProfitService:
89
91
 
90
92
  # ── Internal ───────────────────────────────────────────────────────────
91
93
 
92
- def _read_a_table(self) -> list[list[Any]]:
94
+ def _read_a_table(self, sheet_id: str) -> list[list[Any]]:
93
95
  rows = self._conn.read_rows(
94
96
  self._settings.tencent_a_file_id,
95
- self._settings.tencent_a_sheet_id,
97
+ sheet_id,
96
98
  )
97
99
  # Skip header (row 0)
98
100
  return rows[1:] if rows else []
99
101
 
102
+ def _resolve_a_target(self) -> ResolvedSheetTarget:
103
+ target = resolve_latest_month_sheet(
104
+ self._conn, # type: ignore[arg-type]
105
+ file_id=self._settings.tencent_a_file_id,
106
+ fallback_sheet_id=self._settings.tencent_a_sheet_id,
107
+ title_keyword=self._settings.tencent_a_sheet_name_keyword,
108
+ )
109
+ if target.source != "fixed":
110
+ logger.info(
111
+ "A 表自动选择最新月份工作表: %s (%s)",
112
+ target.title or target.sheet_id,
113
+ target.sheet_id,
114
+ )
115
+ return target
116
+
100
117
  def _parse_rows(self, rows: list[list[Any]]) -> list[OrderRecord]:
101
118
  m = self._map
102
119
  records: list[OrderRecord] = []
@@ -167,10 +184,10 @@ class GrossProfitService:
167
184
 
168
185
  return updates, changed, errors
169
186
 
170
- def _write_updates(self, updates: list[CellUpdate]) -> None:
187
+ def _write_updates(self, sheet_id: str, updates: list[CellUpdate]) -> None:
171
188
  self._conn.batch_update(
172
189
  self._settings.tencent_a_file_id,
173
- self._settings.tencent_a_sheet_id,
190
+ sheet_id,
174
191
  updates,
175
192
  batch_size=self._settings.write_batch_size,
176
193
  )