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.
- package/.env.example +4 -0
- package/CHANGELOG.md +53 -0
- package/README.md +4 -1
- package/build.py +1 -0
- package/cli/dashboard.py +167 -29
- package/cli/setup.py +204 -25
- package/config/settings.py +23 -0
- package/connectors/tencent_docs.py +87 -0
- package/package.json +1 -1
- package/services/gross_profit_service.py +23 -6
- package/services/refund_match_service.py +43 -10
- package/sync_service.spec +1 -0
- package/utils/sheet_selector.py +125 -0
- package//345/220/257/345/212/250.bat +25 -6
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
+
sheet_id,
|
|
230
263
|
row_idx,
|
|
231
264
|
bg_color=color,
|
|
232
265
|
)
|
package/sync_service.spec
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
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%
|