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.
@@ -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)
@@ -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()