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.
@@ -1,14 +1,13 @@
1
1
  """Tencent Docs (腾讯文档) sheet connector.
2
2
 
3
- API Reference (needs verification):
4
- 腾讯文档开放平台: https://docs.qq.com/open/wiki/
5
-
6
- TODO / NEED_VERIFY:
7
- - OAuth2 token refresh flow: the current implementation assumes a pre-obtained
8
- access_token passed via config. Production should implement refresh_token cycle.
9
- - Exact API endpoints and request/response schemas are marked inline.
10
- - Rate limit specifics (QPS, batch size caps) need confirmation from docs.
11
- - Style/formatting API availability is uncertain — update_row_style is best-effort.
3
+ Uses the official Online Sheet v3 APIs:
4
+ - GET /openapi/spreadsheet/v3/files/{fileId}/{sheetId}/{range}
5
+ - POST /openapi/spreadsheet/v3/files/{fileId}/batchUpdate
6
+
7
+ Notes:
8
+ - Access token refresh is still external. Supply a valid access token in config.
9
+ - The sheet model exposes text color formatting. For "整行标红", this connector
10
+ rewrites the full row with red text formatting.
12
11
  """
13
12
 
14
13
  from __future__ import annotations
@@ -25,18 +24,15 @@ from utils.retry import default_retry
25
24
 
26
25
  logger = get_logger(__name__)
27
26
 
28
- # ── TODO / NEED_VERIFY ─────────────────────────────────────────────────────
29
- # Base URL for Tencent Docs Open API.
30
- # 腾讯文档智能表格(SmartSheet)和在线表格(Sheet)的API路径可能不同,
31
- # 需要根据实际文档类型确认。
32
- # 以下为在线表格(Sheet)的推测路径:
33
- _BASE_URL = "https://docs.qq.com/openapi/v2"
34
-
35
- # TODO: 确认实际的 API 路径格式
36
- # 读取单元格数据: GET /openapi/v2/files/{fileID}/sheets/{sheetID}/content
37
- # 写入单元格数据: PUT /openapi/v2/files/{fileID}/sheets/{sheetID}/content
38
- # 这些路径需要与腾讯文档开放平台文档核实
39
- # ────────────────────────────────────────────────────────────────────────────
27
+ _BASE_URL = "https://docs.qq.com"
28
+ # Tencent Docs v3 range query limit:
29
+ # rows <= 1000, cols <= 200, total cells <= 10000.
30
+ # Current project only needs up to column I/L, so keep the query window narrow.
31
+ _MAX_QUERY_ROWS = 200
32
+ _MAX_QUERY_COLS = 20
33
+ _MAX_BATCH_REQUESTS = 5
34
+ _BATCH_SLEEP_SECONDS = 0.5
35
+ _DEFAULT_TEXT_COLOR = {"red": 0, "green": 0, "blue": 0, "alpha": 255}
40
36
 
41
37
 
42
38
  class TencentDocsConnector(BaseSheetConnector):
@@ -60,33 +56,23 @@ class TencentDocsConnector(BaseSheetConnector):
60
56
  )
61
57
 
62
58
  def _build_headers(self) -> dict[str, str]:
63
- return {
59
+ headers = {
64
60
  "Content-Type": "application/json",
61
+ "Accept": "application/json",
65
62
  "Access-Token": self._access_token,
66
- # TODO / NEED_VERIFY: 腾讯文档 API 的认证 header 名称
67
- # 可能是 "Access-Token" 或 "Authorization: Bearer <token>"
68
- # 需根据实际文档确认
63
+ "Client-Id": self._client_id,
69
64
  }
65
+ if self._open_id:
66
+ headers["Open-Id"] = self._open_id
67
+ else:
68
+ logger.warning("Tencent connector initialized without Open-Id; official APIs require it")
69
+ return headers
70
70
 
71
- # ── Token refresh (placeholder) ────────────────────────────────────────
72
71
  def refresh_token(self) -> None:
73
- """TODO: Implement OAuth2 token refresh using client_id + client_secret.
74
-
75
- 腾讯文档的 token 有效期通常较短,生产环境必须实现自动刷新。
76
- 当前版本依赖外部提供有效的 access_token。
77
- """
78
- # TODO / NEED_VERIFY:
79
- # POST /oauth/v2/token
80
- # {
81
- # "client_id": ...,
82
- # "client_secret": ...,
83
- # "grant_type": "refresh_token",
84
- # "refresh_token": ...
85
- # }
72
+ """Token refresh remains external in this MVP."""
86
73
  raise NotImplementedError("Token refresh not yet implemented — supply valid access_token in .env")
87
74
 
88
- # ── Read ───────────────────────────────────────────────────────────────
89
- @default_retry(max_attempts=3)
75
+ @default_retry(max_attempts=5)
90
76
  def read_rows(
91
77
  self,
92
78
  file_id: str,
@@ -95,70 +81,63 @@ class TencentDocsConnector(BaseSheetConnector):
95
81
  start_row: int = 0,
96
82
  end_row: Optional[int] = None,
97
83
  ) -> list[list[Any]]:
98
- """Read rows from a Tencent Docs sheet.
99
-
100
- TODO / NEED_VERIFY: 确认实际的读取 API endpoint 和参数格式。
101
- 以下为推测实现,需根据腾讯文档开放平台文档调整。
102
- """
103
- # TODO / NEED_VERIFY: 实际的 API 路径和参数
104
- # 可能的路径: GET /files/{fileID}/sheets/{sheetID}/content
105
- # 也可能需要指定 range,如 "A1:Z1000"
106
- url = f"/files/{file_id}/sheets/{sheet_id}/content"
107
- params: dict[str, Any] = {}
108
- if end_row is not None:
109
- # TODO: 腾讯文档可能使用 A1 notation 的 range 参数
110
- # 例如 range = "A{start_row+1}:Z{end_row+1}"
111
- params["range"] = f"A{start_row + 1}:ZZ{end_row + 1}"
112
- else:
113
- params["range"] = f"A{start_row + 1}:ZZ"
114
-
115
- logger.info("Reading rows from file=%s sheet=%s range=%s", file_id, sheet_id, params.get("range"))
116
-
117
- resp = self._http.get(url, params=params)
118
- resp.raise_for_status()
119
- data = resp.json()
120
-
121
- # TODO / NEED_VERIFY: 解析响应数据结构
122
- # 腾讯文档返回的数据格式需要确认,以下是推测:
123
- # data = { "data": { "rows": [[cell, ...], ...] } }
124
- # 或: data = { "data": [[cell, ...], ...] }
125
- rows = data.get("data", {}).get("rows", [])
126
- if not rows and isinstance(data.get("data"), list):
127
- rows = data["data"]
128
-
129
- logger.info("Read %d rows from file=%s sheet=%s", len(rows), file_id, sheet_id)
130
- return rows
131
-
132
- # ── Write ──────────────────────────────────────────────────────────────
133
- @default_retry(max_attempts=3)
84
+ """Read rows from a Tencent Docs sheet using the official v3 range API."""
85
+ if end_row is not None and end_row <= start_row:
86
+ return []
87
+
88
+ max_col_letter = col_index_to_letter(_MAX_QUERY_COLS - 1)
89
+ all_rows: list[list[Any]] = []
90
+ next_row = start_row
91
+
92
+ while True:
93
+ chunk_end = next_row + _MAX_QUERY_ROWS
94
+ if end_row is not None:
95
+ chunk_end = min(chunk_end, end_row)
96
+
97
+ range_ref = f"A{next_row + 1}:{max_col_letter}{chunk_end}"
98
+ url = f"/openapi/spreadsheet/v3/files/{file_id}/{sheet_id}/{range_ref}"
99
+ logger.info("Reading rows from file=%s sheet=%s range=%s", file_id, sheet_id, range_ref)
100
+
101
+ try:
102
+ data = self._unwrap_response(self._http.get(url))
103
+ except RuntimeError as exc:
104
+ if all_rows and "range' invalid" in str(exc):
105
+ logger.info("Stop reading at invalid range boundary: %s", range_ref)
106
+ break
107
+ raise
108
+ grid_data = data.get("gridData", data)
109
+ rows = self._grid_data_to_rows(grid_data)
110
+ all_rows.extend(rows)
111
+
112
+ requested_rows = chunk_end - next_row
113
+ if end_row is not None and chunk_end >= end_row:
114
+ break
115
+ if len(rows) < requested_rows:
116
+ break
117
+
118
+ next_row = chunk_end
119
+
120
+ logger.info("Read %d rows from file=%s sheet=%s", len(all_rows), file_id, sheet_id)
121
+ return all_rows
122
+
123
+ @default_retry(max_attempts=5)
134
124
  def write_cells(
135
125
  self,
136
126
  file_id: str,
137
127
  sheet_id: str,
138
128
  updates: list[CellUpdate],
139
129
  ) -> None:
140
- """Write cell values to a Tencent Docs sheet.
141
-
142
- TODO / NEED_VERIFY: 确认写入 API 的 endpoint 和 request body 格式。
143
- """
130
+ """Write cell values via the official v3 batchUpdate API."""
144
131
  if not updates:
145
132
  return
146
133
 
147
- # TODO / NEED_VERIFY: 构建写入请求体
148
- # 腾讯文档可能支持批量单元格更新,格式推测如下:
149
- # PUT /files/{fileID}/sheets/{sheetID}/content
150
- # body = { "data": [ {"range": "G2", "value": 123}, ... ] }
151
- payload = self._build_write_payload(updates)
152
- url = f"/files/{file_id}/sheets/{sheet_id}/content"
153
-
134
+ url = f"/openapi/spreadsheet/v3/files/{file_id}/batchUpdate"
135
+ payload = self._build_write_payload(sheet_id, updates)
154
136
  logger.info("Writing %d cells to file=%s sheet=%s", len(updates), file_id, sheet_id)
155
-
156
- resp = self._http.put(url, json=payload)
157
- resp.raise_for_status()
158
-
137
+ self._unwrap_response(self._http.post(url, json=payload))
159
138
  logger.info("Successfully wrote %d cells", len(updates))
160
139
 
161
- @default_retry(max_attempts=3)
140
+ @default_retry(max_attempts=5)
162
141
  def batch_update(
163
142
  self,
164
143
  file_id: str,
@@ -166,16 +145,16 @@ class TencentDocsConnector(BaseSheetConnector):
166
145
  updates: list[CellUpdate],
167
146
  batch_size: int = 100,
168
147
  ) -> None:
169
- """Write cells in batches to stay within API rate limits."""
148
+ """Write cells in batches while respecting Tencent Docs batch limits."""
170
149
  total = len(updates)
171
- for i in range(0, total, batch_size):
172
- batch = updates[i : i + batch_size]
150
+ chunk_size = max(1, min(batch_size, _MAX_BATCH_REQUESTS))
151
+ for i in range(0, total, chunk_size):
152
+ batch = updates[i : i + chunk_size]
173
153
  self.write_cells(file_id, sheet_id, batch)
174
- if i + batch_size < total:
175
- time.sleep(0.5) # Basic rate-limit courtesy
176
- logger.info("Batch update complete: %d cells in %d batches", total, (total + batch_size - 1) // batch_size)
154
+ if i + chunk_size < total:
155
+ time.sleep(_BATCH_SLEEP_SECONDS)
156
+ logger.info("Batch update complete: %d cells in %d batches", total, (total + chunk_size - 1) // chunk_size)
177
157
 
178
- # ── Column management ──────────────────────────────────────────────────
179
158
  def ensure_column(
180
159
  self,
181
160
  file_id: str,
@@ -183,20 +162,14 @@ class TencentDocsConnector(BaseSheetConnector):
183
162
  col_letter: str,
184
163
  header_name: str,
185
164
  ) -> None:
186
- """Ensure column header exists; write it if missing.
187
-
188
- TODO / NEED_VERIFY: 可能需要先读取 header row,检查列是否存在,
189
- 如果不存在则需要插入列或写入 header。
190
- """
165
+ """Ensure column header exists; write it if missing."""
191
166
  header = self.get_header(file_id, sheet_id)
192
167
  from config.mappings import col_letter_to_index
193
- idx = col_letter_to_index(col_letter)
194
168
 
169
+ idx = col_letter_to_index(col_letter)
195
170
  if idx < len(header) and header[idx] == header_name:
196
- logger.debug("Column %s already has header '%s'", col_letter, header_name)
197
171
  return
198
172
 
199
- # Write the header cell
200
173
  self.write_cells(file_id, sheet_id, [CellUpdate(row=0, col=idx, value=header_name)])
201
174
  logger.info("Ensured column %s header = '%s'", col_letter, header_name)
202
175
 
@@ -211,7 +184,7 @@ class TencentDocsConnector(BaseSheetConnector):
211
184
  return [str(v) if v is not None else "" for v in rows[0]]
212
185
  return []
213
186
 
214
- # ── Style (optional) ───────────────────────────────────────────────────
187
+ @default_retry(max_attempts=5)
215
188
  def update_row_style(
216
189
  self,
217
190
  file_id: str,
@@ -219,35 +192,172 @@ class TencentDocsConnector(BaseSheetConnector):
219
192
  row_index: int,
220
193
  bg_color: Optional[str] = None,
221
194
  ) -> None:
222
- """Optional: set row background color.
223
-
224
- TODO / NEED_VERIFY: 腾讯文档是否支持通过 API 设置单元格/行样式。
225
- 如果不支持,此方法将仅记录日志并跳过。
226
- 当前实现为 best-effort placeholder。
227
- """
228
- if bg_color is None:
229
- logger.debug("update_row_style called with no color, skipping row=%d", row_index)
195
+ """Rewrite a row with red or default text formatting."""
196
+ header = self.get_header(file_id, sheet_id)
197
+ rows = self.read_rows(file_id, sheet_id, start_row=row_index, end_row=row_index + 1)
198
+ if not rows:
199
+ logger.warning("No row found for style update: row=%d file=%s sheet=%s", row_index, file_id, sheet_id)
230
200
  return
231
201
 
232
- # TODO / NEED_VERIFY: 样式 API 路径和格式
233
- # 可能的路径: PUT /files/{fileID}/sheets/{sheetID}/styles
234
- # body = { "range": "A{row}:Z{row}", "style": {"backgroundColor": "#FF0000"} }
235
- logger.info(
236
- "Style update requested for row=%d bg=%s (file=%s) "
237
- "TODO: verify Tencent Docs style API support",
238
- row_index, bg_color, file_id,
239
- )
202
+ row_values = list(rows[0])
203
+ width = max(len(header), len(row_values))
204
+ if width == 0:
205
+ return
206
+ if len(row_values) < width:
207
+ row_values.extend([""] * (width - len(row_values)))
208
+
209
+ text_color = self._hex_to_rgba(bg_color) if bg_color else _DEFAULT_TEXT_COLOR
210
+ payload = {
211
+ "requests": [
212
+ {
213
+ "updateRangeRequest": {
214
+ "sheetId": sheet_id,
215
+ "gridData": {
216
+ "startRow": row_index,
217
+ "startColumn": 0,
218
+ "rows": [
219
+ {
220
+ "values": [
221
+ self._build_cell_data(value, text_color=text_color)
222
+ for value in row_values
223
+ ]
224
+ }
225
+ ],
226
+ },
227
+ }
228
+ }
229
+ ]
230
+ }
231
+ url = f"/openapi/spreadsheet/v3/files/{file_id}/batchUpdate"
232
+ self._unwrap_response(self._http.post(url, json=payload))
233
+ logger.info("Updated row style for row=%d file=%s sheet=%s", row_index, file_id, sheet_id)
234
+
235
+ @staticmethod
236
+ def _grid_data_to_rows(grid_data: dict[str, Any]) -> list[list[Any]]:
237
+ rows: list[list[Any]] = []
238
+ for row_data in grid_data.get("rows", []):
239
+ rows.append([
240
+ TencentDocsConnector._extract_cell_value(cell)
241
+ for cell in row_data.get("values", [])
242
+ ])
243
+ return rows
244
+
245
+ @staticmethod
246
+ def _extract_cell_value(cell: dict[str, Any]) -> Any:
247
+ if not isinstance(cell, dict):
248
+ return None
249
+ cell_value = cell.get("cellValue") or {}
250
+ if "number" in cell_value:
251
+ return cell_value["number"]
252
+ if "text" in cell_value:
253
+ return cell_value["text"]
254
+ if "link" in cell_value:
255
+ link = cell_value["link"] or {}
256
+ return link.get("text") or link.get("url") or ""
257
+ if "location" in cell_value:
258
+ location = cell_value["location"] or {}
259
+ return location.get("name") or ""
260
+ if "time" in cell_value:
261
+ return cell_value["time"]
262
+ if "select" in cell_value:
263
+ select = cell_value["select"] or {}
264
+ return select.get("text") or ""
265
+ return None
266
+
267
+ @staticmethod
268
+ def _unwrap_response(resp: httpx.Response) -> dict[str, Any]:
269
+ if resp.status_code == 429:
270
+ raise RuntimeError("Tencent Docs API failed: HTTP 429 Requests Over Limit. Please Retry Later.")
271
+ resp.raise_for_status()
272
+ payload = resp.json()
273
+ if not isinstance(payload, dict):
274
+ raise RuntimeError(f"Unexpected Tencent Docs response: {payload!r}")
275
+
276
+ if "ret" in payload and payload.get("ret") not in (0, None):
277
+ raise RuntimeError(
278
+ TencentDocsConnector._friendly_api_error(
279
+ code=payload.get("ret"),
280
+ message=payload.get("msg"),
281
+ )
282
+ )
283
+ if "code" in payload and payload.get("code") not in (0, None):
284
+ raise RuntimeError(
285
+ TencentDocsConnector._friendly_api_error(
286
+ code=payload.get("code"),
287
+ message=payload.get("message"),
288
+ )
289
+ )
290
+
291
+ data = payload.get("data")
292
+ if isinstance(data, dict):
293
+ return data
294
+ return payload
295
+
296
+ @staticmethod
297
+ def _friendly_api_error(code: Any, message: Any) -> str:
298
+ code_text = str(code)
299
+ message_text = str(message or "")
300
+ base = f"Tencent Docs API failed: code={code_text} message={message_text}"
301
+
302
+ if code_text == "400007":
303
+ return base + "。腾讯文档接口限流,请稍后重试。"
304
+ if code_text == "400001":
305
+ return base + "。请求参数不合法,请检查表格 ID、sheet ID、列范围或表格结构。"
306
+ if code_text in {"401", "401001"}:
307
+ return base + "。认证失败,请检查 Access Token、Client ID、Open ID。"
308
+ if code_text in {"403", "403001"}:
309
+ return base + "。权限不足,请确认应用授权和文档访问权限。"
310
+ return base
240
311
 
241
- # ── Internal helpers ───────────────────────────────────────────────────
242
312
  @staticmethod
243
- def _build_write_payload(updates: list[CellUpdate]) -> dict[str, Any]:
244
- """Build the API request body for a batch cell write.
245
-
246
- TODO / NEED_VERIFY: 确认腾讯文档的批量写入 request body 格式。
247
- """
248
- cells = []
249
- for u in updates:
250
- col_letter = col_index_to_letter(u.col)
251
- cell_ref = f"{col_letter}{u.row + 1}" # A1 notation, 1-based
252
- cells.append({"range": cell_ref, "value": u.value})
253
- return {"data": cells}
313
+ def _build_write_payload(sheet_id: str, updates: list[CellUpdate]) -> dict[str, Any]:
314
+ return {
315
+ "requests": [
316
+ {
317
+ "updateRangeRequest": {
318
+ "sheetId": sheet_id,
319
+ "gridData": {
320
+ "startRow": update.row,
321
+ "startColumn": update.col,
322
+ "rows": [
323
+ {
324
+ "values": [
325
+ TencentDocsConnector._build_cell_data(update.value)
326
+ ]
327
+ }
328
+ ],
329
+ },
330
+ }
331
+ }
332
+ for update in updates
333
+ ]
334
+ }
335
+
336
+ @staticmethod
337
+ def _build_cell_data(value: Any, text_color: Optional[dict[str, int]] = None) -> dict[str, Any]:
338
+ cell_data: dict[str, Any] = {"cellValue": TencentDocsConnector._build_cell_value(value)}
339
+ if text_color is not None:
340
+ cell_data["cellFormat"] = {"textFormat": {"color": text_color}}
341
+ return cell_data
342
+
343
+ @staticmethod
344
+ def _build_cell_value(value: Any) -> dict[str, Any]:
345
+ if isinstance(value, bool):
346
+ return {"text": str(value).lower()}
347
+ if isinstance(value, (int, float)):
348
+ return {"number": value}
349
+ if value is None:
350
+ return {"text": ""}
351
+ return {"text": str(value)}
352
+
353
+ @staticmethod
354
+ def _hex_to_rgba(color: str) -> dict[str, int]:
355
+ value = color.strip().lstrip("#")
356
+ if len(value) != 6:
357
+ raise ValueError(f"Unsupported color value: {color}")
358
+ return {
359
+ "red": int(value[0:2], 16),
360
+ "green": int(value[2:4], 16),
361
+ "blue": int(value[4:6], 16),
362
+ "alpha": 255,
363
+ }
@@ -21,9 +21,12 @@ class SyncState(BaseModel):
21
21
 
22
22
  last_run_at: Optional[datetime] = None
23
23
 
24
- # A 表: order_no -> fingerprint
24
+ # A 表毛利任务: row_index -> fingerprint
25
25
  a_table_fingerprints: dict[str, str] = Field(default_factory=dict)
26
26
 
27
+ # A 表退款任务: 整体扫描 hash(基于单号 + 当前退款状态)
28
+ a_table_refund_scan_hash: str = ""
29
+
27
30
  # B 表: 退款单号集合 hash
28
31
  b_table_refund_hash: str = ""
29
32
 
@@ -45,3 +45,18 @@ class TaskResult(BaseModel):
45
45
  self.success = success
46
46
  if error_message:
47
47
  self.error_message = error_message
48
+
49
+
50
+ class RunSummary(BaseModel):
51
+ """Summary of one manual or scheduled execution round."""
52
+
53
+ trigger: str = "manual"
54
+ success: bool = True
55
+ started_at: datetime = Field(default_factory=datetime.now)
56
+ finished_at: Optional[datetime] = None
57
+ task_count: int = 0
58
+ rows_read: int = 0
59
+ rows_changed: int = 0
60
+ rows_error: int = 0
61
+ tasks: list[TaskResult] = Field(default_factory=list)
62
+ message: Optional[str] = None
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "tb-order-sync",
3
- "version": "0.3.1",
4
- "description": "Tencent Docs and Feishu order-sheet sync service with refund workflow, daemon scheduler, and tb CLI",
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",
5
5
  "bin": {
6
6
  "tb": "bin/tb.js"
7
7
  },
@@ -16,6 +16,7 @@
16
16
  "main.py",
17
17
  "requirements.txt",
18
18
  "README.md",
19
+ "快速开始.txt",
19
20
  "CHANGELOG.md",
20
21
  "LICENSE",
21
22
  ".env.example",
@@ -42,6 +43,8 @@
42
43
  "refund-workflow",
43
44
  "gross-profit",
44
45
  "daemon",
46
+ "autostart",
47
+ "self-check",
45
48
  "scheduler",
46
49
  "rich-cli",
47
50
  "tencent-docs",