tb-order-sync 0.4.1 → 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/.env.example +4 -0
- package/CHANGELOG.md +67 -0
- package/README.md +15 -1
- package/bin/postinstall.js +21 -0
- package/bin/runtime.js +228 -0
- package/bin/tb.js +11 -108
- package/build.py +1 -0
- package/cli/dashboard.py +167 -29
- package/cli/setup.py +209 -28
- package/config/settings.py +73 -7
- package/connectors/tencent_docs.py +87 -0
- package/package.json +3 -2
- package/services/daemon_service.py +12 -4
- package/services/gross_profit_service.py +23 -6
- package/services/refund_match_service.py +43 -10
- package/sync_service.spec +1 -0
- package/utils/sheet_selector.py +125 -0
- package//345/220/257/345/212/250.bat +25 -6
- package//345/277/253/351/200/237/345/274/200/345/247/213.txt +16 -9
package/cli/setup.py
CHANGED
|
@@ -7,9 +7,10 @@ Usage:
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import
|
|
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.
|
|
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
|
|
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 = "多表格同步服务 — 配置向导"
|
|
@@ -43,8 +46,8 @@ STEP_SUMMARY = "配置总览"
|
|
|
43
46
|
STEP_WRITE = "写入配置"
|
|
44
47
|
STEP_TEST = "连接测试"
|
|
45
48
|
|
|
46
|
-
ENV_PATH =
|
|
47
|
-
ENV_EXAMPLE_PATH =
|
|
49
|
+
ENV_PATH = APP_HOME / ".env"
|
|
50
|
+
ENV_EXAMPLE_PATH = PACKAGE_ROOT / ".env.example"
|
|
48
51
|
|
|
49
52
|
# Column letter validator
|
|
50
53
|
_COL_RE = re.compile(r"^[A-Z]{1,3}$")
|
|
@@ -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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
raw =
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
289
|
+
for index in indexes:
|
|
290
|
+
label, url = links[index]
|
|
195
291
|
try:
|
|
196
|
-
if
|
|
292
|
+
if self._open_url(url):
|
|
197
293
|
opened += 1
|
|
198
|
-
|
|
199
|
-
|
|
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",
|
|
@@ -551,7 +702,9 @@ class SetupWizard:
|
|
|
551
702
|
self.console.print(f"\n[bold cyan]🔌 {STEP_TEST}[/bold cyan]")
|
|
552
703
|
self.console.print(" 正在执行启动自检:状态目录 + 腾讯文档 A/B 表读取...\n")
|
|
553
704
|
|
|
554
|
-
state_dir = Path(self.values.get("STATE_DIR", "state"))
|
|
705
|
+
state_dir = Path(self.values.get("STATE_DIR", "state")).expanduser()
|
|
706
|
+
if not state_dir.is_absolute():
|
|
707
|
+
state_dir = APP_HOME / state_dir
|
|
555
708
|
try:
|
|
556
709
|
state_dir.mkdir(parents=True, exist_ok=True)
|
|
557
710
|
probe = state_dir / ".write_test"
|
|
@@ -571,20 +724,36 @@ class SetupWizard:
|
|
|
571
724
|
access_token=self.values.get("TENCENT_ACCESS_TOKEN", ""),
|
|
572
725
|
open_id=self.values.get("TENCENT_OPEN_ID", ""),
|
|
573
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
|
+
)
|
|
574
739
|
a_rows = conn.read_rows(
|
|
575
740
|
self.values.get("TENCENT_A_FILE_ID", ""),
|
|
576
|
-
|
|
741
|
+
a_target.sheet_id,
|
|
577
742
|
start_row=0,
|
|
578
743
|
end_row=2,
|
|
579
744
|
)
|
|
580
745
|
b_rows = conn.read_rows(
|
|
581
746
|
self.values.get("TENCENT_B_FILE_ID", ""),
|
|
582
|
-
|
|
747
|
+
b_target.sheet_id,
|
|
583
748
|
start_row=0,
|
|
584
749
|
end_row=2,
|
|
585
750
|
)
|
|
586
751
|
self.console.print(f" [bold green]✅ A 表可读:{len(a_rows)} 行[/bold green]")
|
|
587
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]")
|
|
588
757
|
if a_rows:
|
|
589
758
|
self.console.print(f" A 表表头: {a_rows[0][:5]}...")
|
|
590
759
|
if b_rows:
|
|
@@ -691,8 +860,17 @@ class SetupWizard:
|
|
|
691
860
|
def run_full(self) -> None:
|
|
692
861
|
"""Run the complete setup wizard."""
|
|
693
862
|
try:
|
|
863
|
+
hero = Group(
|
|
864
|
+
Align.center(Text.from_markup(_SETUP_LOGO)),
|
|
865
|
+
Align.right(_build_setup_version_badge()),
|
|
866
|
+
)
|
|
694
867
|
self.console.print(Panel(
|
|
695
|
-
|
|
868
|
+
Group(
|
|
869
|
+
hero,
|
|
870
|
+
Text(""),
|
|
871
|
+
Text(BANNER_TITLE, style="bold", justify="center"),
|
|
872
|
+
Text(BANNER_SUBTITLE, style="default", justify="center"),
|
|
873
|
+
),
|
|
696
874
|
border_style="cyan",
|
|
697
875
|
padding=(1, 2),
|
|
698
876
|
))
|
|
@@ -737,6 +915,9 @@ class SetupWizard:
|
|
|
737
915
|
except KeyboardInterrupt:
|
|
738
916
|
self.console.print("\n\n[yellow]已取消配置向导[/yellow]")
|
|
739
917
|
sys.exit(0)
|
|
918
|
+
except SetupInputTerminated:
|
|
919
|
+
self.console.print("\n\n[yellow]输入已结束,配置向导已安全退出[/yellow]")
|
|
920
|
+
sys.exit(1)
|
|
740
921
|
|
|
741
922
|
|
|
742
923
|
# ── CLI handlers ───────────────────────────────────────────────────────────
|
package/config/settings.py
CHANGED
|
@@ -2,24 +2,76 @@
|
|
|
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
|
|
8
9
|
from functools import lru_cache
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
|
|
11
|
-
from pydantic import
|
|
12
|
+
from pydantic import field_validator
|
|
12
13
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
13
14
|
|
|
14
|
-
def
|
|
15
|
-
"""Resolve
|
|
15
|
+
def _get_package_root() -> Path:
|
|
16
|
+
"""Resolve package root, compatible with PyInstaller frozen exe."""
|
|
16
17
|
if getattr(sys, "frozen", False):
|
|
17
|
-
# Running as packaged exe — use exe's directory as root
|
|
18
18
|
return Path(sys.executable).resolve().parent
|
|
19
19
|
return Path(__file__).resolve().parent.parent
|
|
20
20
|
|
|
21
21
|
|
|
22
|
-
|
|
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
|
+
|
|
41
|
+
def _looks_like_global_node_package(root: Path) -> bool:
|
|
42
|
+
"""Best-effort detection for an npm global package install."""
|
|
43
|
+
parts = {part.lower() for part in root.parts}
|
|
44
|
+
return root.name == "tb-order-sync" and "node_modules" in parts
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _default_app_home() -> Path:
|
|
48
|
+
"""Resolve the writable runtime home for config/state/venv."""
|
|
49
|
+
package_root = _get_package_root()
|
|
50
|
+
explicit = os.environ.get("TB_HOME", "").strip()
|
|
51
|
+
if explicit:
|
|
52
|
+
return Path(explicit).expanduser().resolve()
|
|
53
|
+
|
|
54
|
+
if getattr(sys, "frozen", False):
|
|
55
|
+
return package_root
|
|
56
|
+
|
|
57
|
+
if _looks_like_global_node_package(package_root):
|
|
58
|
+
home = Path.home()
|
|
59
|
+
if os.name == "nt":
|
|
60
|
+
appdata = os.environ.get("APPDATA")
|
|
61
|
+
if appdata:
|
|
62
|
+
return Path(appdata).expanduser().resolve() / "tb-order-sync"
|
|
63
|
+
return (home / "AppData" / "Roaming" / "tb-order-sync").resolve()
|
|
64
|
+
if sys.platform == "darwin":
|
|
65
|
+
return (home / "Library" / "Application Support" / "tb-order-sync").resolve()
|
|
66
|
+
return (home / ".tb-order-sync").resolve()
|
|
67
|
+
|
|
68
|
+
return package_root
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
PACKAGE_ROOT = _get_package_root()
|
|
72
|
+
APP_HOME = _default_app_home()
|
|
73
|
+
PROJECT_ROOT = PACKAGE_ROOT
|
|
74
|
+
APP_VERSION = _read_app_version(PACKAGE_ROOT)
|
|
23
75
|
|
|
24
76
|
|
|
25
77
|
class AppEnv(str, Enum):
|
|
@@ -37,7 +89,7 @@ class Settings(BaseSettings):
|
|
|
37
89
|
"""All configuration knobs, sourced from .env / environment variables."""
|
|
38
90
|
|
|
39
91
|
model_config = SettingsConfigDict(
|
|
40
|
-
env_file=os.environ.get("DOTENV_PATH", str(
|
|
92
|
+
env_file=os.environ.get("DOTENV_PATH", str(APP_HOME / ".env")),
|
|
41
93
|
env_file_encoding="utf-8",
|
|
42
94
|
case_sensitive=False,
|
|
43
95
|
extra="ignore",
|
|
@@ -46,7 +98,7 @@ class Settings(BaseSettings):
|
|
|
46
98
|
# ── 基础 ──────────────────────────────────────────────
|
|
47
99
|
app_env: AppEnv = AppEnv.DEV
|
|
48
100
|
log_level: str = "INFO"
|
|
49
|
-
state_dir: str = str(
|
|
101
|
+
state_dir: str = str(APP_HOME / "state")
|
|
50
102
|
|
|
51
103
|
# ── 腾讯文档 ──────────────────────────────────────────
|
|
52
104
|
tencent_client_id: str = ""
|
|
@@ -55,8 +107,10 @@ class Settings(BaseSettings):
|
|
|
55
107
|
tencent_access_token: str = ""
|
|
56
108
|
tencent_a_file_id: str = ""
|
|
57
109
|
tencent_a_sheet_id: str = ""
|
|
110
|
+
tencent_a_sheet_name_keyword: str = ""
|
|
58
111
|
tencent_b_file_id: str = ""
|
|
59
112
|
tencent_b_sheet_id: str = ""
|
|
113
|
+
tencent_b_sheet_name_keyword: str = ""
|
|
60
114
|
|
|
61
115
|
# ── 飞书(预留) ─────────────────────────────────────
|
|
62
116
|
feishu_app_id: str = ""
|
|
@@ -89,6 +143,18 @@ class Settings(BaseSettings):
|
|
|
89
143
|
refund_status_text: str = "已退款"
|
|
90
144
|
data_error_text: str = "数据异常"
|
|
91
145
|
|
|
146
|
+
@field_validator("state_dir", mode="before")
|
|
147
|
+
@classmethod
|
|
148
|
+
def _resolve_state_dir(cls, value: object) -> str:
|
|
149
|
+
"""Resolve relative state_dir values against APP_HOME."""
|
|
150
|
+
if value in (None, ""):
|
|
151
|
+
return str(APP_HOME / "state")
|
|
152
|
+
|
|
153
|
+
path = Path(str(value)).expanduser()
|
|
154
|
+
if not path.is_absolute():
|
|
155
|
+
path = APP_HOME / path
|
|
156
|
+
return str(path.resolve())
|
|
157
|
+
|
|
92
158
|
|
|
93
159
|
@lru_cache(maxsize=1)
|
|
94
160
|
def get_settings() -> Settings:
|
|
@@ -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
|