tb-order-sync 0.4.1 → 0.4.5

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tb-order-sync",
3
- "version": "0.4.1",
4
- "description": "Tencent Docs order sync service with gross-profit automation, refund marking, startup self-check, autostart daemon, and tb CLI",
3
+ "version": "0.4.5",
4
+ "description": "Tencent Docs order sync CLI with one-command npm install, gross-profit automation, refund marking, self-check, and daemon mode",
5
5
  "bin": {
6
6
  "tb": "bin/tb.js"
7
7
  },
@@ -26,6 +26,7 @@
26
26
  "启动.command"
27
27
  ],
28
28
  "scripts": {
29
+ "postinstall": "node bin/postinstall.js",
29
30
  "tb": "node bin/tb.js",
30
31
  "check": "node bin/tb.js check",
31
32
  "pack:local": "npm pack"
@@ -14,7 +14,7 @@ from dataclasses import dataclass
14
14
  from pathlib import Path
15
15
  from typing import Any
16
16
 
17
- from config.settings import PROJECT_ROOT, Settings
17
+ from config.settings import APP_HOME, PACKAGE_ROOT, Settings
18
18
  from utils.logger import get_logger
19
19
 
20
20
  logger = get_logger(__name__)
@@ -131,10 +131,14 @@ class DaemonService:
131
131
  log_handle = self._log_path.open("ab")
132
132
  try:
133
133
  spawn_kwargs: dict[str, Any] = {
134
- "cwd": str(PROJECT_ROOT),
134
+ "cwd": str(PACKAGE_ROOT),
135
135
  "stdin": subprocess.DEVNULL,
136
136
  "stdout": log_handle,
137
137
  "stderr": log_handle,
138
+ "env": {
139
+ **os.environ,
140
+ "TB_HOME": str(APP_HOME),
141
+ },
138
142
  }
139
143
 
140
144
  if os.name == "nt":
@@ -332,7 +336,7 @@ class DaemonService:
332
336
  def _build_spawn_command(self) -> list[str]:
333
337
  if getattr(sys, "frozen", False):
334
338
  return [str(Path(sys.executable).resolve()), "schedule"]
335
- return [sys.executable, str(PROJECT_ROOT / "main.py"), "schedule"]
339
+ return [sys.executable, str(PACKAGE_ROOT / "main.py"), "schedule"]
336
340
 
337
341
  def _autostart_command_line(self) -> str:
338
342
  return subprocess.list2cmdline(self._build_spawn_command())
@@ -355,7 +359,11 @@ class DaemonService:
355
359
  " <array>\n"
356
360
  f"{args}\n"
357
361
  " </array>\n"
358
- f" <key>WorkingDirectory</key>\n <string>{PROJECT_ROOT}</string>\n"
362
+ " <key>EnvironmentVariables</key>\n"
363
+ " <dict>\n"
364
+ f" <key>TB_HOME</key>\n <string>{APP_HOME}</string>\n"
365
+ " </dict>\n"
366
+ f" <key>WorkingDirectory</key>\n <string>{PACKAGE_ROOT}</string>\n"
359
367
  " <key>RunAtLoad</key>\n <true/>\n"
360
368
  " <key>KeepAlive</key>\n <false/>\n"
361
369
  f" <key>StandardOutPath</key>\n <string>{self._log_path}</string>\n"
@@ -22,6 +22,7 @@ from services.state_service import StateService
22
22
  from utils.diff import row_fingerprint
23
23
  from utils.logger import get_logger
24
24
  from utils.parser import normalize_order_no, parse_number
25
+ from utils.sheet_selector import ResolvedSheetTarget, resolve_latest_month_sheet
25
26
 
26
27
  logger = get_logger(__name__)
27
28
 
@@ -55,7 +56,8 @@ class GrossProfitService:
55
56
 
56
57
  try:
57
58
  state = self._state_svc.load()
58
- rows = self._read_a_table()
59
+ a_target = self._resolve_a_target()
60
+ rows = self._read_a_table(a_target.sheet_id)
59
61
  result.rows_read = len(rows)
60
62
  if not rows:
61
63
  logger.warning("A table returned 0 data rows")
@@ -69,7 +71,7 @@ class GrossProfitService:
69
71
  result.rows_error = errors
70
72
 
71
73
  if updates and not dry_run:
72
- self._write_updates(updates)
74
+ self._write_updates(a_target.sheet_id, updates)
73
75
  state.last_run_at = datetime.now()
74
76
  self._state_svc.save(state)
75
77
 
@@ -89,14 +91,29 @@ class GrossProfitService:
89
91
 
90
92
  # ── Internal ───────────────────────────────────────────────────────────
91
93
 
92
- def _read_a_table(self) -> list[list[Any]]:
94
+ def _read_a_table(self, sheet_id: str) -> list[list[Any]]:
93
95
  rows = self._conn.read_rows(
94
96
  self._settings.tencent_a_file_id,
95
- self._settings.tencent_a_sheet_id,
97
+ sheet_id,
96
98
  )
97
99
  # Skip header (row 0)
98
100
  return rows[1:] if rows else []
99
101
 
102
+ def _resolve_a_target(self) -> ResolvedSheetTarget:
103
+ target = resolve_latest_month_sheet(
104
+ self._conn, # type: ignore[arg-type]
105
+ file_id=self._settings.tencent_a_file_id,
106
+ fallback_sheet_id=self._settings.tencent_a_sheet_id,
107
+ title_keyword=self._settings.tencent_a_sheet_name_keyword,
108
+ )
109
+ if target.source != "fixed":
110
+ logger.info(
111
+ "A 表自动选择最新月份工作表: %s (%s)",
112
+ target.title or target.sheet_id,
113
+ target.sheet_id,
114
+ )
115
+ return target
116
+
100
117
  def _parse_rows(self, rows: list[list[Any]]) -> list[OrderRecord]:
101
118
  m = self._map
102
119
  records: list[OrderRecord] = []
@@ -167,10 +184,10 @@ class GrossProfitService:
167
184
 
168
185
  return updates, changed, errors
169
186
 
170
- def _write_updates(self, updates: list[CellUpdate]) -> None:
187
+ def _write_updates(self, sheet_id: str, updates: list[CellUpdate]) -> None:
171
188
  self._conn.batch_update(
172
189
  self._settings.tencent_a_file_id,
173
- self._settings.tencent_a_sheet_id,
190
+ sheet_id,
174
191
  updates,
175
192
  batch_size=self._settings.write_batch_size,
176
193
  )
@@ -23,6 +23,7 @@ from services.state_service import StateService
23
23
  from utils.diff import row_fingerprint, set_hash
24
24
  from utils.logger import get_logger
25
25
  from utils.parser import normalize_order_no
26
+ from utils.sheet_selector import ResolvedSheetTarget, resolve_latest_month_sheet
26
27
 
27
28
  logger = get_logger(__name__)
28
29
 
@@ -59,14 +60,16 @@ class RefundMatchService:
59
60
 
60
61
  try:
61
62
  state = self._state_svc.load()
63
+ a_target = self._resolve_a_target()
64
+ b_target = self._resolve_b_target()
62
65
 
63
66
  # 1. Build refund set from B table
64
- refund_set = self._build_refund_set()
67
+ refund_set = self._build_refund_set(b_target.sheet_id)
65
68
  new_refund_hash = set_hash(list(refund_set))
66
69
  logger.info("B table refund set: %d order numbers, hash=%s", len(refund_set), new_refund_hash[:8])
67
70
 
68
71
  # 2. Read A table
69
- a_rows = self._read_a_table()
72
+ a_rows = self._read_a_table(a_target.sheet_id)
70
73
  result.rows_read = len(a_rows)
71
74
  a_scan_hash = self._build_a_scan_hash(a_rows)
72
75
 
@@ -89,12 +92,12 @@ class RefundMatchService:
89
92
  if updates:
90
93
  self._conn.batch_update(
91
94
  self._settings.tencent_a_file_id,
92
- self._settings.tencent_a_sheet_id,
95
+ a_target.sheet_id,
93
96
  updates,
94
97
  batch_size=self._settings.write_batch_size,
95
98
  )
96
99
  if self._settings.enable_style_update and style_ops:
97
- self._apply_styles(style_ops)
100
+ self._apply_styles(a_target.sheet_id, style_ops)
98
101
 
99
102
  state.b_table_refund_hash = new_refund_hash
100
103
  state.b_table_refund_set = sorted(refund_set)
@@ -117,10 +120,10 @@ class RefundMatchService:
117
120
 
118
121
  # ── Internal ───────────────────────────────────────────────────────────
119
122
 
120
- def _build_refund_set(self) -> set[str]:
123
+ def _build_refund_set(self, sheet_id: str) -> set[str]:
121
124
  rows = self._conn.read_rows(
122
125
  self._settings.tencent_b_file_id,
123
- self._settings.tencent_b_sheet_id,
126
+ sheet_id,
124
127
  )
125
128
  # Skip header
126
129
  data_rows = rows[1:] if rows else []
@@ -133,13 +136,43 @@ class RefundMatchService:
133
136
  refund_set.add(order_no)
134
137
  return refund_set
135
138
 
136
- def _read_a_table(self) -> list[list[Any]]:
139
+ def _read_a_table(self, sheet_id: str) -> list[list[Any]]:
137
140
  rows = self._conn.read_rows(
138
141
  self._settings.tencent_a_file_id,
139
- self._settings.tencent_a_sheet_id,
142
+ sheet_id,
140
143
  )
141
144
  return rows[1:] if rows else []
142
145
 
146
+ def _resolve_a_target(self) -> ResolvedSheetTarget:
147
+ target = resolve_latest_month_sheet(
148
+ self._conn, # type: ignore[arg-type]
149
+ file_id=self._settings.tencent_a_file_id,
150
+ fallback_sheet_id=self._settings.tencent_a_sheet_id,
151
+ title_keyword=self._settings.tencent_a_sheet_name_keyword,
152
+ )
153
+ if target.source != "fixed":
154
+ logger.info(
155
+ "退款匹配使用 A 表最新月份工作表: %s (%s)",
156
+ target.title or target.sheet_id,
157
+ target.sheet_id,
158
+ )
159
+ return target
160
+
161
+ def _resolve_b_target(self) -> ResolvedSheetTarget:
162
+ target = resolve_latest_month_sheet(
163
+ self._conn, # type: ignore[arg-type]
164
+ file_id=self._settings.tencent_b_file_id,
165
+ fallback_sheet_id=self._settings.tencent_b_sheet_id,
166
+ title_keyword=self._settings.tencent_b_sheet_name_keyword,
167
+ )
168
+ if target.source != "fixed":
169
+ logger.info(
170
+ "退款匹配使用 B 表最新月份工作表: %s (%s)",
171
+ target.title or target.sheet_id,
172
+ target.sheet_id,
173
+ )
174
+ return target
175
+
143
176
  def _build_a_scan_hash(self, a_rows: list[list[Any]]) -> str:
144
177
  m = self._map
145
178
  row_hashes = []
@@ -219,14 +252,14 @@ class RefundMatchService:
219
252
 
220
253
  return updates, style_ops, changed
221
254
 
222
- def _apply_styles(self, ops: list[tuple[int, Optional[str]]]) -> None:
255
+ def _apply_styles(self, sheet_id: str, ops: list[tuple[int, Optional[str]]]) -> None:
223
256
  failures: list[str] = []
224
257
  total = len(ops)
225
258
  for index, (row_idx, color) in enumerate(ops):
226
259
  try:
227
260
  self._conn.update_row_style(
228
261
  self._settings.tencent_a_file_id,
229
- self._settings.tencent_a_sheet_id,
262
+ sheet_id,
230
263
  row_idx,
231
264
  bg_color=color,
232
265
  )
package/sync_service.spec CHANGED
@@ -20,6 +20,7 @@ a = Analysis(
20
20
  pathex=[str(root)],
21
21
  binaries=[],
22
22
  datas=[
23
+ (str(root / 'package.json'), '.'),
23
24
  # Bundle .env.example so first-run setup can use it as template
24
25
  (str(root / '.env.example'), '.'),
25
26
  (str(root / '快速开始.txt'), '.'),
@@ -0,0 +1,125 @@
1
+ """Helpers for resolving monthly Tencent Docs sheet targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from typing import Iterable, Protocol
8
+
9
+
10
+ _YEAR_MONTH_RE = re.compile(r"(?P<year>20\d{2})\s*[年./_-]?\s*(?P<month>1[0-2]|0?[1-9])\s*月?")
11
+ _MONTH_ONLY_RE = re.compile(r"(?<!\d)(?P<month>1[0-2]|0?[1-9])\s*月")
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class SheetInfo:
16
+ """Minimal spreadsheet tab metadata."""
17
+
18
+ sheet_id: str
19
+ title: str
20
+ index: int = 0
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class ResolvedSheetTarget:
25
+ """Resolved file/sheet target for one task run."""
26
+
27
+ file_id: str
28
+ sheet_id: str
29
+ title: str | None = None
30
+ source: str = "fixed"
31
+
32
+
33
+ class SheetListingConnector(Protocol):
34
+ """Connector protocol for optional sheet-listing support."""
35
+
36
+ def list_sheets(self, file_id: str) -> list[SheetInfo]:
37
+ """Return spreadsheet tabs for a file."""
38
+
39
+
40
+ def resolve_latest_month_sheet(
41
+ connector: SheetListingConnector,
42
+ *,
43
+ file_id: str,
44
+ fallback_sheet_id: str,
45
+ title_keyword: str,
46
+ ) -> ResolvedSheetTarget:
47
+ """Resolve the latest monthly sheet by title keyword.
48
+
49
+ If `title_keyword` is blank, the fixed `fallback_sheet_id` is returned unchanged.
50
+ """
51
+ keyword = title_keyword.strip()
52
+ if not keyword:
53
+ return ResolvedSheetTarget(file_id=file_id, sheet_id=fallback_sheet_id, source="fixed")
54
+
55
+ list_sheets = getattr(connector, "list_sheets", None)
56
+ if not callable(list_sheets):
57
+ raise RuntimeError("当前 connector 不支持按标题自动选择最新月份工作表")
58
+
59
+ sheets = list_sheets(file_id)
60
+ selected = select_latest_month_sheet(sheets, keyword=keyword)
61
+ return ResolvedSheetTarget(
62
+ file_id=file_id,
63
+ sheet_id=selected.sheet_id,
64
+ title=selected.title,
65
+ source="latest-month",
66
+ )
67
+
68
+
69
+ def select_latest_month_sheet(sheets: Iterable[SheetInfo], *, keyword: str) -> SheetInfo:
70
+ """Choose the latest monthly sheet from matching titles.
71
+
72
+ Priority:
73
+ 1. Prefer titles that include both year and month, such as `2026年3月` / `2026-03` / `2026/03`
74
+ 2. Fall back to month-only titles such as `3月毛利率`
75
+
76
+ This keeps same-year monthly tabs automatic, while allowing accurate cross-year
77
+ selection when the sheet title carries the year.
78
+ """
79
+ needle = keyword.strip().lower()
80
+ candidates = [sheet for sheet in sheets if needle in sheet.title.lower()]
81
+ if not candidates:
82
+ raise ValueError(f"未找到包含关键字 '{keyword}' 的工作表")
83
+
84
+ ranked: list[tuple[int, int, int, SheetInfo]] = []
85
+ unparsed: list[SheetInfo] = []
86
+ for sheet in candidates:
87
+ period = extract_year_month(sheet.title)
88
+ if period is None:
89
+ unparsed.append(sheet)
90
+ continue
91
+ year, month = period
92
+ ranked.append((year, month, sheet.index, sheet))
93
+
94
+ if ranked:
95
+ ranked.sort(key=lambda item: (item[0], item[1], item[2]))
96
+ return ranked[-1][3]
97
+
98
+ if len(candidates) == 1:
99
+ return candidates[0]
100
+
101
+ titles = ", ".join(sheet.title for sheet in candidates[:5])
102
+ raise ValueError(
103
+ f"找到多个包含关键字 '{keyword}' 的工作表,但无法从标题解析月份: {titles}"
104
+ )
105
+
106
+
107
+ def extract_year_month(title: str) -> tuple[int, int] | None:
108
+ """Extract `(year, month)` from a sheet title.
109
+
110
+ Supports examples like:
111
+ - 2026年3月毛利率
112
+ - 2026-03 毛利率
113
+ - 3月毛利率
114
+ """
115
+ year_month = _YEAR_MONTH_RE.search(title)
116
+ if year_month:
117
+ year = int(year_month.group("year"))
118
+ month = int(year_month.group("month"))
119
+ return year, month
120
+
121
+ month_only = _MONTH_ONLY_RE.search(title)
122
+ if month_only:
123
+ return 0, int(month_only.group("month"))
124
+
125
+ return None
@@ -1,4 +1,5 @@
1
1
  @echo off
2
+ setlocal EnableExtensions DisableDelayedExpansion
2
3
  chcp 65001 >nul 2>&1
3
4
  title 多表格同步服务
4
5
  cd /d "%~dp0"
@@ -12,12 +13,14 @@ echo.
12
13
  :: ── 优先级1:已有打包好的 exe ────────────────────────
13
14
  if exist "sync_service.exe" (
14
15
  set "CMD=sync_service.exe"
16
+ set "CMD_ARGS="
15
17
  goto :menu
16
18
  )
17
19
 
18
20
  :: ── 优先级2:已有虚拟环境 ───────────────────────────
19
21
  if exist ".venv\Scripts\python.exe" (
20
- set "CMD=.venv\Scripts\python main.py"
22
+ set "CMD=.venv\Scripts\python.exe"
23
+ set "CMD_ARGS=main.py"
21
24
  goto :menu
22
25
  )
23
26
 
@@ -89,7 +92,8 @@ echo [*] 使用嵌入式 Python 安装依赖...
89
92
  echo.
90
93
  python_embed\python.exe -m pip install -q -r requirements.txt --target=".deps" 2>nul
91
94
  set "PYTHONPATH=%~dp0.deps;%~dp0"
92
- set "CMD=python_embed\python.exe main.py"
95
+ set "CMD=python_embed\python.exe"
96
+ set "CMD_ARGS=main.py"
93
97
  goto :menu
94
98
 
95
99
  :venv_setup
@@ -111,15 +115,30 @@ if errorlevel 1 (
111
115
 
112
116
  echo [3/3] 环境初始化完成!
113
117
  echo.
114
- set "CMD=.venv\Scripts\python main.py"
118
+ set "CMD=.venv\Scripts\python.exe"
119
+ set "CMD_ARGS=main.py"
115
120
 
116
121
  :: ── 启动 Rich 控制台 / 执行指定命令 ────────────────
122
+ :menu
123
+ if not defined CMD (
124
+ echo [!] 启动命令未初始化成功
125
+ echo.
126
+ pause
127
+ exit /b 1
128
+ )
129
+
117
130
  if "%~1"=="" (
118
- %CMD%
131
+ call "%CMD%" %CMD_ARGS%
119
132
  echo.
120
133
  pause
121
134
  exit /b 0
122
135
  )
123
136
 
124
- %CMD% %*
125
- exit /b %errorlevel%
137
+ call "%CMD%" %CMD_ARGS% %*
138
+ set "EXIT_CODE=%errorlevel%"
139
+ if not "%EXIT_CODE%"=="0" (
140
+ echo.
141
+ echo [!] 程序退出,返回码: %EXIT_CODE%
142
+ pause
143
+ )
144
+ exit /b %EXIT_CODE%
@@ -1,31 +1,38 @@
1
1
  TB Order Sync 快速开始
2
2
 
3
- 1. 双击启动
3
+ 1. 一条命令安装 CLI(macOS / 已安装 Node.js)
4
+ - npm install -g tb-order-sync
5
+ - 安装完成后直接运行 `tb`
6
+ - 运行环境会放到 `~/Library/Application Support/tb-order-sync/`
7
+
8
+ 2. 双击启动
4
9
  - Windows: 启动.bat
5
10
  - macOS: 启动.command
6
11
 
7
- 2. 首次运行
12
+ 3. 首次运行
8
13
  - 如果本机还没配置,程序会自动进入 setup 配置向导
9
14
  - 配好腾讯文档信息后,建议先执行一次 `tb check`
10
15
 
11
- 3. 常用命令
16
+ 4. 常用命令
12
17
  - tb all 执行毛利计算 + 退款匹配
13
18
  - tb all --dry-run 仅演练,不写回
14
19
  - tb daemon start 后台持续运行
15
20
  - tb daemon status 查看后台状态
16
21
  - tb daemon logs 查看后台日志
17
22
 
18
- 4. 登录自启
23
+ 5. 登录自启
19
24
  - tb daemon autostart-enable
20
25
  - tb daemon autostart-status
21
26
  - tb daemon autostart-disable
22
27
 
23
- 5. 日志与状态文件
24
- - state/scheduler.console.log
25
- - state/last_run.json
26
- - state/sync_state.json
28
+ 6. 日志与状态文件
29
+ - npm 安装方式:~/Library/Application Support/tb-order-sync/state/
30
+ - 常见文件:
31
+ - scheduler.console.log
32
+ - last_run.json
33
+ - sync_state.json
27
34
 
28
- 6. 常见问题
35
+ 7. 常见问题
29
36
  - 缺配置:运行 tb setup
30
37
  - 想做启动自检:运行 tb check
31
38
  - 接口限流:稍等后重试,不要连续高频重复执行