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/.env.example +4 -0
- package/CHANGELOG.md +53 -0
- package/README.md +4 -1
- package/build.py +1 -0
- package/cli/dashboard.py +167 -29
- package/cli/setup.py +204 -25
- package/config/settings.py +23 -0
- package/connectors/tencent_docs.py +87 -0
- package/package.json +1 -1
- 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/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 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
|
-
|
|
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",
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ───────────────────────────────────────────────────────────
|
package/config/settings.py
CHANGED
|
@@ -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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
+
sheet_id,
|
|
174
191
|
updates,
|
|
175
192
|
batch_size=self._settings.write_batch_size,
|
|
176
193
|
)
|