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,35 @@
1
+ """Incremental sync state models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from typing import Optional
7
+
8
+ from pydantic import BaseModel, Field
9
+
10
+
11
+ class RowFingerprint(BaseModel):
12
+ """Fingerprint for a single row, used for change detection."""
13
+
14
+ row_index: int
15
+ order_no: str = ""
16
+ fingerprint: str = "" # MD5 hex of key fields
17
+
18
+
19
+ class SyncState(BaseModel):
20
+ """Persisted state for incremental sync."""
21
+
22
+ last_run_at: Optional[datetime] = None
23
+
24
+ # A 表: order_no -> fingerprint
25
+ a_table_fingerprints: dict[str, str] = Field(default_factory=dict)
26
+
27
+ # B 表: 退款单号集合 hash
28
+ b_table_refund_hash: str = ""
29
+
30
+ # B 表: 退款单号快照
31
+ b_table_refund_set: list[str] = Field(default_factory=list)
32
+
33
+ # C 表(预留)
34
+ c_table_fingerprints: dict[str, str] = Field(default_factory=dict)
35
+ c_table_last_run_at: Optional[datetime] = None
@@ -0,0 +1,47 @@
1
+ """Task execution models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from enum import Enum
7
+ from typing import Optional
8
+
9
+ from pydantic import BaseModel, Field
10
+
11
+ from config.settings import SyncMode
12
+
13
+
14
+ class TaskName(str, Enum):
15
+ GROSS_PROFIT = "gross_profit"
16
+ REFUND_MATCH = "refund_match"
17
+ C_TO_A_SYNC = "c_to_a_sync"
18
+
19
+
20
+ class SyncTaskConfig(BaseModel):
21
+ """Configuration for a single sync task execution."""
22
+
23
+ task_name: TaskName
24
+ mode: SyncMode = SyncMode.INCREMENTAL
25
+ dry_run: bool = False
26
+ batch_size: int = 100
27
+
28
+
29
+ class TaskResult(BaseModel):
30
+ """Result summary after a task execution."""
31
+
32
+ task_name: TaskName
33
+ success: bool = True
34
+ mode: SyncMode = SyncMode.FULL
35
+ rows_read: int = 0
36
+ rows_changed: int = 0
37
+ rows_error: int = 0
38
+ dry_run: bool = False
39
+ started_at: datetime = Field(default_factory=datetime.now)
40
+ finished_at: Optional[datetime] = None
41
+ error_message: Optional[str] = None
42
+
43
+ def finish(self, success: bool = True, error_message: Optional[str] = None) -> None:
44
+ self.finished_at = datetime.now()
45
+ self.success = success
46
+ if error_message:
47
+ self.error_message = error_message
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "tb-order-sync",
3
+ "version": "0.3.0",
4
+ "description": "Multi-sheet order sync and refund workflow service with a tb CLI launcher",
5
+ "bin": {
6
+ "tb": "bin/tb.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "cli/**/*.py",
11
+ "config/**/*.py",
12
+ "connectors/**/*.py",
13
+ "models/**/*.py",
14
+ "services/**/*.py",
15
+ "utils/**/*.py",
16
+ "main.py",
17
+ "requirements.txt",
18
+ "README.md",
19
+ "CHANGELOG.md",
20
+ ".env.example",
21
+ "build.py",
22
+ "sync_service.spec",
23
+ "启动.bat",
24
+ "启动.command"
25
+ ],
26
+ "scripts": {
27
+ "tb": "node bin/tb.js",
28
+ "check": "node bin/tb.js check",
29
+ "pack:local": "npm pack"
30
+ },
31
+ "keywords": [
32
+ "python",
33
+ "scheduler",
34
+ "daemon",
35
+ "rich-cli",
36
+ "tencent-docs",
37
+ "feishu"
38
+ ],
39
+ "license": "MIT"
40
+ }
@@ -0,0 +1,8 @@
1
+ httpx>=0.27.0
2
+ pydantic>=2.5.0
3
+ pydantic-settings>=2.1.0
4
+ tenacity>=8.2.0
5
+ APScheduler>=3.10.0
6
+ python-dotenv>=1.0.0
7
+ rich>=13.0.0
8
+ pytest>=7.4.0
File without changes
@@ -0,0 +1,49 @@
1
+ """C table → A table sync service (skeleton / placeholder).
2
+
3
+ This service will sync raw data from Feishu C table into Tencent Docs A table.
4
+ Currently a structural placeholder — implementation pending Feishu connector completion.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Optional
10
+
11
+ from config.settings import Settings, SyncMode, get_settings
12
+ from connectors.base import BaseSheetConnector
13
+ from models.task_models import TaskName, TaskResult
14
+ from services.state_service import StateService
15
+ from utils.logger import get_logger
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ class CToASyncService:
21
+ """Sync records from C table (Feishu) to A table (Tencent Docs).
22
+
23
+ TODO: Implement when Feishu connector is ready.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ source_connector: BaseSheetConnector,
29
+ target_connector: BaseSheetConnector,
30
+ state_service: StateService,
31
+ settings: Optional[Settings] = None,
32
+ ) -> None:
33
+ self._source = source_connector
34
+ self._target = target_connector
35
+ self._state_svc = state_service
36
+ self._settings = settings or get_settings()
37
+
38
+ def run(
39
+ self,
40
+ mode: Optional[SyncMode] = None,
41
+ dry_run: Optional[bool] = None,
42
+ ) -> TaskResult:
43
+ mode = mode or self._settings.c_sync_mode
44
+ dry_run = dry_run if dry_run is not None else self._settings.dry_run
45
+ result = TaskResult(task_name=TaskName.C_TO_A_SYNC, mode=mode, dry_run=dry_run)
46
+
47
+ logger.warning("C → A sync service is not yet implemented (Feishu connector pending)")
48
+ result.finish(success=False, error_message="Not implemented")
49
+ return result
@@ -0,0 +1,319 @@
1
+ """Cross-platform daemon management for the scheduler process."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import json
7
+ import os
8
+ import signal
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ from collections import deque
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from config.settings import PROJECT_ROOT, Settings
18
+ from utils.logger import get_logger
19
+
20
+ logger = get_logger(__name__)
21
+
22
+ _PID_FILENAME = "scheduler.pid"
23
+ _META_FILENAME = "scheduler.meta.json"
24
+ _LOG_FILENAME = "scheduler.console.log"
25
+ _WINDOWS_PROCESS_TERMINATE = 0x0001
26
+ _WINDOWS_QUERY_LIMITED_INFORMATION = 0x1000
27
+ _WINDOWS_STILL_ACTIVE = 259
28
+
29
+
30
+ @dataclass(slots=True)
31
+ class DaemonStatus:
32
+ """Current daemon status snapshot."""
33
+
34
+ running: bool
35
+ pid: int | None
36
+ pid_file: Path
37
+ log_file: Path
38
+ message: str
39
+ started_at: str | None = None
40
+ command: list[str] | None = None
41
+ stale: bool = False
42
+
43
+
44
+ class DaemonService:
45
+ """Manage a single detached scheduler process."""
46
+
47
+ def __init__(self, settings: Settings) -> None:
48
+ self._settings = settings
49
+ self._state_dir = Path(settings.state_dir)
50
+ self._state_dir.mkdir(parents=True, exist_ok=True)
51
+ self._pid_path = self._state_dir / _PID_FILENAME
52
+ self._meta_path = self._state_dir / _META_FILENAME
53
+ self._log_path = self._state_dir / _LOG_FILENAME
54
+
55
+ @property
56
+ def log_file(self) -> Path:
57
+ return self._log_path
58
+
59
+ def status(self, *, cleanup_stale: bool = True) -> DaemonStatus:
60
+ """Return current daemon status and clean stale pid files by default."""
61
+ pid = self._read_pid()
62
+ meta = self._read_meta()
63
+
64
+ if pid is None:
65
+ return DaemonStatus(
66
+ running=False,
67
+ pid=None,
68
+ pid_file=self._pid_path,
69
+ log_file=self._log_path,
70
+ message="守护进程未运行",
71
+ started_at=meta.get("started_at"),
72
+ command=meta.get("command"),
73
+ )
74
+
75
+ if self._is_process_alive(pid):
76
+ return DaemonStatus(
77
+ running=True,
78
+ pid=pid,
79
+ pid_file=self._pid_path,
80
+ log_file=self._log_path,
81
+ message=f"守护进程运行中 (PID {pid})",
82
+ started_at=meta.get("started_at"),
83
+ command=meta.get("command"),
84
+ )
85
+
86
+ if cleanup_stale:
87
+ self._clear_runtime_files()
88
+
89
+ return DaemonStatus(
90
+ running=False,
91
+ pid=pid,
92
+ pid_file=self._pid_path,
93
+ log_file=self._log_path,
94
+ message=f"检测到失效 PID 文件,已清理 (PID {pid})",
95
+ started_at=meta.get("started_at"),
96
+ command=meta.get("command"),
97
+ stale=True,
98
+ )
99
+
100
+ def start(self, *, force: bool = False) -> DaemonStatus:
101
+ """Launch the scheduler in a detached background process."""
102
+ current = self.status()
103
+ if current.running:
104
+ if not force:
105
+ return DaemonStatus(
106
+ running=True,
107
+ pid=current.pid,
108
+ pid_file=self._pid_path,
109
+ log_file=self._log_path,
110
+ message=f"守护进程已在运行 (PID {current.pid})",
111
+ started_at=current.started_at,
112
+ command=current.command,
113
+ )
114
+ self.stop(force=True)
115
+
116
+ cmd = self._build_spawn_command()
117
+ logger.info("Starting daemon: %s", cmd)
118
+ self._log_path.parent.mkdir(parents=True, exist_ok=True)
119
+
120
+ log_handle = self._log_path.open("ab")
121
+ try:
122
+ spawn_kwargs: dict[str, Any] = {
123
+ "cwd": str(PROJECT_ROOT),
124
+ "stdin": subprocess.DEVNULL,
125
+ "stdout": log_handle,
126
+ "stderr": log_handle,
127
+ }
128
+
129
+ if os.name == "nt":
130
+ spawn_kwargs["creationflags"] = (
131
+ subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS
132
+ )
133
+ else:
134
+ spawn_kwargs["start_new_session"] = True
135
+
136
+ proc = subprocess.Popen(cmd, **spawn_kwargs)
137
+ finally:
138
+ log_handle.close()
139
+
140
+ time.sleep(1.0)
141
+ if not self._is_process_alive(proc.pid):
142
+ return DaemonStatus(
143
+ running=False,
144
+ pid=proc.pid,
145
+ pid_file=self._pid_path,
146
+ log_file=self._log_path,
147
+ message="守护进程启动失败,请检查日志文件",
148
+ command=cmd,
149
+ )
150
+
151
+ self._write_runtime_files(proc.pid, cmd)
152
+ return DaemonStatus(
153
+ running=True,
154
+ pid=proc.pid,
155
+ pid_file=self._pid_path,
156
+ log_file=self._log_path,
157
+ message=f"守护进程已启动 (PID {proc.pid})",
158
+ started_at=self._read_meta().get("started_at"),
159
+ command=cmd,
160
+ )
161
+
162
+ def stop(self, *, force: bool = False, timeout: float = 10.0) -> DaemonStatus:
163
+ """Stop the detached scheduler process."""
164
+ current = self.status(cleanup_stale=False)
165
+ if not current.running or current.pid is None:
166
+ self._clear_runtime_files()
167
+ return DaemonStatus(
168
+ running=False,
169
+ pid=None,
170
+ pid_file=self._pid_path,
171
+ log_file=self._log_path,
172
+ message="守护进程未运行",
173
+ )
174
+
175
+ pid = current.pid
176
+ logger.info("Stopping daemon pid=%s", pid)
177
+ self._terminate_process(pid, force=False)
178
+
179
+ deadline = time.time() + timeout
180
+ while time.time() < deadline:
181
+ if not self._is_process_alive(pid):
182
+ self._clear_runtime_files()
183
+ return DaemonStatus(
184
+ running=False,
185
+ pid=None,
186
+ pid_file=self._pid_path,
187
+ log_file=self._log_path,
188
+ message=f"守护进程已停止 (PID {pid})",
189
+ )
190
+ time.sleep(0.25)
191
+
192
+ if force or os.name == "nt":
193
+ self._terminate_process(pid, force=True)
194
+ time.sleep(0.5)
195
+
196
+ if self._is_process_alive(pid):
197
+ return DaemonStatus(
198
+ running=True,
199
+ pid=pid,
200
+ pid_file=self._pid_path,
201
+ log_file=self._log_path,
202
+ message=f"守护进程停止超时 (PID {pid}),请检查日志",
203
+ started_at=current.started_at,
204
+ command=current.command,
205
+ )
206
+
207
+ self._clear_runtime_files()
208
+ return DaemonStatus(
209
+ running=False,
210
+ pid=None,
211
+ pid_file=self._pid_path,
212
+ log_file=self._log_path,
213
+ message=f"守护进程已强制停止 (PID {pid})",
214
+ )
215
+
216
+ def restart(self, *, force: bool = True) -> DaemonStatus:
217
+ """Restart the daemon process."""
218
+ self.stop(force=force)
219
+ return self.start(force=False)
220
+
221
+ def read_log_tail(self, lines: int = 40) -> str:
222
+ """Return the last N lines of daemon console output."""
223
+ if not self._log_path.exists():
224
+ return ""
225
+ with self._log_path.open("r", encoding="utf-8", errors="replace") as handle:
226
+ return "".join(deque(handle, maxlen=max(1, lines)))
227
+
228
+ def _build_spawn_command(self) -> list[str]:
229
+ if getattr(sys, "frozen", False):
230
+ return [str(Path(sys.executable).resolve()), "schedule"]
231
+ return [sys.executable, str(PROJECT_ROOT / "main.py"), "schedule"]
232
+
233
+ def _read_pid(self) -> int | None:
234
+ if not self._pid_path.exists():
235
+ return None
236
+ try:
237
+ return int(self._pid_path.read_text(encoding="utf-8").strip())
238
+ except Exception:
239
+ return None
240
+
241
+ def _read_meta(self) -> dict[str, Any]:
242
+ if not self._meta_path.exists():
243
+ return {}
244
+ try:
245
+ return json.loads(self._meta_path.read_text(encoding="utf-8"))
246
+ except Exception:
247
+ return {}
248
+
249
+ def _write_runtime_files(self, pid: int, cmd: list[str]) -> None:
250
+ self._pid_path.write_text(str(pid), encoding="utf-8")
251
+ payload = {
252
+ "pid": pid,
253
+ "started_at": time.strftime("%Y-%m-%d %H:%M:%S"),
254
+ "command": cmd,
255
+ "log_file": str(self._log_path),
256
+ }
257
+ self._meta_path.write_text(
258
+ json.dumps(payload, ensure_ascii=False, indent=2),
259
+ encoding="utf-8",
260
+ )
261
+
262
+ def _clear_runtime_files(self) -> None:
263
+ for path in (self._pid_path, self._meta_path):
264
+ try:
265
+ path.unlink(missing_ok=True)
266
+ except Exception:
267
+ logger.debug("Failed to remove runtime file: %s", path, exc_info=True)
268
+
269
+ def _is_process_alive(self, pid: int) -> bool:
270
+ if pid <= 0:
271
+ return False
272
+ if os.name == "nt":
273
+ return self._is_process_alive_windows(pid)
274
+ try:
275
+ os.kill(pid, 0)
276
+ except OSError:
277
+ return False
278
+ return True
279
+
280
+ def _terminate_process(self, pid: int, *, force: bool) -> None:
281
+ if pid <= 0:
282
+ return
283
+ if os.name == "nt":
284
+ self._terminate_process_windows(pid)
285
+ return
286
+
287
+ sig = signal.SIGKILL if force else signal.SIGTERM
288
+ try:
289
+ os.kill(pid, sig)
290
+ except ProcessLookupError:
291
+ return
292
+
293
+ @staticmethod
294
+ def _is_process_alive_windows(pid: int) -> bool:
295
+ kernel32 = ctypes.windll.kernel32
296
+ handle = kernel32.OpenProcess(_WINDOWS_QUERY_LIMITED_INFORMATION, 0, pid)
297
+ if not handle:
298
+ return False
299
+
300
+ try:
301
+ exit_code = ctypes.c_ulong()
302
+ if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
303
+ return False
304
+ return exit_code.value == _WINDOWS_STILL_ACTIVE
305
+ finally:
306
+ kernel32.CloseHandle(handle)
307
+
308
+ @staticmethod
309
+ def _terminate_process_windows(pid: int) -> None:
310
+ kernel32 = ctypes.windll.kernel32
311
+ access = _WINDOWS_PROCESS_TERMINATE | _WINDOWS_QUERY_LIMITED_INFORMATION
312
+ handle = kernel32.OpenProcess(access, 0, pid)
313
+ if not handle:
314
+ return
315
+
316
+ try:
317
+ kernel32.TerminateProcess(handle, 1)
318
+ finally:
319
+ kernel32.CloseHandle(handle)