tb-order-sync 0.4.2 → 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.
@@ -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%