tb-order-sync 0.3.0
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 +50 -0
- package/CHANGELOG.md +29 -0
- package/README.md +392 -0
- package/bin/tb.js +143 -0
- package/build.py +91 -0
- package/cli/__init__.py +0 -0
- package/cli/commands.py +258 -0
- package/cli/dashboard.py +327 -0
- package/cli/setup.py +698 -0
- package/config/__init__.py +4 -0
- package/config/mappings.py +63 -0
- package/config/settings.py +95 -0
- package/connectors/__init__.py +0 -0
- package/connectors/base.py +98 -0
- package/connectors/feishu_sheets.py +96 -0
- package/connectors/tencent_docs.py +253 -0
- package/main.py +6 -0
- package/models/__init__.py +12 -0
- package/models/records.py +38 -0
- package/models/state_models.py +35 -0
- package/models/task_models.py +47 -0
- package/package.json +40 -0
- package/requirements.txt +8 -0
- package/services/__init__.py +0 -0
- package/services/c_to_a_sync_service.py +49 -0
- package/services/daemon_service.py +319 -0
- package/services/gross_profit_service.py +202 -0
- package/services/refund_match_service.py +196 -0
- package/services/scheduler_service.py +76 -0
- package/services/state_service.py +50 -0
- package/sync_service.spec +93 -0
- package/utils/__init__.py +0 -0
- package/utils/diff.py +27 -0
- package/utils/logger.py +47 -0
- package/utils/parser.py +50 -0
- package/utils/retry.py +26 -0
- package//345/220/257/345/212/250.bat +125 -0
- package//345/220/257/345/212/250.command +125 -0
package/cli/commands.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""CLI entry points for the sync service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import TYPE_CHECKING, Optional
|
|
8
|
+
|
|
9
|
+
from config.settings import Settings, SyncMode, get_settings
|
|
10
|
+
from models.task_models import TaskResult
|
|
11
|
+
from services.daemon_service import DaemonService
|
|
12
|
+
from services.state_service import StateService
|
|
13
|
+
from utils.logger import get_logger, setup_logging
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from connectors.tencent_docs import TencentDocsConnector
|
|
17
|
+
|
|
18
|
+
logger = get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_connector(settings: Settings) -> TencentDocsConnector:
|
|
22
|
+
from connectors.tencent_docs import TencentDocsConnector
|
|
23
|
+
|
|
24
|
+
return TencentDocsConnector(
|
|
25
|
+
client_id=settings.tencent_client_id,
|
|
26
|
+
client_secret=settings.tencent_client_secret,
|
|
27
|
+
access_token=settings.tencent_access_token,
|
|
28
|
+
open_id=settings.tencent_open_id,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _build_state_service(settings: Settings) -> StateService:
|
|
33
|
+
return StateService(state_dir=settings.state_dir)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _add_run_options(parser: argparse.ArgumentParser) -> None:
|
|
37
|
+
"""Attach common execution flags to a parser."""
|
|
38
|
+
parser.add_argument("--dry-run", action="store_true", help="Simulate without writing")
|
|
39
|
+
parser.add_argument("--mode", choices=["incremental", "full"], help="Override sync mode")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _normalize_task_name(command: str) -> str:
|
|
43
|
+
"""Map shorthand commands to internal task names."""
|
|
44
|
+
if command in ("gross-profit", "gp"):
|
|
45
|
+
return "gross-profit"
|
|
46
|
+
if command in ("refund-match", "rm"):
|
|
47
|
+
return "refund-match"
|
|
48
|
+
if command == "all":
|
|
49
|
+
return "all"
|
|
50
|
+
return command
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def has_required_runtime_config(settings: Settings) -> bool:
|
|
54
|
+
"""Return whether Tencent runtime essentials are configured."""
|
|
55
|
+
required = [
|
|
56
|
+
settings.tencent_client_id,
|
|
57
|
+
settings.tencent_client_secret,
|
|
58
|
+
settings.tencent_access_token,
|
|
59
|
+
settings.tencent_a_file_id,
|
|
60
|
+
settings.tencent_a_sheet_id,
|
|
61
|
+
settings.tencent_b_file_id,
|
|
62
|
+
settings.tencent_b_sheet_id,
|
|
63
|
+
]
|
|
64
|
+
return all(bool(value.strip()) for value in required)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _ensure_runtime_config(settings: Settings) -> bool:
|
|
68
|
+
if has_required_runtime_config(settings):
|
|
69
|
+
return True
|
|
70
|
+
logger.error("缺少腾讯文档必填配置,请先运行 `setup` 或 `config` 完成配置。")
|
|
71
|
+
return False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def execute_tasks(
|
|
75
|
+
settings: Settings,
|
|
76
|
+
task: str,
|
|
77
|
+
*,
|
|
78
|
+
dry_run: bool = False,
|
|
79
|
+
mode: Optional[SyncMode] = None,
|
|
80
|
+
) -> list[TaskResult]:
|
|
81
|
+
"""Run one or more tasks and return their results."""
|
|
82
|
+
if not _ensure_runtime_config(settings):
|
|
83
|
+
return []
|
|
84
|
+
|
|
85
|
+
from services.gross_profit_service import GrossProfitService
|
|
86
|
+
from services.refund_match_service import RefundMatchService
|
|
87
|
+
|
|
88
|
+
connector = _build_connector(settings)
|
|
89
|
+
state_svc = _build_state_service(settings)
|
|
90
|
+
results: list[TaskResult] = []
|
|
91
|
+
|
|
92
|
+
if task in ("gross-profit", "all"):
|
|
93
|
+
svc = GrossProfitService(connector, state_svc, settings)
|
|
94
|
+
result = svc.run(mode=mode or settings.gross_profit_mode, dry_run=dry_run)
|
|
95
|
+
_print_result(result)
|
|
96
|
+
results.append(result)
|
|
97
|
+
|
|
98
|
+
if task in ("refund-match", "all"):
|
|
99
|
+
svc = RefundMatchService(connector, state_svc, settings)
|
|
100
|
+
result = svc.run(mode=mode or settings.refund_match_mode, dry_run=dry_run)
|
|
101
|
+
_print_result(result)
|
|
102
|
+
results.append(result)
|
|
103
|
+
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def cmd_run(args: argparse.Namespace, settings: Settings) -> None:
|
|
108
|
+
"""Run one or more tasks."""
|
|
109
|
+
dry_run = args.dry_run or settings.dry_run
|
|
110
|
+
mode: Optional[SyncMode] = None
|
|
111
|
+
if args.mode:
|
|
112
|
+
mode = SyncMode(args.mode)
|
|
113
|
+
|
|
114
|
+
execute_tasks(settings, args.task, dry_run=dry_run, mode=mode)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def start_scheduler(settings: Settings) -> None:
|
|
118
|
+
"""Start the foreground scheduler."""
|
|
119
|
+
if not _ensure_runtime_config(settings):
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
from services.scheduler_service import SchedulerService
|
|
123
|
+
|
|
124
|
+
connector = _build_connector(settings)
|
|
125
|
+
state_svc = _build_state_service(settings)
|
|
126
|
+
scheduler = SchedulerService(connector, state_svc, settings)
|
|
127
|
+
scheduler.start()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def cmd_schedule(args: argparse.Namespace, settings: Settings) -> None:
|
|
131
|
+
"""Start periodic scheduler."""
|
|
132
|
+
start_scheduler(settings)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def cmd_daemon(args: argparse.Namespace, settings: Settings) -> None:
|
|
136
|
+
"""Manage the scheduler daemon process."""
|
|
137
|
+
daemon = DaemonService(settings)
|
|
138
|
+
action = args.daemon_action
|
|
139
|
+
|
|
140
|
+
if action == "start":
|
|
141
|
+
if not _ensure_runtime_config(settings):
|
|
142
|
+
return
|
|
143
|
+
status = daemon.start(force=getattr(args, "force", False))
|
|
144
|
+
logger.info(status.message)
|
|
145
|
+
elif action == "stop":
|
|
146
|
+
status = daemon.stop(force=getattr(args, "force", False))
|
|
147
|
+
logger.info(status.message)
|
|
148
|
+
elif action == "restart":
|
|
149
|
+
status = daemon.restart(force=True)
|
|
150
|
+
logger.info(status.message)
|
|
151
|
+
elif action == "status":
|
|
152
|
+
status = daemon.status()
|
|
153
|
+
logger.info(status.message)
|
|
154
|
+
if status.running:
|
|
155
|
+
logger.info("daemon pid=%s log=%s", status.pid, status.log_file)
|
|
156
|
+
elif action == "logs":
|
|
157
|
+
content = daemon.read_log_tail(lines=args.lines)
|
|
158
|
+
if content:
|
|
159
|
+
print(content, end="")
|
|
160
|
+
else:
|
|
161
|
+
logger.info("No daemon log output yet: %s", daemon.log_file)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _print_result(result: TaskResult) -> None:
|
|
165
|
+
status = "OK" if result.success else "FAILED"
|
|
166
|
+
logger.info(
|
|
167
|
+
"[%s] %s — read=%d changed=%d errors=%d dry_run=%s",
|
|
168
|
+
status, result.task_name.value, result.rows_read,
|
|
169
|
+
result.rows_changed, result.rows_error, result.dry_run,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
174
|
+
parser = argparse.ArgumentParser(
|
|
175
|
+
prog="sync-service",
|
|
176
|
+
description="多表格同步与退款标记服务",
|
|
177
|
+
)
|
|
178
|
+
sub = parser.add_subparsers(dest="command", required=False)
|
|
179
|
+
|
|
180
|
+
run_parser = sub.add_parser("run", help="Run task(s)")
|
|
181
|
+
run_parser.add_argument(
|
|
182
|
+
"task",
|
|
183
|
+
choices=["gross-profit", "refund-match", "all"],
|
|
184
|
+
help="Which task to run",
|
|
185
|
+
)
|
|
186
|
+
_add_run_options(run_parser)
|
|
187
|
+
|
|
188
|
+
all_parser = sub.add_parser("all", help="直接执行全部任务")
|
|
189
|
+
_add_run_options(all_parser)
|
|
190
|
+
|
|
191
|
+
gp_parser = sub.add_parser("gross-profit", aliases=["gp"], help="直接执行毛利计算")
|
|
192
|
+
_add_run_options(gp_parser)
|
|
193
|
+
|
|
194
|
+
rm_parser = sub.add_parser("refund-match", aliases=["rm"], help="直接执行退款匹配")
|
|
195
|
+
_add_run_options(rm_parser)
|
|
196
|
+
|
|
197
|
+
sub.add_parser("schedule", aliases=["start"], help="前台启动定时调度")
|
|
198
|
+
|
|
199
|
+
daemon_parser = sub.add_parser("daemon", help="守护进程管理")
|
|
200
|
+
daemon_sub = daemon_parser.add_subparsers(dest="daemon_action", required=True)
|
|
201
|
+
daemon_start = daemon_sub.add_parser("start", help="启动后台守护进程")
|
|
202
|
+
daemon_start.add_argument("--force", action="store_true", help="已运行时先停止再重启")
|
|
203
|
+
daemon_stop = daemon_sub.add_parser("stop", help="停止后台守护进程")
|
|
204
|
+
daemon_stop.add_argument("--force", action="store_true", help="必要时强制终止")
|
|
205
|
+
daemon_sub.add_parser("restart", help="重启后台守护进程")
|
|
206
|
+
daemon_sub.add_parser("status", help="查看后台守护状态")
|
|
207
|
+
daemon_logs = daemon_sub.add_parser("logs", help="查看后台日志")
|
|
208
|
+
daemon_logs.add_argument("--lines", type=int, default=40, help="显示最后 N 行日志")
|
|
209
|
+
|
|
210
|
+
setup_parser = sub.add_parser("setup", aliases=["config"], help="交互式配置向导")
|
|
211
|
+
setup_parser.add_argument("--check", action="store_true", help="验证当前配置状态")
|
|
212
|
+
|
|
213
|
+
sub.add_parser("check", help="验证当前配置状态")
|
|
214
|
+
sub.add_parser("menu", aliases=["ui", "dashboard"], help="打开交互式控制台")
|
|
215
|
+
|
|
216
|
+
return parser
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main(argv: list[str] | None = None) -> None:
|
|
220
|
+
"""CLI main entry point."""
|
|
221
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
222
|
+
if not argv:
|
|
223
|
+
argv = ["menu"]
|
|
224
|
+
|
|
225
|
+
settings = get_settings()
|
|
226
|
+
setup_logging(level=settings.log_level, log_dir=settings.state_dir)
|
|
227
|
+
|
|
228
|
+
parser = build_parser()
|
|
229
|
+
args = parser.parse_args(argv)
|
|
230
|
+
|
|
231
|
+
if args.command in ("setup", "config"):
|
|
232
|
+
from cli.setup import cmd_setup
|
|
233
|
+
|
|
234
|
+
cmd_setup(args)
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
if args.command == "check":
|
|
238
|
+
from cli.setup import cmd_setup
|
|
239
|
+
|
|
240
|
+
args.check = True
|
|
241
|
+
cmd_setup(args)
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
if args.command in ("menu", "ui", "dashboard"):
|
|
245
|
+
from cli.dashboard import cmd_menu
|
|
246
|
+
|
|
247
|
+
cmd_menu(args, settings)
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
if args.command == "run":
|
|
251
|
+
cmd_run(args, settings)
|
|
252
|
+
elif args.command in ("all", "gross-profit", "gp", "refund-match", "rm"):
|
|
253
|
+
args.task = _normalize_task_name(args.command)
|
|
254
|
+
cmd_run(args, settings)
|
|
255
|
+
elif args.command in ("schedule", "start"):
|
|
256
|
+
cmd_schedule(args, settings)
|
|
257
|
+
elif args.command == "daemon":
|
|
258
|
+
cmd_daemon(args, settings)
|
package/cli/dashboard.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Rich-powered interactive dashboard for the sync service."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from rich import box
|
|
10
|
+
from rich.align import Align
|
|
11
|
+
from rich.columns import Columns
|
|
12
|
+
from rich.console import Console, Group
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.table import Table
|
|
15
|
+
from rich.text import Text
|
|
16
|
+
|
|
17
|
+
from config.settings import Settings, get_settings
|
|
18
|
+
from services.daemon_service import DaemonService
|
|
19
|
+
from services.state_service import StateService
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class DashboardApp:
|
|
23
|
+
"""Interactive terminal UI for daily operations."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, settings: Settings) -> None:
|
|
26
|
+
self.console = Console()
|
|
27
|
+
self._settings = settings
|
|
28
|
+
self._refresh_services()
|
|
29
|
+
|
|
30
|
+
def run(self) -> None:
|
|
31
|
+
"""Render dashboard and handle user actions until exit."""
|
|
32
|
+
while True:
|
|
33
|
+
self._refresh_settings()
|
|
34
|
+
self.console.clear()
|
|
35
|
+
self.console.print(self._build_screen())
|
|
36
|
+
|
|
37
|
+
choice = self._ask(
|
|
38
|
+
"选择操作",
|
|
39
|
+
default="1",
|
|
40
|
+
choices={"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "0"},
|
|
41
|
+
)
|
|
42
|
+
if not self._handle_choice(choice):
|
|
43
|
+
break
|
|
44
|
+
|
|
45
|
+
def _refresh_settings(self) -> None:
|
|
46
|
+
get_settings.cache_clear()
|
|
47
|
+
self._settings = get_settings()
|
|
48
|
+
self._refresh_services()
|
|
49
|
+
|
|
50
|
+
def _refresh_services(self) -> None:
|
|
51
|
+
self._daemon = DaemonService(self._settings)
|
|
52
|
+
self._state_svc = StateService(self._settings.state_dir)
|
|
53
|
+
|
|
54
|
+
def _build_screen(self) -> Group:
|
|
55
|
+
daemon_status = self._daemon.status()
|
|
56
|
+
state = self._state_svc.load(quiet=True)
|
|
57
|
+
|
|
58
|
+
title = Text("多表格同步与退款标记服务", style="bold white")
|
|
59
|
+
subtitle = Text("Scheduler Console", style="bold #8ecae6")
|
|
60
|
+
header = Panel(
|
|
61
|
+
Align.center(Group(title, subtitle)),
|
|
62
|
+
border_style="#219ebc",
|
|
63
|
+
box=box.HEAVY,
|
|
64
|
+
padding=(1, 2),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
runtime_panel = Panel(
|
|
68
|
+
self._build_runtime_table(),
|
|
69
|
+
title="[bold #023047]运行配置[/bold #023047]",
|
|
70
|
+
border_style="#8ecae6",
|
|
71
|
+
box=box.ROUNDED,
|
|
72
|
+
)
|
|
73
|
+
daemon_panel = Panel(
|
|
74
|
+
self._build_daemon_table(daemon_status),
|
|
75
|
+
title="[bold #023047]守护进程[/bold #023047]",
|
|
76
|
+
border_style="#90be6d" if daemon_status.running else "#f4a261",
|
|
77
|
+
box=box.ROUNDED,
|
|
78
|
+
)
|
|
79
|
+
state_panel = Panel(
|
|
80
|
+
self._build_state_table(state),
|
|
81
|
+
title="[bold #023047]同步状态[/bold #023047]",
|
|
82
|
+
border_style="#ffb703",
|
|
83
|
+
box=box.ROUNDED,
|
|
84
|
+
)
|
|
85
|
+
config_panel = Panel(
|
|
86
|
+
self._build_config_table(),
|
|
87
|
+
title="[bold #023047]接入状态[/bold #023047]",
|
|
88
|
+
border_style="#fb8500" if self._is_config_ready() else "#d62828",
|
|
89
|
+
box=box.ROUNDED,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
actions = Panel(
|
|
93
|
+
self._build_action_table(),
|
|
94
|
+
title="[bold #023047]操作台[/bold #023047]",
|
|
95
|
+
border_style="#219ebc",
|
|
96
|
+
box=box.ROUNDED,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
footer = Panel(
|
|
100
|
+
Text(
|
|
101
|
+
f"日志目录: {Path(self._settings.state_dir).resolve()} "
|
|
102
|
+
f"后台日志: {self._daemon.log_file.name}",
|
|
103
|
+
style="dim",
|
|
104
|
+
),
|
|
105
|
+
border_style="#577590",
|
|
106
|
+
box=box.SIMPLE,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return Group(
|
|
110
|
+
header,
|
|
111
|
+
Columns([runtime_panel, daemon_panel], equal=True, expand=True),
|
|
112
|
+
Columns([state_panel, config_panel], equal=True, expand=True),
|
|
113
|
+
actions,
|
|
114
|
+
footer,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _build_runtime_table(self) -> Table:
|
|
118
|
+
table = Table(box=None, show_header=False, pad_edge=False)
|
|
119
|
+
table.add_column(style="bold white")
|
|
120
|
+
table.add_column(style="#023047")
|
|
121
|
+
table.add_row("环境", self._settings.app_env.value)
|
|
122
|
+
table.add_row("间隔", f"{self._settings.task_interval_minutes} 分钟")
|
|
123
|
+
table.add_row("抖动", f"{self._settings.startup_jitter_seconds} 秒")
|
|
124
|
+
table.add_row("毛利模式", self._settings.gross_profit_mode.value)
|
|
125
|
+
table.add_row("退款模式", self._settings.refund_match_mode.value)
|
|
126
|
+
table.add_row("Dry Run", "开启" if self._settings.dry_run else "关闭")
|
|
127
|
+
return table
|
|
128
|
+
|
|
129
|
+
def _build_daemon_table(self, status) -> Table:
|
|
130
|
+
table = Table(box=None, show_header=False, pad_edge=False)
|
|
131
|
+
table.add_column(style="bold white")
|
|
132
|
+
table.add_column(style="#023047")
|
|
133
|
+
table.add_row("状态", "[green]运行中[/green]" if status.running else "[yellow]未运行[/yellow]")
|
|
134
|
+
table.add_row("PID", str(status.pid or "-"))
|
|
135
|
+
table.add_row("启动时间", status.started_at or "-")
|
|
136
|
+
table.add_row("日志文件", status.log_file.name)
|
|
137
|
+
table.add_row("PID 文件", status.pid_file.name)
|
|
138
|
+
return table
|
|
139
|
+
|
|
140
|
+
def _build_state_table(self, state) -> Table:
|
|
141
|
+
table = Table(box=None, show_header=False, pad_edge=False)
|
|
142
|
+
table.add_column(style="bold white")
|
|
143
|
+
table.add_column(style="#023047")
|
|
144
|
+
table.add_row("上次运行", self._fmt_time(state.last_run_at))
|
|
145
|
+
table.add_row("A 表指纹", str(len(state.a_table_fingerprints)))
|
|
146
|
+
table.add_row("退款快照", str(len(state.b_table_refund_set)))
|
|
147
|
+
table.add_row("退款哈希", state.b_table_refund_hash[:12] if state.b_table_refund_hash else "-")
|
|
148
|
+
table.add_row("C 表预留", str(len(state.c_table_fingerprints)))
|
|
149
|
+
return table
|
|
150
|
+
|
|
151
|
+
def _build_config_table(self) -> Table:
|
|
152
|
+
ready = self._is_config_ready()
|
|
153
|
+
table = Table(box=None, show_header=False, pad_edge=False)
|
|
154
|
+
table.add_column(style="bold white")
|
|
155
|
+
table.add_column(style="#023047")
|
|
156
|
+
table.add_row("腾讯 A/B 表", "[green]已配置[/green]" if ready else "[red]未完成[/red]")
|
|
157
|
+
table.add_row("飞书 C 表", "[green]已录入[/green]" if self._settings.feishu_app_id else "[yellow]待接入[/yellow]")
|
|
158
|
+
table.add_row("样式更新", "开启" if self._settings.enable_style_update else "关闭")
|
|
159
|
+
table.add_row("批量写入", str(self._settings.write_batch_size))
|
|
160
|
+
table.add_row("重试次数", str(self._settings.retry_times))
|
|
161
|
+
return table
|
|
162
|
+
|
|
163
|
+
def _build_action_table(self) -> Table:
|
|
164
|
+
table = Table(box=box.SIMPLE_HEAVY, expand=True)
|
|
165
|
+
table.add_column("编号", justify="center", style="bold #219ebc", width=6)
|
|
166
|
+
table.add_column("动作", style="bold white")
|
|
167
|
+
table.add_column("说明", style="#023047")
|
|
168
|
+
table.add_row("1", "执行全部任务", "毛利计算 + 退款匹配")
|
|
169
|
+
table.add_row("2", "模拟执行", "全部任务 dry-run,不写入表格")
|
|
170
|
+
table.add_row("3", "仅毛利计算", "按当前模式处理 A 表")
|
|
171
|
+
table.add_row("4", "仅退款匹配", "刷新退款状态列")
|
|
172
|
+
table.add_row("5", "启动守护", "后台持续运行定时调度")
|
|
173
|
+
table.add_row("6", "停止守护", "停止后台调度进程")
|
|
174
|
+
table.add_row("7", "重启守护", "重启后台调度进程")
|
|
175
|
+
table.add_row("8", "查看后台日志", "显示守护日志末尾 40 行")
|
|
176
|
+
table.add_row("9", "配置向导", "打开交互式 setup")
|
|
177
|
+
table.add_row("10", "配置检查", "检查 .env 完整性")
|
|
178
|
+
table.add_row("11", "前台调度", "当前终端直接运行 scheduler")
|
|
179
|
+
table.add_row("0", "退出", "返回系统")
|
|
180
|
+
return table
|
|
181
|
+
|
|
182
|
+
def _handle_choice(self, choice: str) -> bool:
|
|
183
|
+
if choice == "0":
|
|
184
|
+
return False
|
|
185
|
+
if choice == "1":
|
|
186
|
+
self._run_task("all")
|
|
187
|
+
elif choice == "2":
|
|
188
|
+
self._run_task("all", dry_run=True)
|
|
189
|
+
elif choice == "3":
|
|
190
|
+
self._run_task("gross-profit")
|
|
191
|
+
elif choice == "4":
|
|
192
|
+
self._run_task("refund-match")
|
|
193
|
+
elif choice == "5":
|
|
194
|
+
self._daemon_action("start")
|
|
195
|
+
elif choice == "6":
|
|
196
|
+
self._daemon_action("stop")
|
|
197
|
+
elif choice == "7":
|
|
198
|
+
self._daemon_action("restart")
|
|
199
|
+
elif choice == "8":
|
|
200
|
+
self._show_log_tail()
|
|
201
|
+
elif choice == "9":
|
|
202
|
+
self._run_setup(check=False)
|
|
203
|
+
elif choice == "10":
|
|
204
|
+
self._run_setup(check=True)
|
|
205
|
+
elif choice == "11":
|
|
206
|
+
self._run_foreground_scheduler()
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
def _run_task(self, task: str, *, dry_run: bool = False) -> None:
|
|
210
|
+
if not self._ensure_config():
|
|
211
|
+
return
|
|
212
|
+
from cli.commands import execute_tasks
|
|
213
|
+
|
|
214
|
+
results = execute_tasks(self._settings, task, dry_run=dry_run)
|
|
215
|
+
table = Table(box=box.SIMPLE_HEAVY, expand=True)
|
|
216
|
+
table.add_column("任务", style="bold cyan")
|
|
217
|
+
table.add_column("结果", justify="center")
|
|
218
|
+
table.add_column("读取", justify="right")
|
|
219
|
+
table.add_column("变更", justify="right")
|
|
220
|
+
table.add_column("异常", justify="right")
|
|
221
|
+
for item in results:
|
|
222
|
+
status = "[green]成功[/green]" if item.success else "[red]失败[/red]"
|
|
223
|
+
table.add_row(
|
|
224
|
+
item.task_name.value,
|
|
225
|
+
status,
|
|
226
|
+
str(item.rows_read),
|
|
227
|
+
str(item.rows_changed),
|
|
228
|
+
str(item.rows_error),
|
|
229
|
+
)
|
|
230
|
+
self._pause_with_panel(Panel(table, title="执行结果", border_style="#219ebc", box=box.ROUNDED))
|
|
231
|
+
|
|
232
|
+
def _daemon_action(self, action: str) -> None:
|
|
233
|
+
if action == "start" and not self._ensure_config():
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
if action == "start":
|
|
237
|
+
status = self._daemon.start()
|
|
238
|
+
elif action == "stop":
|
|
239
|
+
status = self._daemon.stop(force=True)
|
|
240
|
+
else:
|
|
241
|
+
status = self._daemon.restart()
|
|
242
|
+
|
|
243
|
+
self._pause_with_panel(Panel(status.message, title="守护结果", border_style="#90be6d", box=box.ROUNDED))
|
|
244
|
+
|
|
245
|
+
def _show_log_tail(self) -> None:
|
|
246
|
+
content = self._daemon.read_log_tail(lines=40)
|
|
247
|
+
body = content if content else "后台日志暂时为空。"
|
|
248
|
+
self._pause_with_panel(
|
|
249
|
+
Panel(body, title=f"后台日志 · {self._daemon.log_file.name}", border_style="#ffb703", box=box.ROUNDED)
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
def _run_setup(self, *, check: bool) -> None:
|
|
253
|
+
from cli.setup import cmd_setup
|
|
254
|
+
|
|
255
|
+
args = argparse.Namespace(check=check)
|
|
256
|
+
cmd_setup(args)
|
|
257
|
+
self._refresh_settings()
|
|
258
|
+
self._wait()
|
|
259
|
+
|
|
260
|
+
def _run_foreground_scheduler(self) -> None:
|
|
261
|
+
if not self._ensure_config():
|
|
262
|
+
return
|
|
263
|
+
if not self._confirm("前台调度会占用当前终端,确认继续?", default=False):
|
|
264
|
+
return
|
|
265
|
+
from cli.commands import start_scheduler
|
|
266
|
+
|
|
267
|
+
start_scheduler(self._settings)
|
|
268
|
+
|
|
269
|
+
def _ensure_config(self) -> bool:
|
|
270
|
+
if self._is_config_ready():
|
|
271
|
+
return True
|
|
272
|
+
self._pause_with_panel(
|
|
273
|
+
Panel(
|
|
274
|
+
"腾讯文档必填配置尚未完成,请先运行配置向导。",
|
|
275
|
+
title="配置未完成",
|
|
276
|
+
border_style="red",
|
|
277
|
+
box=box.ROUNDED,
|
|
278
|
+
)
|
|
279
|
+
)
|
|
280
|
+
return False
|
|
281
|
+
|
|
282
|
+
def _is_config_ready(self) -> bool:
|
|
283
|
+
fields = [
|
|
284
|
+
self._settings.tencent_client_id,
|
|
285
|
+
self._settings.tencent_client_secret,
|
|
286
|
+
self._settings.tencent_access_token,
|
|
287
|
+
self._settings.tencent_a_file_id,
|
|
288
|
+
self._settings.tencent_a_sheet_id,
|
|
289
|
+
self._settings.tencent_b_file_id,
|
|
290
|
+
self._settings.tencent_b_sheet_id,
|
|
291
|
+
]
|
|
292
|
+
return all(bool(value.strip()) for value in fields)
|
|
293
|
+
|
|
294
|
+
def _pause_with_panel(self, panel: Panel) -> None:
|
|
295
|
+
self.console.clear()
|
|
296
|
+
self.console.print(panel)
|
|
297
|
+
self._wait()
|
|
298
|
+
|
|
299
|
+
def _wait(self) -> None:
|
|
300
|
+
self.console.input("[dim]按回车返回控制台[/dim]")
|
|
301
|
+
|
|
302
|
+
def _ask(self, label: str, *, default: str, choices: set[str]) -> str:
|
|
303
|
+
prompt = f"[bold cyan]{label}[/bold cyan] [dim](默认 {default})[/dim]: "
|
|
304
|
+
while True:
|
|
305
|
+
raw = self.console.input(prompt).strip()
|
|
306
|
+
value = raw or default
|
|
307
|
+
if value in choices:
|
|
308
|
+
return value
|
|
309
|
+
self.console.print("[red]输入无效,请重新输入。[/red]")
|
|
310
|
+
|
|
311
|
+
def _confirm(self, label: str, *, default: bool = False) -> bool:
|
|
312
|
+
hint = "Y/n" if default else "y/N"
|
|
313
|
+
raw = self.console.input(f"[bold cyan]{label}[/bold cyan] [dim]{hint}[/dim]: ").strip().lower()
|
|
314
|
+
if not raw:
|
|
315
|
+
return default
|
|
316
|
+
return raw in {"y", "yes", "是"}
|
|
317
|
+
|
|
318
|
+
@staticmethod
|
|
319
|
+
def _fmt_time(value: datetime | None) -> str:
|
|
320
|
+
if value is None:
|
|
321
|
+
return "-"
|
|
322
|
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def cmd_menu(args: argparse.Namespace, settings: Settings) -> None:
|
|
326
|
+
"""Entry point for the interactive dashboard."""
|
|
327
|
+
DashboardApp(settings).run()
|