tb-order-sync 0.3.1 → 0.4.1
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 +3 -2
- package/CHANGELOG.md +36 -0
- package/README.md +110 -103
- package/build.py +11 -4
- package/cli/commands.py +66 -6
- package/cli/dashboard.py +40 -9
- package/cli/setup.py +87 -35
- package/config/settings.py +2 -2
- package/connectors/tencent_docs.py +247 -137
- package/models/state_models.py +4 -1
- package/models/task_models.py +15 -0
- package/package.json +5 -2
- package/services/daemon_service.py +134 -0
- package/services/gross_profit_service.py +15 -1
- package/services/refund_match_service.py +52 -9
- package/services/scheduler_service.py +27 -2
- package/services/state_service.py +25 -0
- package/sync_service.spec +2 -0
- package/utils/retry.py +20 -2
- package//345/277/253/351/200/237/345/274/200/345/247/213.txt +31 -0
|
@@ -22,6 +22,8 @@ logger = get_logger(__name__)
|
|
|
22
22
|
_PID_FILENAME = "scheduler.pid"
|
|
23
23
|
_META_FILENAME = "scheduler.meta.json"
|
|
24
24
|
_LOG_FILENAME = "scheduler.console.log"
|
|
25
|
+
_AUTOSTART_LABEL = "com.tb-order-sync.scheduler"
|
|
26
|
+
_WINDOWS_TASK_NAME = "TBOrderSyncScheduler"
|
|
25
27
|
_WINDOWS_PROCESS_TERMINATE = 0x0001
|
|
26
28
|
_WINDOWS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
27
29
|
_WINDOWS_STILL_ACTIVE = 259
|
|
@@ -41,6 +43,15 @@ class DaemonStatus:
|
|
|
41
43
|
stale: bool = False
|
|
42
44
|
|
|
43
45
|
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class AutostartStatus:
|
|
48
|
+
"""Current login-start status."""
|
|
49
|
+
|
|
50
|
+
enabled: bool
|
|
51
|
+
message: str
|
|
52
|
+
target: str
|
|
53
|
+
|
|
54
|
+
|
|
44
55
|
class DaemonService:
|
|
45
56
|
"""Manage a single detached scheduler process."""
|
|
46
57
|
|
|
@@ -225,11 +236,134 @@ class DaemonService:
|
|
|
225
236
|
with self._log_path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
226
237
|
return "".join(deque(handle, maxlen=max(1, lines)))
|
|
227
238
|
|
|
239
|
+
def autostart_status(self) -> AutostartStatus:
|
|
240
|
+
"""Return whether login-start is enabled for the current user."""
|
|
241
|
+
if os.name == "nt":
|
|
242
|
+
try:
|
|
243
|
+
result = subprocess.run(
|
|
244
|
+
["schtasks", "/Query", "/TN", _WINDOWS_TASK_NAME],
|
|
245
|
+
capture_output=True,
|
|
246
|
+
text=True,
|
|
247
|
+
)
|
|
248
|
+
enabled = result.returncode == 0
|
|
249
|
+
return AutostartStatus(
|
|
250
|
+
enabled=enabled,
|
|
251
|
+
message="已启用登录自启" if enabled else "未启用登录自启",
|
|
252
|
+
target=_WINDOWS_TASK_NAME,
|
|
253
|
+
)
|
|
254
|
+
except FileNotFoundError:
|
|
255
|
+
return AutostartStatus(False, "系统未找到 schtasks,无法检查登录自启。", _WINDOWS_TASK_NAME)
|
|
256
|
+
|
|
257
|
+
plist_path = self._launch_agent_path()
|
|
258
|
+
enabled = plist_path.exists()
|
|
259
|
+
return AutostartStatus(
|
|
260
|
+
enabled=enabled,
|
|
261
|
+
message="已启用登录自启" if enabled else "未启用登录自启",
|
|
262
|
+
target=str(plist_path),
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
def enable_autostart(self) -> AutostartStatus:
|
|
266
|
+
"""Enable login-start for the current user."""
|
|
267
|
+
if os.name == "nt":
|
|
268
|
+
try:
|
|
269
|
+
command = self._autostart_command_line()
|
|
270
|
+
result = subprocess.run(
|
|
271
|
+
[
|
|
272
|
+
"schtasks",
|
|
273
|
+
"/Create",
|
|
274
|
+
"/F",
|
|
275
|
+
"/SC",
|
|
276
|
+
"ONLOGON",
|
|
277
|
+
"/RL",
|
|
278
|
+
"LIMITED",
|
|
279
|
+
"/TN",
|
|
280
|
+
_WINDOWS_TASK_NAME,
|
|
281
|
+
"/TR",
|
|
282
|
+
command,
|
|
283
|
+
],
|
|
284
|
+
capture_output=True,
|
|
285
|
+
text=True,
|
|
286
|
+
)
|
|
287
|
+
if result.returncode != 0:
|
|
288
|
+
msg = (result.stderr or result.stdout or "未知错误").strip()
|
|
289
|
+
return AutostartStatus(False, f"启用登录自启失败: {msg}", _WINDOWS_TASK_NAME)
|
|
290
|
+
return AutostartStatus(True, "已启用登录自启(Windows 任务计划)", _WINDOWS_TASK_NAME)
|
|
291
|
+
except FileNotFoundError:
|
|
292
|
+
return AutostartStatus(False, "系统未找到 schtasks,无法启用登录自启。", _WINDOWS_TASK_NAME)
|
|
293
|
+
|
|
294
|
+
plist_path = self._launch_agent_path()
|
|
295
|
+
plist_path.parent.mkdir(parents=True, exist_ok=True)
|
|
296
|
+
plist_path.write_text(self._launch_agent_plist(), encoding="utf-8")
|
|
297
|
+
try:
|
|
298
|
+
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True)
|
|
299
|
+
result = subprocess.run(["launchctl", "load", str(plist_path)], capture_output=True, text=True)
|
|
300
|
+
if result.returncode != 0:
|
|
301
|
+
msg = (result.stderr or result.stdout or "未知错误").strip()
|
|
302
|
+
return AutostartStatus(False, f"启用登录自启失败: {msg}", str(plist_path))
|
|
303
|
+
return AutostartStatus(True, "已启用登录自启(macOS LaunchAgent)", str(plist_path))
|
|
304
|
+
except FileNotFoundError:
|
|
305
|
+
return AutostartStatus(False, "系统未找到 launchctl,无法启用登录自启。", str(plist_path))
|
|
306
|
+
|
|
307
|
+
def disable_autostart(self) -> AutostartStatus:
|
|
308
|
+
"""Disable login-start for the current user."""
|
|
309
|
+
if os.name == "nt":
|
|
310
|
+
try:
|
|
311
|
+
result = subprocess.run(
|
|
312
|
+
["schtasks", "/Delete", "/F", "/TN", _WINDOWS_TASK_NAME],
|
|
313
|
+
capture_output=True,
|
|
314
|
+
text=True,
|
|
315
|
+
)
|
|
316
|
+
if result.returncode != 0:
|
|
317
|
+
msg = (result.stderr or result.stdout or "任务不存在").strip()
|
|
318
|
+
return AutostartStatus(False, f"停用登录自启失败: {msg}", _WINDOWS_TASK_NAME)
|
|
319
|
+
return AutostartStatus(False, "已停用登录自启(Windows 任务计划)", _WINDOWS_TASK_NAME)
|
|
320
|
+
except FileNotFoundError:
|
|
321
|
+
return AutostartStatus(False, "系统未找到 schtasks,无法停用登录自启。", _WINDOWS_TASK_NAME)
|
|
322
|
+
|
|
323
|
+
plist_path = self._launch_agent_path()
|
|
324
|
+
if plist_path.exists():
|
|
325
|
+
try:
|
|
326
|
+
subprocess.run(["launchctl", "unload", str(plist_path)], capture_output=True, text=True)
|
|
327
|
+
except FileNotFoundError:
|
|
328
|
+
return AutostartStatus(False, "系统未找到 launchctl,无法停用登录自启。", str(plist_path))
|
|
329
|
+
plist_path.unlink(missing_ok=True)
|
|
330
|
+
return AutostartStatus(False, "已停用登录自启(macOS LaunchAgent)", str(plist_path))
|
|
331
|
+
|
|
228
332
|
def _build_spawn_command(self) -> list[str]:
|
|
229
333
|
if getattr(sys, "frozen", False):
|
|
230
334
|
return [str(Path(sys.executable).resolve()), "schedule"]
|
|
231
335
|
return [sys.executable, str(PROJECT_ROOT / "main.py"), "schedule"]
|
|
232
336
|
|
|
337
|
+
def _autostart_command_line(self) -> str:
|
|
338
|
+
return subprocess.list2cmdline(self._build_spawn_command())
|
|
339
|
+
|
|
340
|
+
@staticmethod
|
|
341
|
+
def _launch_agent_path() -> Path:
|
|
342
|
+
return Path.home() / "Library" / "LaunchAgents" / f"{_AUTOSTART_LABEL}.plist"
|
|
343
|
+
|
|
344
|
+
def _launch_agent_plist(self) -> str:
|
|
345
|
+
command = self._build_spawn_command()
|
|
346
|
+
args = "\n".join(f" <string>{arg}</string>" for arg in command)
|
|
347
|
+
return (
|
|
348
|
+
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
|
349
|
+
"<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" "
|
|
350
|
+
"\"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n"
|
|
351
|
+
"<plist version=\"1.0\">\n"
|
|
352
|
+
"<dict>\n"
|
|
353
|
+
f" <key>Label</key>\n <string>{_AUTOSTART_LABEL}</string>\n"
|
|
354
|
+
" <key>ProgramArguments</key>\n"
|
|
355
|
+
" <array>\n"
|
|
356
|
+
f"{args}\n"
|
|
357
|
+
" </array>\n"
|
|
358
|
+
f" <key>WorkingDirectory</key>\n <string>{PROJECT_ROOT}</string>\n"
|
|
359
|
+
" <key>RunAtLoad</key>\n <true/>\n"
|
|
360
|
+
" <key>KeepAlive</key>\n <false/>\n"
|
|
361
|
+
f" <key>StandardOutPath</key>\n <string>{self._log_path}</string>\n"
|
|
362
|
+
f" <key>StandardErrorPath</key>\n <string>{self._log_path}</string>\n"
|
|
363
|
+
"</dict>\n"
|
|
364
|
+
"</plist>\n"
|
|
365
|
+
)
|
|
366
|
+
|
|
233
367
|
def _read_pid(self) -> int | None:
|
|
234
368
|
if not self._pid_path.exists():
|
|
235
369
|
return None
|
|
@@ -145,7 +145,10 @@ class GrossProfitService:
|
|
|
145
145
|
e = parse_number(rec.freight)
|
|
146
146
|
f = parse_number(rec.customer_quote)
|
|
147
147
|
|
|
148
|
-
if
|
|
148
|
+
if self._all_cost_fields_blank(rec):
|
|
149
|
+
# Treat rows with empty C/D/E as unused rows and keep G blank.
|
|
150
|
+
new_val = ""
|
|
151
|
+
elif any(v is None for v in (c, d, e, f)):
|
|
149
152
|
# Data error
|
|
150
153
|
new_val = self._settings.data_error_text
|
|
151
154
|
errors += 1
|
|
@@ -200,3 +203,14 @@ class GrossProfitService:
|
|
|
200
203
|
def _safe_get_str(row: list[Any], idx: int) -> Optional[str]:
|
|
201
204
|
val = row[idx] if idx < len(row) else None
|
|
202
205
|
return str(val) if val is not None else None
|
|
206
|
+
|
|
207
|
+
@staticmethod
|
|
208
|
+
def _is_blank(value: Optional[str]) -> bool:
|
|
209
|
+
return value is None or value.strip() == ""
|
|
210
|
+
|
|
211
|
+
def _all_cost_fields_blank(self, rec: OrderRecord) -> bool:
|
|
212
|
+
return (
|
|
213
|
+
self._is_blank(rec.product_price)
|
|
214
|
+
and self._is_blank(rec.packaging_price)
|
|
215
|
+
and self._is_blank(rec.freight)
|
|
216
|
+
)
|
|
@@ -3,19 +3,21 @@
|
|
|
3
3
|
Business rules:
|
|
4
4
|
- Read B table column A (order numbers) to build refund set
|
|
5
5
|
- Scan A table column H (order numbers)
|
|
6
|
-
- If A row's order_no is in refund set → write "
|
|
6
|
+
- If A row's order_no is in refund set → write "已退款" to A table I column
|
|
7
7
|
- If not in refund set → clear I column
|
|
8
|
-
- Optional: set/clear row
|
|
8
|
+
- Optional: set/clear row-level highlight style
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
from datetime import datetime
|
|
14
|
+
import time
|
|
14
15
|
from typing import Any, Optional
|
|
15
16
|
|
|
16
17
|
from config.mappings import ColumnMapping, get_column_mapping
|
|
17
18
|
from config.settings import Settings, SyncMode, get_settings
|
|
18
19
|
from connectors.base import BaseSheetConnector, CellUpdate
|
|
20
|
+
from models.state_models import SyncState
|
|
19
21
|
from models.task_models import TaskName, TaskResult
|
|
20
22
|
from services.state_service import StateService
|
|
21
23
|
from utils.diff import row_fingerprint, set_hash
|
|
@@ -24,7 +26,7 @@ from utils.parser import normalize_order_no
|
|
|
24
26
|
|
|
25
27
|
logger = get_logger(__name__)
|
|
26
28
|
|
|
27
|
-
#
|
|
29
|
+
# Text highlight colors for optional style update
|
|
28
30
|
_BG_RED = "#FF4D4F"
|
|
29
31
|
_BG_DEFAULT = None # None means reset / no color
|
|
30
32
|
|
|
@@ -66,9 +68,14 @@ class RefundMatchService:
|
|
|
66
68
|
# 2. Read A table
|
|
67
69
|
a_rows = self._read_a_table()
|
|
68
70
|
result.rows_read = len(a_rows)
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
a_scan_hash = self._build_a_scan_hash(a_rows)
|
|
72
|
+
|
|
73
|
+
# 3. Incremental short-circuit: only skip when both B refunds and A scan are unchanged
|
|
74
|
+
if (
|
|
75
|
+
mode == SyncMode.INCREMENTAL
|
|
76
|
+
and new_refund_hash == state.b_table_refund_hash
|
|
77
|
+
and a_scan_hash == state.a_table_refund_scan_hash
|
|
78
|
+
):
|
|
72
79
|
logger.info("Refund set unchanged, skipping (incremental mode)")
|
|
73
80
|
result.finish()
|
|
74
81
|
return result
|
|
@@ -91,6 +98,7 @@ class RefundMatchService:
|
|
|
91
98
|
|
|
92
99
|
state.b_table_refund_hash = new_refund_hash
|
|
93
100
|
state.b_table_refund_set = sorted(refund_set)
|
|
101
|
+
state.a_table_refund_scan_hash = self._build_desired_scan_hash(a_rows, refund_set)
|
|
94
102
|
state.last_run_at = datetime.now()
|
|
95
103
|
self._state_svc.save(state)
|
|
96
104
|
else:
|
|
@@ -132,6 +140,29 @@ class RefundMatchService:
|
|
|
132
140
|
)
|
|
133
141
|
return rows[1:] if rows else []
|
|
134
142
|
|
|
143
|
+
def _build_a_scan_hash(self, a_rows: list[list[Any]]) -> str:
|
|
144
|
+
m = self._map
|
|
145
|
+
row_hashes = []
|
|
146
|
+
for idx, row in enumerate(a_rows):
|
|
147
|
+
order_no = normalize_order_no(row[m.a_order_no] if m.a_order_no < len(row) else None)
|
|
148
|
+
current_status = (
|
|
149
|
+
str(row[m.a_refund_status]).strip()
|
|
150
|
+
if m.a_refund_status < len(row) and row[m.a_refund_status] is not None
|
|
151
|
+
else ""
|
|
152
|
+
)
|
|
153
|
+
row_hashes.append(row_fingerprint([idx, order_no, current_status]))
|
|
154
|
+
return row_fingerprint(row_hashes)
|
|
155
|
+
|
|
156
|
+
def _build_desired_scan_hash(self, a_rows: list[list[Any]], refund_set: set[str]) -> str:
|
|
157
|
+
m = self._map
|
|
158
|
+
refund_text = self._settings.refund_status_text
|
|
159
|
+
row_hashes = []
|
|
160
|
+
for idx, row in enumerate(a_rows):
|
|
161
|
+
order_no = normalize_order_no(row[m.a_order_no] if m.a_order_no < len(row) else None)
|
|
162
|
+
desired = refund_text if order_no and order_no in refund_set else ""
|
|
163
|
+
row_hashes.append(row_fingerprint([idx, order_no, desired]))
|
|
164
|
+
return row_fingerprint(row_hashes)
|
|
165
|
+
|
|
135
166
|
def _match(
|
|
136
167
|
self,
|
|
137
168
|
a_rows: list[list[Any]],
|
|
@@ -151,12 +182,17 @@ class RefundMatchService:
|
|
|
151
182
|
|
|
152
183
|
for idx, row in enumerate(a_rows):
|
|
153
184
|
row_num = idx + 1 # 1-based (0 is header)
|
|
185
|
+
current_status = str(row[m.a_refund_status]).strip() if m.a_refund_status < len(row) and row[m.a_refund_status] is not None else ""
|
|
154
186
|
order_no = normalize_order_no(row[m.a_order_no] if m.a_order_no < len(row) else None)
|
|
155
187
|
|
|
156
188
|
if not order_no:
|
|
189
|
+
if current_status:
|
|
190
|
+
updates.append(CellUpdate(row=row_num, col=m.a_refund_status, value=""))
|
|
191
|
+
style_ops.append((row_num, _BG_DEFAULT))
|
|
192
|
+
changed += 1
|
|
193
|
+
logger.info("Row %d: 单号为空,清除退款标记", row_num)
|
|
157
194
|
continue
|
|
158
195
|
|
|
159
|
-
current_status = str(row[m.a_refund_status]).strip() if m.a_refund_status < len(row) and row[m.a_refund_status] is not None else ""
|
|
160
196
|
is_refund = order_no in refund_set
|
|
161
197
|
|
|
162
198
|
if is_refund:
|
|
@@ -184,7 +220,9 @@ class RefundMatchService:
|
|
|
184
220
|
return updates, style_ops, changed
|
|
185
221
|
|
|
186
222
|
def _apply_styles(self, ops: list[tuple[int, Optional[str]]]) -> None:
|
|
187
|
-
|
|
223
|
+
failures: list[str] = []
|
|
224
|
+
total = len(ops)
|
|
225
|
+
for index, (row_idx, color) in enumerate(ops):
|
|
188
226
|
try:
|
|
189
227
|
self._conn.update_row_style(
|
|
190
228
|
self._settings.tencent_a_file_id,
|
|
@@ -192,5 +230,10 @@ class RefundMatchService:
|
|
|
192
230
|
row_idx,
|
|
193
231
|
bg_color=color,
|
|
194
232
|
)
|
|
233
|
+
if index + 1 < total:
|
|
234
|
+
time.sleep(0.2)
|
|
195
235
|
except Exception as exc:
|
|
196
|
-
|
|
236
|
+
failures.append(f"row={row_idx} error={exc}")
|
|
237
|
+
|
|
238
|
+
if failures:
|
|
239
|
+
raise RuntimeError("Style update failed: " + "; ".join(failures))
|
|
@@ -11,6 +11,7 @@ from apscheduler.triggers.interval import IntervalTrigger
|
|
|
11
11
|
|
|
12
12
|
from config.settings import Settings, SyncMode, get_settings
|
|
13
13
|
from connectors.base import BaseSheetConnector
|
|
14
|
+
from models.task_models import RunSummary
|
|
14
15
|
from services.gross_profit_service import GrossProfitService
|
|
15
16
|
from services.refund_match_service import RefundMatchService
|
|
16
17
|
from services.state_service import StateService
|
|
@@ -39,8 +40,32 @@ class SchedulerService:
|
|
|
39
40
|
def _run_all(self) -> None:
|
|
40
41
|
"""Execute all tasks in sequence."""
|
|
41
42
|
logger.info("--- Scheduled run: all tasks ---")
|
|
42
|
-
self._gp_svc.run()
|
|
43
|
-
self._rm_svc.run()
|
|
43
|
+
gp_result = self._gp_svc.run()
|
|
44
|
+
rm_result = self._rm_svc.run()
|
|
45
|
+
try:
|
|
46
|
+
self._state_svc.save_last_run(self._build_summary([gp_result, rm_result], trigger="scheduled"))
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
logger.warning("Failed to save scheduled run summary: %s", exc)
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _build_summary(results, *, trigger: str) -> RunSummary:
|
|
52
|
+
finished_values = [item.finished_at for item in results if item.finished_at is not None]
|
|
53
|
+
success = all(item.success for item in results)
|
|
54
|
+
message = None
|
|
55
|
+
if not success:
|
|
56
|
+
errors = [f"{item.task_name.value}: {item.error_message}" for item in results if item.error_message]
|
|
57
|
+
message = " | ".join(errors) if errors else "存在任务失败,请检查后台日志。"
|
|
58
|
+
return RunSummary(
|
|
59
|
+
trigger=trigger,
|
|
60
|
+
success=success,
|
|
61
|
+
finished_at=max(finished_values) if finished_values else None,
|
|
62
|
+
task_count=len(results),
|
|
63
|
+
rows_read=sum(item.rows_read for item in results),
|
|
64
|
+
rows_changed=sum(item.rows_changed for item in results),
|
|
65
|
+
rows_error=sum(item.rows_error for item in results),
|
|
66
|
+
tasks=results,
|
|
67
|
+
message=message,
|
|
68
|
+
)
|
|
44
69
|
|
|
45
70
|
def start(self) -> None:
|
|
46
71
|
"""Start the blocking scheduler with configured interval and jitter."""
|
|
@@ -5,12 +5,14 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from pathlib import Path
|
|
7
7
|
|
|
8
|
+
from models.task_models import RunSummary
|
|
8
9
|
from models.state_models import SyncState
|
|
9
10
|
from utils.logger import get_logger
|
|
10
11
|
|
|
11
12
|
logger = get_logger(__name__)
|
|
12
13
|
|
|
13
14
|
STATE_FILENAME = "sync_state.json"
|
|
15
|
+
LAST_RUN_FILENAME = "last_run.json"
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class StateService:
|
|
@@ -20,6 +22,7 @@ class StateService:
|
|
|
20
22
|
self._dir = Path(state_dir)
|
|
21
23
|
self._dir.mkdir(parents=True, exist_ok=True)
|
|
22
24
|
self._path = self._dir / STATE_FILENAME
|
|
25
|
+
self._last_run_path = self._dir / LAST_RUN_FILENAME
|
|
23
26
|
|
|
24
27
|
def load(self, *, quiet: bool = False) -> SyncState:
|
|
25
28
|
"""Load state from disk. Returns fresh state if file missing / corrupt."""
|
|
@@ -48,3 +51,25 @@ class StateService:
|
|
|
48
51
|
except Exception as exc:
|
|
49
52
|
logger.error("Failed to save state: %s", exc)
|
|
50
53
|
raise
|
|
54
|
+
|
|
55
|
+
def load_last_run(self, *, quiet: bool = False) -> RunSummary | None:
|
|
56
|
+
"""Load the most recent execution summary if present."""
|
|
57
|
+
if not self._last_run_path.exists():
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
raw = self._last_run_path.read_text(encoding="utf-8")
|
|
61
|
+
return RunSummary.model_validate_json(raw)
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
if not quiet:
|
|
64
|
+
logger.warning("Failed to load last run summary (%s): %s", exc, self._last_run_path)
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
def save_last_run(self, summary: RunSummary) -> None:
|
|
68
|
+
"""Persist the most recent execution summary."""
|
|
69
|
+
try:
|
|
70
|
+
raw = summary.model_dump_json(indent=2)
|
|
71
|
+
self._last_run_path.write_text(raw, encoding="utf-8")
|
|
72
|
+
logger.info("Saved last run summary to %s", self._last_run_path)
|
|
73
|
+
except Exception as exc:
|
|
74
|
+
logger.error("Failed to save last run summary: %s", exc)
|
|
75
|
+
raise
|
package/sync_service.spec
CHANGED
package/utils/retry.py
CHANGED
|
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from tenacity import (
|
|
6
6
|
retry,
|
|
7
|
-
|
|
7
|
+
retry_if_exception,
|
|
8
8
|
stop_after_attempt,
|
|
9
9
|
wait_exponential,
|
|
10
10
|
before_sleep_log,
|
|
@@ -15,12 +15,30 @@ from utils.logger import get_logger
|
|
|
15
15
|
logger = get_logger(__name__)
|
|
16
16
|
|
|
17
17
|
|
|
18
|
+
def is_retryable_exception(exc: BaseException) -> bool:
|
|
19
|
+
"""Return whether an exception is worth retrying."""
|
|
20
|
+
if isinstance(exc, (IOError, ConnectionError, TimeoutError)):
|
|
21
|
+
return True
|
|
22
|
+
|
|
23
|
+
if isinstance(exc, RuntimeError):
|
|
24
|
+
text = str(exc)
|
|
25
|
+
retry_markers = (
|
|
26
|
+
"code=400007",
|
|
27
|
+
"Requests Over Limit",
|
|
28
|
+
"HTTP 429",
|
|
29
|
+
"temporarily unavailable",
|
|
30
|
+
)
|
|
31
|
+
return any(marker in text for marker in retry_markers)
|
|
32
|
+
|
|
33
|
+
return False
|
|
34
|
+
|
|
35
|
+
|
|
18
36
|
def default_retry(max_attempts: int = 3):
|
|
19
37
|
"""Decorator: retry with exponential backoff on transient errors."""
|
|
20
38
|
return retry(
|
|
21
39
|
stop=stop_after_attempt(max_attempts),
|
|
22
40
|
wait=wait_exponential(multiplier=1, min=2, max=30),
|
|
23
|
-
retry=
|
|
41
|
+
retry=retry_if_exception(is_retryable_exception),
|
|
24
42
|
before_sleep=before_sleep_log(logger, log_level=20), # INFO
|
|
25
43
|
reraise=True,
|
|
26
44
|
)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
TB Order Sync 快速开始
|
|
2
|
+
|
|
3
|
+
1. 双击启动
|
|
4
|
+
- Windows: 启动.bat
|
|
5
|
+
- macOS: 启动.command
|
|
6
|
+
|
|
7
|
+
2. 首次运行
|
|
8
|
+
- 如果本机还没配置,程序会自动进入 setup 配置向导
|
|
9
|
+
- 配好腾讯文档信息后,建议先执行一次 `tb check`
|
|
10
|
+
|
|
11
|
+
3. 常用命令
|
|
12
|
+
- tb all 执行毛利计算 + 退款匹配
|
|
13
|
+
- tb all --dry-run 仅演练,不写回
|
|
14
|
+
- tb daemon start 后台持续运行
|
|
15
|
+
- tb daemon status 查看后台状态
|
|
16
|
+
- tb daemon logs 查看后台日志
|
|
17
|
+
|
|
18
|
+
4. 登录自启
|
|
19
|
+
- tb daemon autostart-enable
|
|
20
|
+
- tb daemon autostart-status
|
|
21
|
+
- tb daemon autostart-disable
|
|
22
|
+
|
|
23
|
+
5. 日志与状态文件
|
|
24
|
+
- state/scheduler.console.log
|
|
25
|
+
- state/last_run.json
|
|
26
|
+
- state/sync_state.json
|
|
27
|
+
|
|
28
|
+
6. 常见问题
|
|
29
|
+
- 缺配置:运行 tb setup
|
|
30
|
+
- 想做启动自检:运行 tb check
|
|
31
|
+
- 接口限流:稍等后重试,不要连续高频重复执行
|