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,605 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
PAYUNi 統一物流 CVS 超商物流 Python 完整範例
|
|
4
|
+
|
|
5
|
+
依照 taiwan-logistics-skill 最高規範撰寫
|
|
6
|
+
支援: 7-11 C2C (常溫/冷凍)、7-11 B2C、T-Cat 宅配 (常溫/冷凍/冷藏)
|
|
7
|
+
|
|
8
|
+
API 文件: https://www.payuni.com.tw
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import urllib.parse
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Dict, Literal, Optional
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
from Crypto.Cipher import AES
|
|
20
|
+
import requests
|
|
21
|
+
HAS_DEPENDENCIES = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
HAS_DEPENDENCIES = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class CVS711ShipmentData:
|
|
28
|
+
"""7-11 C2C 物流訂單資料"""
|
|
29
|
+
mer_trade_no: str
|
|
30
|
+
goods_type: Literal[1, 2] # 1=常溫, 2=冷凍
|
|
31
|
+
goods_amount: int
|
|
32
|
+
goods_name: str
|
|
33
|
+
sender_name: str
|
|
34
|
+
sender_phone: str
|
|
35
|
+
sender_store_id: str
|
|
36
|
+
receiver_name: str
|
|
37
|
+
receiver_phone: str
|
|
38
|
+
receiver_store_id: str
|
|
39
|
+
notify_url: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class TCatShipmentData:
|
|
44
|
+
"""T-Cat 宅配物流訂單資料"""
|
|
45
|
+
mer_trade_no: str
|
|
46
|
+
goods_type: Literal[1, 2, 3] # 1=常溫, 2=冷凍, 3=冷藏
|
|
47
|
+
goods_amount: int
|
|
48
|
+
goods_name: str
|
|
49
|
+
goods_weight: Optional[int] = None # 重量 (克)
|
|
50
|
+
sender_name: str = ''
|
|
51
|
+
sender_phone: str = ''
|
|
52
|
+
sender_zip_code: str = ''
|
|
53
|
+
sender_address: str = ''
|
|
54
|
+
receiver_name: str = ''
|
|
55
|
+
receiver_phone: str = ''
|
|
56
|
+
receiver_zip_code: str = ''
|
|
57
|
+
receiver_address: str = ''
|
|
58
|
+
scheduled_delivery_time: Optional[Literal['01', '02', '03']] = None # 01=13前, 02=14-18, 03=不指定
|
|
59
|
+
notify_url: str = ''
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class ShipmentResponse:
|
|
64
|
+
"""物流訂單回應"""
|
|
65
|
+
success: bool
|
|
66
|
+
status: str
|
|
67
|
+
message: str
|
|
68
|
+
mer_trade_no: str
|
|
69
|
+
logistics_id: Optional[str] = None
|
|
70
|
+
cvs_payment_no: Optional[str] = None
|
|
71
|
+
cvs_validation_no: Optional[str] = None
|
|
72
|
+
expire_date: Optional[str] = None
|
|
73
|
+
shipment_no: Optional[str] = None
|
|
74
|
+
booking_note: Optional[str] = None
|
|
75
|
+
raw: Dict = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class QueryShipmentData:
|
|
80
|
+
"""查詢物流狀態資料"""
|
|
81
|
+
mer_trade_no: str
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class QueryShipmentResponse:
|
|
86
|
+
"""查詢物流狀態回應"""
|
|
87
|
+
success: bool
|
|
88
|
+
logistics_id: str
|
|
89
|
+
mer_trade_no: str
|
|
90
|
+
logistics_type: str
|
|
91
|
+
logistics_status: str
|
|
92
|
+
logistics_status_msg: str
|
|
93
|
+
shipment_no: Optional[str] = None
|
|
94
|
+
receiver_store_id: Optional[str] = None
|
|
95
|
+
update_time: Optional[str] = None
|
|
96
|
+
raw: Dict = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PAYUNiLogistics:
|
|
100
|
+
"""
|
|
101
|
+
PAYUNi 統一物流服務
|
|
102
|
+
|
|
103
|
+
認證方式: AES-256-GCM + SHA256
|
|
104
|
+
加密方式: AES-GCM 加密 + SHA256 驗證
|
|
105
|
+
|
|
106
|
+
支援物流類型:
|
|
107
|
+
- PAYUNi_Logistic_711: 7-11 C2C (常溫)
|
|
108
|
+
- PAYUNi_Logistic_711_Freeze: 7-11 C2C (冷凍)
|
|
109
|
+
- PAYUNi_Logistic_711_B2C: 7-11 B2C (大宗寄倉)
|
|
110
|
+
- PAYUNi_Logistic_Tcat: T-Cat 宅配 (常溫)
|
|
111
|
+
- PAYUNi_Logistic_Tcat_Freeze: T-Cat 冷凍
|
|
112
|
+
- PAYUNi_Logistic_Tcat_Cold: T-Cat 冷藏
|
|
113
|
+
|
|
114
|
+
溫度類型:
|
|
115
|
+
- 1: 常溫
|
|
116
|
+
- 2: 冷凍
|
|
117
|
+
- 3: 冷藏 (僅 T-Cat)
|
|
118
|
+
|
|
119
|
+
尺寸重量限制:
|
|
120
|
+
- 常溫: 150cm 材積, 20kg
|
|
121
|
+
- 冷凍/冷藏: 120cm 材積, 15kg
|
|
122
|
+
- 材積計算: 長 + 寬 + 高 ≤ 限制
|
|
123
|
+
|
|
124
|
+
測試環境:
|
|
125
|
+
- API URL: https://sandbox-api.payuni.com.tw/api
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
# 測試環境
|
|
129
|
+
TEST_API_URL = 'https://sandbox-api.payuni.com.tw/api'
|
|
130
|
+
|
|
131
|
+
# 正式環境
|
|
132
|
+
PROD_API_URL = 'https://api.payuni.com.tw/api'
|
|
133
|
+
|
|
134
|
+
def __init__(
|
|
135
|
+
self,
|
|
136
|
+
mer_id: str,
|
|
137
|
+
hash_key: str,
|
|
138
|
+
hash_iv: str,
|
|
139
|
+
is_production: bool = False
|
|
140
|
+
):
|
|
141
|
+
"""
|
|
142
|
+
初始化 PAYUNi 物流服務
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
mer_id: 商店代號
|
|
146
|
+
hash_key: HashKey
|
|
147
|
+
hash_iv: HashIV (16 bytes)
|
|
148
|
+
is_production: 是否為正式環境 (預設 False)
|
|
149
|
+
|
|
150
|
+
Raises:
|
|
151
|
+
ImportError: 缺少必要套件
|
|
152
|
+
"""
|
|
153
|
+
if not HAS_DEPENDENCIES:
|
|
154
|
+
raise ImportError(
|
|
155
|
+
'需要安裝必要套件:\n'
|
|
156
|
+
' pip install pycryptodome requests'
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
self.mer_id = mer_id
|
|
160
|
+
self.hash_key = hash_key.encode('utf-8')
|
|
161
|
+
self.hash_iv = hash_iv.encode('utf-8')
|
|
162
|
+
self.base_url = self.PROD_API_URL if is_production else self.TEST_API_URL
|
|
163
|
+
|
|
164
|
+
def encrypt_data(self, data: Dict[str, any]) -> str:
|
|
165
|
+
"""
|
|
166
|
+
加密資料 (AES-256-GCM)
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
data: 交易資料字典
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
str: AES-GCM 加密後的 hex 字串 (含 auth tag)
|
|
173
|
+
"""
|
|
174
|
+
# 轉換為查詢字串
|
|
175
|
+
query_string = urllib.parse.urlencode(data)
|
|
176
|
+
|
|
177
|
+
# AES-256-GCM 加密
|
|
178
|
+
cipher = AES.new(self.hash_key, AES.MODE_GCM, nonce=self.hash_iv)
|
|
179
|
+
encrypted, tag = cipher.encrypt_and_digest(query_string.encode('utf-8'))
|
|
180
|
+
|
|
181
|
+
# 組合加密資料和 tag,轉換為 hex
|
|
182
|
+
return (encrypted + tag).hex()
|
|
183
|
+
|
|
184
|
+
def decrypt_data(self, encrypted_data: str) -> Dict[str, any]:
|
|
185
|
+
"""
|
|
186
|
+
解密資料 (AES-256-GCM)
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
encrypted_data: AES-GCM 加密的 hex 字串
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
Dict: 解密後的資料字典
|
|
193
|
+
|
|
194
|
+
Raises:
|
|
195
|
+
ValueError: 解密失敗或驗證失敗
|
|
196
|
+
"""
|
|
197
|
+
try:
|
|
198
|
+
# hex 轉 bytes
|
|
199
|
+
data = bytes.fromhex(encrypted_data)
|
|
200
|
+
|
|
201
|
+
# 分離加密資料和 tag (最後 16 bytes)
|
|
202
|
+
encrypted = data[:-16]
|
|
203
|
+
tag = data[-16:]
|
|
204
|
+
|
|
205
|
+
# AES-256-GCM 解密並驗證
|
|
206
|
+
decipher = AES.new(self.hash_key, AES.MODE_GCM, nonce=self.hash_iv)
|
|
207
|
+
decrypted = decipher.decrypt_and_verify(encrypted, tag)
|
|
208
|
+
|
|
209
|
+
# 解析查詢字串
|
|
210
|
+
query_string = decrypted.decode('utf-8')
|
|
211
|
+
params = urllib.parse.parse_qs(query_string)
|
|
212
|
+
|
|
213
|
+
# 轉換為單值字典
|
|
214
|
+
result = {k: v[0] if len(v) == 1 else v for k, v in params.items()}
|
|
215
|
+
return result
|
|
216
|
+
except Exception as e:
|
|
217
|
+
raise ValueError(f'解密失敗: {str(e)}')
|
|
218
|
+
|
|
219
|
+
def generate_hash_info(self, encrypt_info: str) -> str:
|
|
220
|
+
"""
|
|
221
|
+
產生 HashInfo (SHA256)
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
encrypt_info: 加密後的資料
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
str: SHA256 雜湊值 (大寫)
|
|
228
|
+
"""
|
|
229
|
+
raw = encrypt_info + self.hash_key.decode('utf-8') + self.hash_iv.decode('utf-8')
|
|
230
|
+
return hashlib.sha256(raw.encode('utf-8')).hexdigest().upper()
|
|
231
|
+
|
|
232
|
+
def verify_hash_info(self, encrypt_info: str, hash_info: str) -> bool:
|
|
233
|
+
"""驗證 HashInfo"""
|
|
234
|
+
calculated_hash = self.generate_hash_info(encrypt_info)
|
|
235
|
+
return calculated_hash == hash_info.upper()
|
|
236
|
+
|
|
237
|
+
def create_711_shipment(
|
|
238
|
+
self,
|
|
239
|
+
data: CVS711ShipmentData,
|
|
240
|
+
) -> ShipmentResponse:
|
|
241
|
+
"""
|
|
242
|
+
建立 7-11 C2C 物流訂單
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
data: 7-11 C2C 物流訂單資料
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
ShipmentResponse: 物流訂單回應
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ValueError: 參數驗證失敗
|
|
252
|
+
Exception: API 請求失敗
|
|
253
|
+
|
|
254
|
+
Example:
|
|
255
|
+
>>> shipment_data = CVS711ShipmentData(
|
|
256
|
+
... mer_trade_no='LOG123',
|
|
257
|
+
... goods_type=1,
|
|
258
|
+
... goods_amount=500,
|
|
259
|
+
... goods_name='T-shirt',
|
|
260
|
+
... sender_name='Sender',
|
|
261
|
+
... sender_phone='0912345678',
|
|
262
|
+
... sender_store_id='123456',
|
|
263
|
+
... receiver_name='Receiver',
|
|
264
|
+
... receiver_phone='0987654321',
|
|
265
|
+
... receiver_store_id='654321',
|
|
266
|
+
... notify_url='https://your-site.com/notify',
|
|
267
|
+
... )
|
|
268
|
+
>>> result = service.create_711_shipment(shipment_data)
|
|
269
|
+
>>> print(result.logistics_id)
|
|
270
|
+
"""
|
|
271
|
+
# 決定物流類型
|
|
272
|
+
logistics_type = 'PAYUNi_Logistic_711_Freeze' if data.goods_type == 2 else 'PAYUNi_Logistic_711'
|
|
273
|
+
|
|
274
|
+
# 準備加密資料
|
|
275
|
+
encrypt_data_obj = {
|
|
276
|
+
'MerID': self.mer_id,
|
|
277
|
+
'MerTradeNo': data.mer_trade_no,
|
|
278
|
+
'LogisticsType': logistics_type,
|
|
279
|
+
'GoodsType': data.goods_type,
|
|
280
|
+
'GoodsAmount': data.goods_amount,
|
|
281
|
+
'GoodsName': data.goods_name,
|
|
282
|
+
'SenderName': data.sender_name,
|
|
283
|
+
'SenderPhone': data.sender_phone,
|
|
284
|
+
'SenderStoreID': data.sender_store_id,
|
|
285
|
+
'ReceiverName': data.receiver_name,
|
|
286
|
+
'ReceiverPhone': data.receiver_phone,
|
|
287
|
+
'ReceiverStoreID': data.receiver_store_id,
|
|
288
|
+
'NotifyURL': data.notify_url,
|
|
289
|
+
'Timestamp': int(time.time()),
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# 加密資料
|
|
293
|
+
encrypt_info = self.encrypt_data(encrypt_data_obj)
|
|
294
|
+
hash_info = self.generate_hash_info(encrypt_info)
|
|
295
|
+
|
|
296
|
+
# 準備 API 請求
|
|
297
|
+
api_data = {
|
|
298
|
+
'MerID': self.mer_id,
|
|
299
|
+
'Version': '1.0',
|
|
300
|
+
'EncryptInfo': encrypt_info,
|
|
301
|
+
'HashInfo': hash_info,
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
# 發送 API 請求
|
|
305
|
+
try:
|
|
306
|
+
response = requests.post(
|
|
307
|
+
f'{self.base_url}/logistics/create',
|
|
308
|
+
data=api_data,
|
|
309
|
+
timeout=30,
|
|
310
|
+
)
|
|
311
|
+
response.raise_for_status()
|
|
312
|
+
result = response.json()
|
|
313
|
+
except Exception as e:
|
|
314
|
+
raise Exception(f'API 請求失敗: {str(e)}')
|
|
315
|
+
|
|
316
|
+
# 解析回應
|
|
317
|
+
if result.get('Status') != 'SUCCESS':
|
|
318
|
+
return ShipmentResponse(
|
|
319
|
+
success=False,
|
|
320
|
+
status=result.get('Status', 'ERROR'),
|
|
321
|
+
message=result.get('Message', '未知錯誤'),
|
|
322
|
+
mer_trade_no=data.mer_trade_no,
|
|
323
|
+
raw=result,
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
# 解密回應資料
|
|
327
|
+
response_encrypt_info = result.get('EncryptInfo', '')
|
|
328
|
+
if response_encrypt_info:
|
|
329
|
+
try:
|
|
330
|
+
decrypted = self.decrypt_data(response_encrypt_info)
|
|
331
|
+
except:
|
|
332
|
+
decrypted = {}
|
|
333
|
+
else:
|
|
334
|
+
decrypted = {}
|
|
335
|
+
|
|
336
|
+
return ShipmentResponse(
|
|
337
|
+
success=True,
|
|
338
|
+
status=result.get('Status', ''),
|
|
339
|
+
message=result.get('Message', ''),
|
|
340
|
+
mer_trade_no=data.mer_trade_no,
|
|
341
|
+
logistics_id=decrypted.get('LogisticsID'),
|
|
342
|
+
cvs_payment_no=decrypted.get('CVSPaymentNo'),
|
|
343
|
+
cvs_validation_no=decrypted.get('CVSValidationNo'),
|
|
344
|
+
expire_date=decrypted.get('ExpireDate'),
|
|
345
|
+
raw=result,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
def create_tcat_shipment(
|
|
349
|
+
self,
|
|
350
|
+
data: TCatShipmentData,
|
|
351
|
+
) -> ShipmentResponse:
|
|
352
|
+
"""
|
|
353
|
+
建立 T-Cat 宅配物流訂單
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
data: T-Cat 宅配物流訂單資料
|
|
357
|
+
|
|
358
|
+
Returns:
|
|
359
|
+
ShipmentResponse: 物流訂單回應
|
|
360
|
+
|
|
361
|
+
Example:
|
|
362
|
+
>>> shipment_data = TCatShipmentData(
|
|
363
|
+
... mer_trade_no='TCAT123',
|
|
364
|
+
... goods_type=2, # 冷凍
|
|
365
|
+
... goods_amount=1000,
|
|
366
|
+
... goods_name='Frozen Food',
|
|
367
|
+
... sender_name='Store',
|
|
368
|
+
... sender_phone='0912345678',
|
|
369
|
+
... sender_zip_code='100',
|
|
370
|
+
... sender_address='Taipei XXX',
|
|
371
|
+
... receiver_name='Customer',
|
|
372
|
+
... receiver_phone='0987654321',
|
|
373
|
+
... receiver_zip_code='300',
|
|
374
|
+
... receiver_address='Hsinchu YYY',
|
|
375
|
+
... notify_url='https://your-site.com/notify',
|
|
376
|
+
... )
|
|
377
|
+
>>> result = service.create_tcat_shipment(shipment_data)
|
|
378
|
+
"""
|
|
379
|
+
# 決定物流類型
|
|
380
|
+
if data.goods_type == 2:
|
|
381
|
+
logistics_type = 'PAYUNi_Logistic_Tcat_Freeze'
|
|
382
|
+
elif data.goods_type == 3:
|
|
383
|
+
logistics_type = 'PAYUNi_Logistic_Tcat_Cold'
|
|
384
|
+
else:
|
|
385
|
+
logistics_type = 'PAYUNi_Logistic_Tcat'
|
|
386
|
+
|
|
387
|
+
# 準備加密資料
|
|
388
|
+
encrypt_data_obj = {
|
|
389
|
+
'MerID': self.mer_id,
|
|
390
|
+
'MerTradeNo': data.mer_trade_no,
|
|
391
|
+
'LogisticsType': logistics_type,
|
|
392
|
+
'GoodsType': data.goods_type,
|
|
393
|
+
'GoodsAmount': data.goods_amount,
|
|
394
|
+
'GoodsName': data.goods_name,
|
|
395
|
+
'SenderName': data.sender_name,
|
|
396
|
+
'SenderPhone': data.sender_phone,
|
|
397
|
+
'SenderZipCode': data.sender_zip_code,
|
|
398
|
+
'SenderAddress': data.sender_address,
|
|
399
|
+
'ReceiverName': data.receiver_name,
|
|
400
|
+
'ReceiverPhone': data.receiver_phone,
|
|
401
|
+
'ReceiverZipCode': data.receiver_zip_code,
|
|
402
|
+
'ReceiverAddress': data.receiver_address,
|
|
403
|
+
'NotifyURL': data.notify_url,
|
|
404
|
+
'Timestamp': int(time.time()),
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if data.goods_weight:
|
|
408
|
+
encrypt_data_obj['GoodsWeight'] = data.goods_weight
|
|
409
|
+
if data.scheduled_delivery_time:
|
|
410
|
+
encrypt_data_obj['ScheduledDeliveryTime'] = data.scheduled_delivery_time
|
|
411
|
+
|
|
412
|
+
# 加密資料
|
|
413
|
+
encrypt_info = self.encrypt_data(encrypt_data_obj)
|
|
414
|
+
hash_info = self.generate_hash_info(encrypt_info)
|
|
415
|
+
|
|
416
|
+
# 準備 API 請求
|
|
417
|
+
api_data = {
|
|
418
|
+
'MerID': self.mer_id,
|
|
419
|
+
'Version': '1.0',
|
|
420
|
+
'EncryptInfo': encrypt_info,
|
|
421
|
+
'HashInfo': hash_info,
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
# 發送 API 請求
|
|
425
|
+
try:
|
|
426
|
+
response = requests.post(
|
|
427
|
+
f'{self.base_url}/logistics/create',
|
|
428
|
+
data=api_data,
|
|
429
|
+
timeout=30,
|
|
430
|
+
)
|
|
431
|
+
response.raise_for_status()
|
|
432
|
+
result = response.json()
|
|
433
|
+
except Exception as e:
|
|
434
|
+
raise Exception(f'API 請求失敗: {str(e)}')
|
|
435
|
+
|
|
436
|
+
# 解析回應
|
|
437
|
+
if result.get('Status') != 'SUCCESS':
|
|
438
|
+
return ShipmentResponse(
|
|
439
|
+
success=False,
|
|
440
|
+
status=result.get('Status', 'ERROR'),
|
|
441
|
+
message=result.get('Message', '未知錯誤'),
|
|
442
|
+
mer_trade_no=data.mer_trade_no,
|
|
443
|
+
raw=result,
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
# 解密回應資料
|
|
447
|
+
response_encrypt_info = result.get('EncryptInfo', '')
|
|
448
|
+
if response_encrypt_info:
|
|
449
|
+
decrypted = self.decrypt_data(response_encrypt_info)
|
|
450
|
+
else:
|
|
451
|
+
decrypted = {}
|
|
452
|
+
|
|
453
|
+
return ShipmentResponse(
|
|
454
|
+
success=True,
|
|
455
|
+
status=result.get('Status', ''),
|
|
456
|
+
message=result.get('Message', ''),
|
|
457
|
+
mer_trade_no=data.mer_trade_no,
|
|
458
|
+
logistics_id=decrypted.get('LogisticsID'),
|
|
459
|
+
shipment_no=decrypted.get('ShipmentNo'),
|
|
460
|
+
booking_note=decrypted.get('BookingNote'),
|
|
461
|
+
raw=result,
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
def query_shipment(
|
|
465
|
+
self,
|
|
466
|
+
data: QueryShipmentData,
|
|
467
|
+
) -> QueryShipmentResponse:
|
|
468
|
+
"""
|
|
469
|
+
查詢物流狀態
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
data: 查詢物流狀態資料
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
QueryShipmentResponse: 物流狀態回應
|
|
476
|
+
|
|
477
|
+
Example:
|
|
478
|
+
>>> query_data = QueryShipmentData(mer_trade_no='LOG123')
|
|
479
|
+
>>> result = service.query_shipment(query_data)
|
|
480
|
+
>>> print(f"狀態: {result.logistics_status_msg}")
|
|
481
|
+
"""
|
|
482
|
+
# 準備加密資料
|
|
483
|
+
encrypt_data_obj = {
|
|
484
|
+
'MerID': self.mer_id,
|
|
485
|
+
'MerTradeNo': data.mer_trade_no,
|
|
486
|
+
'Timestamp': int(time.time()),
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
# 加密資料
|
|
490
|
+
encrypt_info = self.encrypt_data(encrypt_data_obj)
|
|
491
|
+
hash_info = self.generate_hash_info(encrypt_info)
|
|
492
|
+
|
|
493
|
+
# 準備 API 請求
|
|
494
|
+
api_data = {
|
|
495
|
+
'MerID': self.mer_id,
|
|
496
|
+
'Version': '1.0',
|
|
497
|
+
'EncryptInfo': encrypt_info,
|
|
498
|
+
'HashInfo': hash_info,
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
# 發送 API 請求
|
|
502
|
+
try:
|
|
503
|
+
response = requests.post(
|
|
504
|
+
f'{self.base_url}/logistics/query',
|
|
505
|
+
data=api_data,
|
|
506
|
+
timeout=30,
|
|
507
|
+
)
|
|
508
|
+
response.raise_for_status()
|
|
509
|
+
result = response.json()
|
|
510
|
+
except Exception as e:
|
|
511
|
+
raise Exception(f'API 請求失敗: {str(e)}')
|
|
512
|
+
|
|
513
|
+
# 解析回應
|
|
514
|
+
if result.get('Status') != 'SUCCESS':
|
|
515
|
+
return QueryShipmentResponse(
|
|
516
|
+
success=False,
|
|
517
|
+
logistics_id='',
|
|
518
|
+
mer_trade_no=data.mer_trade_no,
|
|
519
|
+
logistics_type='',
|
|
520
|
+
logistics_status='',
|
|
521
|
+
logistics_status_msg=result.get('Message', ''),
|
|
522
|
+
raw=result,
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
# 解密回應資料
|
|
526
|
+
response_encrypt_info = result.get('EncryptInfo', '')
|
|
527
|
+
decrypted = self.decrypt_data(response_encrypt_info)
|
|
528
|
+
|
|
529
|
+
return QueryShipmentResponse(
|
|
530
|
+
success=True,
|
|
531
|
+
logistics_id=decrypted.get('LogisticsID', ''),
|
|
532
|
+
mer_trade_no=decrypted.get('MerTradeNo', ''),
|
|
533
|
+
logistics_type=decrypted.get('LogisticsType', ''),
|
|
534
|
+
logistics_status=decrypted.get('LogisticsStatus', ''),
|
|
535
|
+
logistics_status_msg=decrypted.get('LogisticsStatusMsg', ''),
|
|
536
|
+
shipment_no=decrypted.get('ShipmentNo'),
|
|
537
|
+
receiver_store_id=decrypted.get('ReceiverStoreID'),
|
|
538
|
+
update_time=decrypted.get('UpdateTime'),
|
|
539
|
+
raw=decrypted,
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
# Usage Example
|
|
544
|
+
if __name__ == '__main__':
|
|
545
|
+
print('=' * 60)
|
|
546
|
+
print('PAYUNi 統一物流 - Python 範例')
|
|
547
|
+
print('=' * 60)
|
|
548
|
+
print()
|
|
549
|
+
|
|
550
|
+
# 檢查依賴套件
|
|
551
|
+
if not HAS_DEPENDENCIES:
|
|
552
|
+
print('✗ 錯誤: 需要安裝必要套件')
|
|
553
|
+
print(' 請執行: pip install pycryptodome requests')
|
|
554
|
+
exit(1)
|
|
555
|
+
|
|
556
|
+
print('[注意] 請先至 PAYUNi 申請測試帳號')
|
|
557
|
+
print()
|
|
558
|
+
|
|
559
|
+
# 初始化服務
|
|
560
|
+
service = PAYUNiLogistics(
|
|
561
|
+
mer_id='YOUR_MERCHANT_ID',
|
|
562
|
+
hash_key='YOUR_HASH_KEY',
|
|
563
|
+
hash_iv='YOUR_HASH_IV',
|
|
564
|
+
is_production=False,
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# 範例: 建立 7-11 C2C 物流訂單
|
|
568
|
+
print('[範例] 建立 7-11 C2C 物流訂單')
|
|
569
|
+
print('-' * 60)
|
|
570
|
+
|
|
571
|
+
shipment_data = CVS711ShipmentData(
|
|
572
|
+
mer_trade_no=f'LOG{int(time.time())}',
|
|
573
|
+
goods_type=1, # 常溫
|
|
574
|
+
goods_amount=500,
|
|
575
|
+
goods_name='測試商品',
|
|
576
|
+
sender_name='寄件人',
|
|
577
|
+
sender_phone='0912345678',
|
|
578
|
+
sender_store_id='123456',
|
|
579
|
+
receiver_name='收件人',
|
|
580
|
+
receiver_phone='0987654321',
|
|
581
|
+
receiver_store_id='654321',
|
|
582
|
+
notify_url='https://your-site.com/notify',
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
try:
|
|
586
|
+
result = service.create_711_shipment(shipment_data)
|
|
587
|
+
|
|
588
|
+
if result.success:
|
|
589
|
+
print(f'✓ 物流訂單建立成功')
|
|
590
|
+
print(f' 訂單編號: {result.mer_trade_no}')
|
|
591
|
+
print(f' 物流編號: {result.logistics_id}')
|
|
592
|
+
print(f' 寄貨編號: {result.cvs_payment_no}')
|
|
593
|
+
print(f' 驗證碼: {result.cvs_validation_no}')
|
|
594
|
+
print(f' 有效期限: {result.expire_date}')
|
|
595
|
+
else:
|
|
596
|
+
print(f'✗ 物流訂單建立失敗')
|
|
597
|
+
print(f' 狀態: {result.status}')
|
|
598
|
+
print(f' 訊息: {result.message}')
|
|
599
|
+
except Exception as e:
|
|
600
|
+
print(f'✗ 發生例外: {str(e)}')
|
|
601
|
+
|
|
602
|
+
print()
|
|
603
|
+
print('=' * 60)
|
|
604
|
+
print('範例執行完成')
|
|
605
|
+
print('=' * 60)
|