taiwan-logistics-skill 1.0.0
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/README.md +186 -0
- package/assets/taiwan-logistics/EXAMPLES.md +3183 -0
- package/assets/taiwan-logistics/README.md +33 -0
- package/assets/taiwan-logistics/SKILL.md +827 -0
- package/assets/taiwan-logistics/data/field-mappings.csv +27 -0
- package/assets/taiwan-logistics/data/logistics-types.csv +11 -0
- package/assets/taiwan-logistics/data/operations.csv +8 -0
- package/assets/taiwan-logistics/data/providers.csv +9 -0
- package/assets/taiwan-logistics/data/status-codes.csv +14 -0
- package/assets/taiwan-logistics/examples/newebpay-logistics-cvs-example.py +524 -0
- package/assets/taiwan-logistics/examples/payuni-logistics-cvs-example.py +605 -0
- package/assets/taiwan-logistics/references/NEWEBPAY_LOGISTICS_REFERENCE.md +774 -0
- package/assets/taiwan-logistics/references/ecpay-logistics-api.md +536 -0
- package/assets/taiwan-logistics/references/payuni-logistics-api.md +712 -0
- package/assets/taiwan-logistics/scripts/core.py +276 -0
- package/assets/taiwan-logistics/scripts/search.py +127 -0
- package/assets/taiwan-logistics/scripts/test_logistics.py +236 -0
- package/dist/index.js +16377 -0
- package/package.json +58 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Taiwan Logistics BM25 搜索引擎
|
|
4
|
+
|
|
5
|
+
基於 BM25 (Okapi BM25) 演算法的語義搜索系統
|
|
6
|
+
支援多個搜索域: provider, operation, logistics_type, field, status
|
|
7
|
+
|
|
8
|
+
用法:
|
|
9
|
+
from core import search, search_all, detect_domain
|
|
10
|
+
|
|
11
|
+
# 單域搜索
|
|
12
|
+
results = search("7-11", domain="logistics_type", max_results=5)
|
|
13
|
+
|
|
14
|
+
# 自動偵測域
|
|
15
|
+
results = search("NewebPay API", domain=None, max_results=3)
|
|
16
|
+
|
|
17
|
+
# 全域搜索
|
|
18
|
+
all_results = search_all("配送失敗", max_per_domain=3)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import csv
|
|
22
|
+
import math
|
|
23
|
+
import re
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import List, Dict, Optional, Tuple
|
|
26
|
+
import json
|
|
27
|
+
|
|
28
|
+
# 數據文件路徑
|
|
29
|
+
SCRIPT_DIR = Path(__file__).parent
|
|
30
|
+
DATA_DIR = SCRIPT_DIR.parent / 'data'
|
|
31
|
+
|
|
32
|
+
# CSV 配置
|
|
33
|
+
CSV_CONFIG = {
|
|
34
|
+
'provider': {
|
|
35
|
+
'file': 'providers.csv',
|
|
36
|
+
'search_cols': ['provider', 'name_zh', 'name_en', 'features'],
|
|
37
|
+
'output_cols': ['provider', 'name_zh', 'type', 'test_merchant_id', 'test_hash_key', 'features', 'coverage']
|
|
38
|
+
},
|
|
39
|
+
'operation': {
|
|
40
|
+
'file': 'operations.csv',
|
|
41
|
+
'search_cols': ['operation', 'operation_zh', 'ecpay_endpoint', 'required_fields', 'notes'],
|
|
42
|
+
'output_cols': ['operation', 'operation_zh', 'ecpay_endpoint', 'method', 'required_fields', 'optional_fields', 'notes']
|
|
43
|
+
},
|
|
44
|
+
'logistics_type': {
|
|
45
|
+
'file': 'logistics-types.csv',
|
|
46
|
+
'search_cols': ['code', 'name_zh', 'name_en', 'provider', 'notes'],
|
|
47
|
+
'output_cols': ['code', 'name_zh', 'provider', 'category', 'size_limit', 'weight_limit', 'notes']
|
|
48
|
+
},
|
|
49
|
+
'field': {
|
|
50
|
+
'file': 'field-mappings.csv',
|
|
51
|
+
'search_cols': ['field_name', 'field_zh', 'ecpay_name', 'newebpay_name', 'payuni_name', 'notes'],
|
|
52
|
+
'output_cols': ['field_name', 'field_zh', 'ecpay_name', 'newebpay_name', 'payuni_name', 'type', 'required', 'format', 'notes']
|
|
53
|
+
},
|
|
54
|
+
'status': {
|
|
55
|
+
'file': 'status-codes.csv',
|
|
56
|
+
'search_cols': ['provider', 'code', 'status_zh', 'status_en', 'description'],
|
|
57
|
+
'output_cols': ['provider', 'code', 'status_zh', 'category', 'description']
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# 域偵測關鍵字
|
|
62
|
+
DOMAIN_KEYWORDS = {
|
|
63
|
+
'provider': ['ecpay', '綠界', 'newebpay', '藍新', 'payuni', '統一', '物流', '服務商', 'provider'],
|
|
64
|
+
'operation': ['create', 'query', 'print', 'map', '建立', '查詢', '列印', '電子地圖', 'api', 'endpoint'],
|
|
65
|
+
'logistics_type': ['711', '7-11', 'family', '全家', 'hilife', '萊爾富', 'okmart', 'tcat', '黑貓', '超商', '宅配', 'cvs', 'home'],
|
|
66
|
+
'field': ['field', 'parameter', '參數', '欄位', 'merchantid', 'tradeno', 'logistics'],
|
|
67
|
+
'status': ['status', 'code', '狀態', '配送', '取貨', '完成', '失敗', '300', '3001']
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def tokenize(text: str) -> List[str]:
|
|
72
|
+
"""中英文混合分詞"""
|
|
73
|
+
if not text:
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
# 轉小寫
|
|
77
|
+
text = text.lower()
|
|
78
|
+
|
|
79
|
+
# 分離中英文
|
|
80
|
+
tokens = []
|
|
81
|
+
|
|
82
|
+
# 英文 token (包含數字)
|
|
83
|
+
for match in re.finditer(r'[a-z0-9]+', text):
|
|
84
|
+
tokens.append(match.group())
|
|
85
|
+
|
|
86
|
+
# 中文 bigram + unigram
|
|
87
|
+
chinese_chars = re.findall(r'[\u4e00-\u9fff]', text)
|
|
88
|
+
for char in chinese_chars:
|
|
89
|
+
tokens.append(char)
|
|
90
|
+
|
|
91
|
+
# Bigram for better matching
|
|
92
|
+
for i in range(len(chinese_chars) - 1):
|
|
93
|
+
tokens.append(chinese_chars[i] + chinese_chars[i + 1])
|
|
94
|
+
|
|
95
|
+
return tokens
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def compute_idf(documents: List[List[str]]) -> Dict[str, float]:
|
|
99
|
+
"""計算 IDF (Inverse Document Frequency)"""
|
|
100
|
+
n = len(documents)
|
|
101
|
+
df = {}
|
|
102
|
+
|
|
103
|
+
for doc in documents:
|
|
104
|
+
seen = set()
|
|
105
|
+
for term in doc:
|
|
106
|
+
if term not in seen:
|
|
107
|
+
df[term] = df.get(term, 0) + 1
|
|
108
|
+
seen.add(term)
|
|
109
|
+
|
|
110
|
+
idf = {}
|
|
111
|
+
for term, freq in df.items():
|
|
112
|
+
idf[term] = math.log((n - freq + 0.5) / (freq + 0.5) + 1.0)
|
|
113
|
+
|
|
114
|
+
return idf
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def bm25_score(
|
|
118
|
+
query_tokens: List[str],
|
|
119
|
+
doc_tokens: List[str],
|
|
120
|
+
idf: Dict[str, float],
|
|
121
|
+
avg_dl: float,
|
|
122
|
+
k1: float = 1.5,
|
|
123
|
+
b: float = 0.75
|
|
124
|
+
) -> float:
|
|
125
|
+
"""計算 BM25 分數"""
|
|
126
|
+
score = 0.0
|
|
127
|
+
dl = len(doc_tokens)
|
|
128
|
+
|
|
129
|
+
# Term frequency in document
|
|
130
|
+
tf = {}
|
|
131
|
+
for term in doc_tokens:
|
|
132
|
+
tf[term] = tf.get(term, 0) + 1
|
|
133
|
+
|
|
134
|
+
for term in query_tokens:
|
|
135
|
+
if term in tf:
|
|
136
|
+
freq = tf[term]
|
|
137
|
+
idf_score = idf.get(term, 0)
|
|
138
|
+
numerator = freq * (k1 + 1)
|
|
139
|
+
denominator = freq + k1 * (1 - b + b * (dl / avg_dl))
|
|
140
|
+
score += idf_score * (numerator / denominator)
|
|
141
|
+
|
|
142
|
+
return score
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_csv(domain: str) -> Tuple[List[Dict], List[List[str]]]:
|
|
146
|
+
"""載入 CSV 並返回行數據和 token 化文檔"""
|
|
147
|
+
config = CSV_CONFIG.get(domain)
|
|
148
|
+
if not config:
|
|
149
|
+
return [], []
|
|
150
|
+
|
|
151
|
+
csv_path = DATA_DIR / config['file']
|
|
152
|
+
if not csv_path.exists():
|
|
153
|
+
return [], []
|
|
154
|
+
|
|
155
|
+
rows = []
|
|
156
|
+
documents = []
|
|
157
|
+
|
|
158
|
+
with open(csv_path, 'r', encoding='utf-8') as f:
|
|
159
|
+
reader = csv.DictReader(f)
|
|
160
|
+
for row in reader:
|
|
161
|
+
rows.append(row)
|
|
162
|
+
|
|
163
|
+
# 組合搜索欄位
|
|
164
|
+
search_text = ' '.join(
|
|
165
|
+
str(row.get(col, '')) for col in config['search_cols']
|
|
166
|
+
)
|
|
167
|
+
documents.append(tokenize(search_text))
|
|
168
|
+
|
|
169
|
+
return rows, documents
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def detect_domain(query: str) -> str:
|
|
173
|
+
"""自動偵測查詢應該屬於哪個域"""
|
|
174
|
+
query_lower = query.lower()
|
|
175
|
+
scores = {}
|
|
176
|
+
|
|
177
|
+
for domain, keywords in DOMAIN_KEYWORDS.items():
|
|
178
|
+
score = sum(1 for kw in keywords if kw in query_lower)
|
|
179
|
+
scores[domain] = score
|
|
180
|
+
|
|
181
|
+
# 返回最高分的域,如果都是 0 則返回 'provider'
|
|
182
|
+
max_score = max(scores.values())
|
|
183
|
+
if max_score == 0:
|
|
184
|
+
return 'provider'
|
|
185
|
+
|
|
186
|
+
return max(scores, key=scores.get)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def search(
|
|
190
|
+
query: str,
|
|
191
|
+
domain: Optional[str] = None,
|
|
192
|
+
max_results: int = 5
|
|
193
|
+
) -> List[Dict]:
|
|
194
|
+
"""
|
|
195
|
+
主搜索函數
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
query: 搜索查詢
|
|
199
|
+
domain: 搜索域 (None 表示自動偵測)
|
|
200
|
+
max_results: 最大結果數
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
結果列表 (按分數排序)
|
|
204
|
+
"""
|
|
205
|
+
if not query:
|
|
206
|
+
return []
|
|
207
|
+
|
|
208
|
+
# 自動偵測域
|
|
209
|
+
if domain is None:
|
|
210
|
+
domain = detect_domain(query)
|
|
211
|
+
|
|
212
|
+
# 載入數據
|
|
213
|
+
rows, documents = load_csv(domain)
|
|
214
|
+
if not rows:
|
|
215
|
+
return []
|
|
216
|
+
|
|
217
|
+
# 計算 IDF
|
|
218
|
+
idf = compute_idf(documents)
|
|
219
|
+
avg_dl = sum(len(doc) for doc in documents) / len(documents)
|
|
220
|
+
|
|
221
|
+
# Query tokens
|
|
222
|
+
query_tokens = tokenize(query)
|
|
223
|
+
|
|
224
|
+
# 計算每個文檔的分數
|
|
225
|
+
scores = []
|
|
226
|
+
for i, doc_tokens in enumerate(documents):
|
|
227
|
+
score = bm25_score(query_tokens, doc_tokens, idf, avg_dl)
|
|
228
|
+
if score > 0:
|
|
229
|
+
scores.append((score, i))
|
|
230
|
+
|
|
231
|
+
# 排序並返回結果
|
|
232
|
+
scores.sort(reverse=True)
|
|
233
|
+
|
|
234
|
+
config = CSV_CONFIG[domain]
|
|
235
|
+
results = []
|
|
236
|
+
|
|
237
|
+
for score, idx in scores[:max_results]:
|
|
238
|
+
row = rows[idx]
|
|
239
|
+
result = {col: row.get(col, '') for col in config['output_cols']}
|
|
240
|
+
result['_score'] = round(score, 2)
|
|
241
|
+
result['_domain'] = domain
|
|
242
|
+
results.append(result)
|
|
243
|
+
|
|
244
|
+
return results
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def search_all(query: str, max_per_domain: int = 3) -> Dict[str, List]:
|
|
248
|
+
"""全域搜索 (搜索所有域)"""
|
|
249
|
+
all_results = {}
|
|
250
|
+
|
|
251
|
+
for domain in CSV_CONFIG.keys():
|
|
252
|
+
results = search(query, domain=domain, max_results=max_per_domain)
|
|
253
|
+
if results:
|
|
254
|
+
all_results[domain] = results
|
|
255
|
+
|
|
256
|
+
return all_results
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == '__main__':
|
|
260
|
+
# 測試
|
|
261
|
+
import sys
|
|
262
|
+
|
|
263
|
+
if len(sys.argv) < 2:
|
|
264
|
+
print("用法: python core.py <query> [domain]")
|
|
265
|
+
print(f"可用域: {', '.join(CSV_CONFIG.keys())}")
|
|
266
|
+
sys.exit(1)
|
|
267
|
+
|
|
268
|
+
query = sys.argv[1]
|
|
269
|
+
domain = sys.argv[2] if len(sys.argv) > 2 else None
|
|
270
|
+
|
|
271
|
+
if domain == 'all':
|
|
272
|
+
results = search_all(query)
|
|
273
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
274
|
+
else:
|
|
275
|
+
results = search(query, domain=domain)
|
|
276
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Taiwan Logistics 搜索命令行工具
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python search.py "7-11" # 自動偵測域
|
|
7
|
+
python search.py "建立訂單" --domain operation # 指定域
|
|
8
|
+
python search.py "配送狀態" --format json # JSON 輸出
|
|
9
|
+
python search.py "NewebPay" --max 10 # 限制結果數量
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# Add parent directory to path
|
|
18
|
+
SCRIPT_DIR = Path(__file__).parent
|
|
19
|
+
sys.path.insert(0, str(SCRIPT_DIR))
|
|
20
|
+
|
|
21
|
+
from core import search, search_all, detect_domain, CSV_CONFIG
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def format_text(results: list) -> str:
|
|
25
|
+
"""格式化為文本輸出"""
|
|
26
|
+
if not results:
|
|
27
|
+
return "沒有找到結果"
|
|
28
|
+
|
|
29
|
+
output = []
|
|
30
|
+
for i, result in enumerate(results, 1):
|
|
31
|
+
domain = result.pop('_domain', 'unknown')
|
|
32
|
+
score = result.pop('_score', 0)
|
|
33
|
+
|
|
34
|
+
output.append(f"\n[{i}] 分數: {score:.2f} | 域: {domain}")
|
|
35
|
+
output.append("-" * 60)
|
|
36
|
+
|
|
37
|
+
for key, value in result.items():
|
|
38
|
+
if value:
|
|
39
|
+
output.append(f" {key}: {value}")
|
|
40
|
+
|
|
41
|
+
return "\n".join(output)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_json(results: list) -> str:
|
|
45
|
+
"""格式化為 JSON 輸出"""
|
|
46
|
+
return json.dumps(results, ensure_ascii=False, indent=2)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main():
|
|
50
|
+
parser = argparse.ArgumentParser(
|
|
51
|
+
description='Taiwan Logistics 搜索工具',
|
|
52
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
53
|
+
epilog="""
|
|
54
|
+
範例:
|
|
55
|
+
%(prog)s "7-11 取貨" # 搜索 7-11 相關
|
|
56
|
+
%(prog)s "NewebPay" --domain provider # 搜索服務商
|
|
57
|
+
%(prog)s "建立訂單" --domain operation # 搜索 API 操作
|
|
58
|
+
%(prog)s "配送中" --domain status # 搜索配送狀態
|
|
59
|
+
%(prog)s "重量" --domain field # 搜索欄位說明
|
|
60
|
+
%(prog)s "黑貓" --format json # JSON 輸出
|
|
61
|
+
|
|
62
|
+
可用域 (domains):
|
|
63
|
+
provider - 物流服務商 (ECPay, NewebPay, PAYUNi)
|
|
64
|
+
operation - API 操作 (建立、查詢、列印)
|
|
65
|
+
logistics_type - 物流類型 (7-11, 全家, 黑貓)
|
|
66
|
+
field - 欄位對照表
|
|
67
|
+
status - 配送狀態碼
|
|
68
|
+
all - 全域搜索
|
|
69
|
+
"""
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
'query',
|
|
74
|
+
help='搜索關鍵字'
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
'--domain', '-d',
|
|
78
|
+
choices=list(CSV_CONFIG.keys()) + ['all'],
|
|
79
|
+
help='搜索域 (不指定則自動偵測)'
|
|
80
|
+
)
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
'--max', '-m',
|
|
83
|
+
type=int,
|
|
84
|
+
default=5,
|
|
85
|
+
metavar='N',
|
|
86
|
+
help='最大結果數量 (預設: 5)'
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
'--format', '-f',
|
|
90
|
+
choices=['text', 'json'],
|
|
91
|
+
default='text',
|
|
92
|
+
help='輸出格式 (預設: text)'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
args = parser.parse_args()
|
|
96
|
+
|
|
97
|
+
# 執行搜索
|
|
98
|
+
if args.domain == 'all':
|
|
99
|
+
results = search_all(args.query, max_per_domain=args.max)
|
|
100
|
+
|
|
101
|
+
if args.format == 'json':
|
|
102
|
+
print(format_json(results))
|
|
103
|
+
else:
|
|
104
|
+
print(f"\n搜索查詢: '{args.query}'")
|
|
105
|
+
print("=" * 60)
|
|
106
|
+
for domain, domain_results in results.items():
|
|
107
|
+
print(f"\n[域: {domain}]")
|
|
108
|
+
print(format_text(domain_results))
|
|
109
|
+
else:
|
|
110
|
+
# 自動偵測或指定域
|
|
111
|
+
domain = args.domain
|
|
112
|
+
if domain is None:
|
|
113
|
+
domain = detect_domain(args.query)
|
|
114
|
+
print(f"自動偵測域: {domain}\n", file=sys.stderr)
|
|
115
|
+
|
|
116
|
+
results = search(args.query, domain=domain, max_results=args.max)
|
|
117
|
+
|
|
118
|
+
if args.format == 'json':
|
|
119
|
+
print(format_json(results))
|
|
120
|
+
else:
|
|
121
|
+
print(f"搜索查詢: '{args.query}' (域: {domain})")
|
|
122
|
+
print("=" * 60)
|
|
123
|
+
print(format_text(results))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == '__main__':
|
|
127
|
+
main()
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ECPay Logistics API 連線測試腳本
|
|
4
|
+
|
|
5
|
+
用法:
|
|
6
|
+
python test_logistics.py # 測試連線
|
|
7
|
+
python test_logistics.py --create # 建立測試訂單
|
|
8
|
+
python test_logistics.py --query ORDER123 # 查詢訂單
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import time
|
|
14
|
+
import sys
|
|
15
|
+
import argparse
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import requests
|
|
20
|
+
HAS_REQUESTS = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
HAS_REQUESTS = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ECPay 測試環境設定
|
|
26
|
+
TEST_CONFIG = {
|
|
27
|
+
'merchant_id': '2000132',
|
|
28
|
+
'hash_key': '5294y06JbISpM5x9',
|
|
29
|
+
'hash_iv': 'v77hoKGq4kWxNNIS',
|
|
30
|
+
'create_url': 'https://logistics-stage.ecpay.com.tw/Express/Create',
|
|
31
|
+
'query_url': 'https://logistics-stage.ecpay.com.tw/Helper/QueryLogisticsTradeInfo/V2',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def generate_check_mac_value(params: dict, hash_key: str, hash_iv: str) -> str:
|
|
36
|
+
"""計算 CheckMacValue (MD5)"""
|
|
37
|
+
sorted_params = sorted(params.items())
|
|
38
|
+
param_str = '&'.join(f'{k}={v}' for k, v in sorted_params)
|
|
39
|
+
raw = f'HashKey={hash_key}&{param_str}&HashIV={hash_iv}'
|
|
40
|
+
encoded = urllib.parse.quote_plus(raw).lower()
|
|
41
|
+
return hashlib.md5(encoded.encode('utf-8')).hexdigest().upper()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_connection():
|
|
45
|
+
"""測試 ECPay Logistics API 連線"""
|
|
46
|
+
print('=' * 60)
|
|
47
|
+
print('ECPay Logistics API 連線測試')
|
|
48
|
+
print('=' * 60)
|
|
49
|
+
print()
|
|
50
|
+
|
|
51
|
+
# 測試環境資訊
|
|
52
|
+
print('[測試環境]')
|
|
53
|
+
print(f' 商店代號: {TEST_CONFIG["merchant_id"]}')
|
|
54
|
+
print(f' HashKey: {TEST_CONFIG["hash_key"]}')
|
|
55
|
+
print(f' HashIV: {TEST_CONFIG["hash_iv"]}')
|
|
56
|
+
print()
|
|
57
|
+
|
|
58
|
+
# 測試 CheckMacValue 計算
|
|
59
|
+
print('[CheckMacValue 計算測試]')
|
|
60
|
+
test_params = {
|
|
61
|
+
'MerchantID': TEST_CONFIG['merchant_id'],
|
|
62
|
+
'MerchantTradeNo': 'TEST123456',
|
|
63
|
+
'LogisticsType': 'CVS',
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
mac = generate_check_mac_value(
|
|
67
|
+
test_params,
|
|
68
|
+
TEST_CONFIG['hash_key'],
|
|
69
|
+
TEST_CONFIG['hash_iv']
|
|
70
|
+
)
|
|
71
|
+
print(f' 測試參數: {test_params}')
|
|
72
|
+
print(f' CheckMacValue: {mac}')
|
|
73
|
+
print(f' 長度: {len(mac)} (應為 32, MD5)')
|
|
74
|
+
print(f' 格式: {"正確" if len(mac) == 32 and mac.isupper() else "錯誤"}')
|
|
75
|
+
print()
|
|
76
|
+
|
|
77
|
+
# 測試網路連線
|
|
78
|
+
if HAS_REQUESTS:
|
|
79
|
+
print('[網路連線測試]')
|
|
80
|
+
try:
|
|
81
|
+
response = requests.head(
|
|
82
|
+
'https://logistics-stage.ecpay.com.tw/',
|
|
83
|
+
timeout=5
|
|
84
|
+
)
|
|
85
|
+
print(f' 狀態碼: {response.status_code}')
|
|
86
|
+
print(f' 連線: {"成功" if response.status_code < 500 else "失敗"}')
|
|
87
|
+
except requests.RequestException as e:
|
|
88
|
+
print(f' 連線失敗: {e}')
|
|
89
|
+
else:
|
|
90
|
+
print('[網路連線測試]')
|
|
91
|
+
print(' (需要 requests 套件: pip install requests)')
|
|
92
|
+
print()
|
|
93
|
+
|
|
94
|
+
print('=' * 60)
|
|
95
|
+
print('測試完成')
|
|
96
|
+
print('=' * 60)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def create_test_order():
|
|
100
|
+
"""建立測試物流訂單"""
|
|
101
|
+
if not HAS_REQUESTS:
|
|
102
|
+
print('錯誤: 需要 requests 套件')
|
|
103
|
+
print('請執行: pip install requests')
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
|
|
106
|
+
order_id = f'TEST{int(time.time())}'
|
|
107
|
+
trade_date = datetime.now().strftime('%Y/%m/%d %H:%M:%S')
|
|
108
|
+
|
|
109
|
+
params = {
|
|
110
|
+
'MerchantID': TEST_CONFIG['merchant_id'],
|
|
111
|
+
'MerchantTradeNo': order_id,
|
|
112
|
+
'MerchantTradeDate': trade_date,
|
|
113
|
+
'LogisticsType': 'CVS',
|
|
114
|
+
'LogisticsSubType': 'UNIMART',
|
|
115
|
+
'GoodsAmount': 500,
|
|
116
|
+
'GoodsName': '測試商品',
|
|
117
|
+
'SenderName': '測試寄件人',
|
|
118
|
+
'SenderPhone': '0912345678',
|
|
119
|
+
'ReceiverName': '測試收件人',
|
|
120
|
+
'ReceiverPhone': '0987654321',
|
|
121
|
+
'ReceiverStoreID': '131386', # 7-11 測試門市
|
|
122
|
+
'IsCollection': 'N',
|
|
123
|
+
'ServerReplyURL': 'https://example.com/callback',
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
params['CheckMacValue'] = generate_check_mac_value(
|
|
127
|
+
params,
|
|
128
|
+
TEST_CONFIG['hash_key'],
|
|
129
|
+
TEST_CONFIG['hash_iv']
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
print('=' * 60)
|
|
133
|
+
print('建立測試物流訂單')
|
|
134
|
+
print('=' * 60)
|
|
135
|
+
print()
|
|
136
|
+
print(f'訂單編號: {order_id}')
|
|
137
|
+
print(f'物流類型: 超商取貨 (7-11)')
|
|
138
|
+
print(f'收件門市: 131386')
|
|
139
|
+
print()
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
response = requests.post(
|
|
143
|
+
TEST_CONFIG['create_url'],
|
|
144
|
+
data=params,
|
|
145
|
+
timeout=30
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
print('回應:')
|
|
149
|
+
result = {}
|
|
150
|
+
for item in response.text.split('&'):
|
|
151
|
+
if '=' in item:
|
|
152
|
+
key, value = item.split('=', 1)
|
|
153
|
+
result[key] = urllib.parse.unquote(value)
|
|
154
|
+
print(f' {key}: {result[key]}')
|
|
155
|
+
|
|
156
|
+
if result.get('RtnCode') == '300':
|
|
157
|
+
print()
|
|
158
|
+
print('訂單建立成功!')
|
|
159
|
+
else:
|
|
160
|
+
print()
|
|
161
|
+
print(f'訂單建立失敗: {result.get("RtnMsg", "未知錯誤")}')
|
|
162
|
+
|
|
163
|
+
except requests.RequestException as e:
|
|
164
|
+
print(f'請求失敗: {e}')
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def query_order(order_id: str):
|
|
168
|
+
"""查詢物流訂單狀態"""
|
|
169
|
+
if not HAS_REQUESTS:
|
|
170
|
+
print('錯誤: 需要 requests 套件')
|
|
171
|
+
print('請執行: pip install requests')
|
|
172
|
+
sys.exit(1)
|
|
173
|
+
|
|
174
|
+
params = {
|
|
175
|
+
'MerchantID': TEST_CONFIG['merchant_id'],
|
|
176
|
+
'AllPayLogisticsID': '',
|
|
177
|
+
'MerchantTradeNo': order_id,
|
|
178
|
+
'TimeStamp': int(time.time()),
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
params['CheckMacValue'] = generate_check_mac_value(
|
|
182
|
+
params,
|
|
183
|
+
TEST_CONFIG['hash_key'],
|
|
184
|
+
TEST_CONFIG['hash_iv']
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
print('=' * 60)
|
|
188
|
+
print(f'查詢訂單: {order_id}')
|
|
189
|
+
print('=' * 60)
|
|
190
|
+
print()
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
response = requests.post(
|
|
194
|
+
TEST_CONFIG['query_url'],
|
|
195
|
+
data=params,
|
|
196
|
+
timeout=10
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
print('回應:')
|
|
200
|
+
for item in response.text.split('&'):
|
|
201
|
+
if '=' in item:
|
|
202
|
+
key, value = item.split('=', 1)
|
|
203
|
+
print(f' {key}: {urllib.parse.unquote(value)}')
|
|
204
|
+
|
|
205
|
+
except requests.RequestException as e:
|
|
206
|
+
print(f'查詢失敗: {e}')
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main():
|
|
210
|
+
parser = argparse.ArgumentParser(
|
|
211
|
+
description='ECPay Logistics API 測試工具'
|
|
212
|
+
)
|
|
213
|
+
parser.add_argument(
|
|
214
|
+
'--create',
|
|
215
|
+
action='store_true',
|
|
216
|
+
help='建立測試訂單'
|
|
217
|
+
)
|
|
218
|
+
parser.add_argument(
|
|
219
|
+
'--query',
|
|
220
|
+
type=str,
|
|
221
|
+
metavar='ORDER_ID',
|
|
222
|
+
help='查詢訂單狀態'
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
args = parser.parse_args()
|
|
226
|
+
|
|
227
|
+
if args.create:
|
|
228
|
+
create_test_order()
|
|
229
|
+
elif args.query:
|
|
230
|
+
query_order(args.query)
|
|
231
|
+
else:
|
|
232
|
+
test_connection()
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
if __name__ == '__main__':
|
|
236
|
+
main()
|