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.
@@ -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 any(v is None for v in (c, d, e, f)):
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 "进入退款流程" to A table I column
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 background color
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
- # Background colors for optional style update
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
- # 3. Incremental short-circuit: if refund set unchanged and mode=incremental
71
- if mode == SyncMode.INCREMENTAL and new_refund_hash == state.b_table_refund_hash:
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
- for row_idx, color in ops:
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
- logger.warning("Failed to update style for row %d: %s", row_idx, exc)
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
@@ -22,6 +22,8 @@ a = Analysis(
22
22
  datas=[
23
23
  # Bundle .env.example so first-run setup can use it as template
24
24
  (str(root / '.env.example'), '.'),
25
+ (str(root / '快速开始.txt'), '.'),
26
+ (str(root / '公司同事使用说明.md'), '.'),
25
27
  ],
26
28
  hiddenimports=[
27
29
  'config',
package/utils/retry.py CHANGED
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  from tenacity import (
6
6
  retry,
7
- retry_if_exception_type,
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=retry_if_exception_type((IOError, ConnectionError, TimeoutError)),
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
+ - 接口限流:稍等后重试,不要连续高频重复执行