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.
@@ -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)