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,3183 @@
|
|
|
1
|
+
# Taiwan Logistics Code Examples
|
|
2
|
+
|
|
3
|
+
**Production-ready code examples for Taiwan Logistics integration**
|
|
4
|
+
|
|
5
|
+
Supporting NewebPay Logistics, ECPay Logistics, and PAYUNi Logistics with comprehensive TypeScript, Python, and PHP implementations.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of Contents
|
|
10
|
+
|
|
11
|
+
1. [NewebPay Logistics Examples](#newebpay-logistics-examples)
|
|
12
|
+
- [Basic Integration](#1-basic-integration-newebpay)
|
|
13
|
+
- [Store Map Query](#2-store-map-query)
|
|
14
|
+
- [Create Shipment](#3-create-shipment)
|
|
15
|
+
- [Get Shipment Number](#4-get-shipment-number)
|
|
16
|
+
- [Print Label](#5-print-label)
|
|
17
|
+
- [Query Shipment](#6-query-shipment)
|
|
18
|
+
- [Modify Shipment](#7-modify-shipment)
|
|
19
|
+
- [Track Shipment](#8-track-shipment)
|
|
20
|
+
- [Status Notification](#9-status-notification-callback)
|
|
21
|
+
|
|
22
|
+
2. [ECPay Logistics Examples](#ecpay-logistics-examples)
|
|
23
|
+
3. [Real-World Scenarios](#real-world-scenarios)
|
|
24
|
+
4. [Error Handling](#error-handling)
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## NewebPay Logistics Examples
|
|
29
|
+
|
|
30
|
+
### 1. Basic Integration (NewebPay)
|
|
31
|
+
|
|
32
|
+
#### TypeScript - Encryption Helper
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
import crypto from 'crypto';
|
|
36
|
+
|
|
37
|
+
interface NewebPayConfig {
|
|
38
|
+
merchantId: string;
|
|
39
|
+
hashKey: string;
|
|
40
|
+
hashIV: string;
|
|
41
|
+
isProduction?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
class NewebPayLogistics {
|
|
45
|
+
private config: Required<NewebPayConfig>;
|
|
46
|
+
private baseUrl: string;
|
|
47
|
+
|
|
48
|
+
constructor(config: NewebPayConfig) {
|
|
49
|
+
this.config = {
|
|
50
|
+
...config,
|
|
51
|
+
isProduction: config.isProduction ?? false,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
this.baseUrl = this.config.isProduction
|
|
55
|
+
? 'https://core.newebpay.com/API/Logistic'
|
|
56
|
+
: 'https://ccore.newebpay.com/API/Logistic';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* AES-256-CBC Encryption
|
|
61
|
+
*/
|
|
62
|
+
private aesEncrypt(data: string): string {
|
|
63
|
+
const cipher = crypto.createCipheriv(
|
|
64
|
+
'aes-256-cbc',
|
|
65
|
+
this.config.hashKey,
|
|
66
|
+
this.config.hashIV
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
let encrypted = cipher.update(data, 'utf8', 'hex');
|
|
70
|
+
encrypted += cipher.final('hex');
|
|
71
|
+
|
|
72
|
+
return encrypted;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* AES-256-CBC Decryption
|
|
77
|
+
*/
|
|
78
|
+
private aesDecrypt(encryptedData: string): string {
|
|
79
|
+
const decipher = crypto.createDecipheriv(
|
|
80
|
+
'aes-256-cbc',
|
|
81
|
+
this.config.hashKey,
|
|
82
|
+
this.config.hashIV
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
|
|
86
|
+
decrypted += decipher.final('utf8');
|
|
87
|
+
|
|
88
|
+
return decrypted;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Generate Hash Data (SHA256)
|
|
93
|
+
*/
|
|
94
|
+
private generateHashData(encryptData: string): string {
|
|
95
|
+
const raw = `${this.config.hashKey}${encryptData}${this.config.hashIV}`;
|
|
96
|
+
return crypto.createHash('sha256').update(raw).digest('hex').toUpperCase();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Encrypt request data
|
|
101
|
+
*/
|
|
102
|
+
encryptData(data: Record<string, any>): { EncryptData_: string; HashData_: string } {
|
|
103
|
+
const jsonStr = JSON.stringify(data);
|
|
104
|
+
const encryptData = this.aesEncrypt(jsonStr);
|
|
105
|
+
const hashData = this.generateHashData(encryptData);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
EncryptData_: encryptData,
|
|
109
|
+
HashData_: hashData,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Decrypt response data
|
|
115
|
+
*/
|
|
116
|
+
decryptData(encryptData: string, hashData: string): any {
|
|
117
|
+
// Verify hash
|
|
118
|
+
const calculatedHash = this.generateHashData(encryptData);
|
|
119
|
+
if (calculatedHash !== hashData.toUpperCase()) {
|
|
120
|
+
throw new Error('Hash verification failed');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const decrypted = this.aesDecrypt(encryptData);
|
|
124
|
+
return JSON.parse(decrypted);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Get current Unix timestamp
|
|
129
|
+
*/
|
|
130
|
+
getTimestamp(): string {
|
|
131
|
+
return Math.floor(Date.now() / 1000).toString();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export { NewebPayLogistics };
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
#### Python - Encryption Helper
|
|
139
|
+
|
|
140
|
+
```python
|
|
141
|
+
"""NewebPay Logistics Encryption Helper - Python"""
|
|
142
|
+
|
|
143
|
+
import json
|
|
144
|
+
import hashlib
|
|
145
|
+
import time
|
|
146
|
+
from typing import Dict, Any, Tuple
|
|
147
|
+
from Crypto.Cipher import AES
|
|
148
|
+
from Crypto.Util.Padding import pad, unpad
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class NewebPayLogistics:
|
|
152
|
+
"""NewebPay Logistics API Client"""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
merchant_id: str,
|
|
157
|
+
hash_key: str,
|
|
158
|
+
hash_iv: str,
|
|
159
|
+
is_production: bool = False
|
|
160
|
+
):
|
|
161
|
+
self.merchant_id = merchant_id
|
|
162
|
+
self.hash_key = hash_key.encode('utf-8')
|
|
163
|
+
self.hash_iv = hash_iv.encode('utf-8')
|
|
164
|
+
|
|
165
|
+
self.base_url = (
|
|
166
|
+
'https://core.newebpay.com/API/Logistic'
|
|
167
|
+
if is_production
|
|
168
|
+
else 'https://ccore.newebpay.com/API/Logistic'
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def aes_encrypt(self, data: str) -> str:
|
|
172
|
+
"""AES-256-CBC Encryption"""
|
|
173
|
+
cipher = AES.new(self.hash_key, AES.MODE_CBC, self.hash_iv)
|
|
174
|
+
padded_data = pad(data.encode('utf-8'), AES.block_size)
|
|
175
|
+
encrypted = cipher.encrypt(padded_data)
|
|
176
|
+
return encrypted.hex()
|
|
177
|
+
|
|
178
|
+
def aes_decrypt(self, encrypted_data: str) -> str:
|
|
179
|
+
"""AES-256-CBC Decryption"""
|
|
180
|
+
cipher = AES.new(self.hash_key, AES.MODE_CBC, self.hash_iv)
|
|
181
|
+
decrypted = cipher.decrypt(bytes.fromhex(encrypted_data))
|
|
182
|
+
unpadded = unpad(decrypted, AES.block_size)
|
|
183
|
+
return unpadded.decode('utf-8')
|
|
184
|
+
|
|
185
|
+
def generate_hash_data(self, encrypt_data: str) -> str:
|
|
186
|
+
"""Generate Hash Data (SHA256)"""
|
|
187
|
+
raw = f"{self.hash_key.decode('utf-8')}{encrypt_data}{self.hash_iv.decode('utf-8')}"
|
|
188
|
+
return hashlib.sha256(raw.encode('utf-8')).hexdigest().upper()
|
|
189
|
+
|
|
190
|
+
def encrypt_data(self, data: Dict[str, Any]) -> Dict[str, str]:
|
|
191
|
+
"""Encrypt request data"""
|
|
192
|
+
json_str = json.dumps(data, ensure_ascii=False)
|
|
193
|
+
encrypt_data = self.aes_encrypt(json_str)
|
|
194
|
+
hash_data = self.generate_hash_data(encrypt_data)
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
'EncryptData_': encrypt_data,
|
|
198
|
+
'HashData_': hash_data,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def decrypt_data(self, encrypt_data: str, hash_data: str) -> Dict[str, Any]:
|
|
202
|
+
"""Decrypt response data"""
|
|
203
|
+
# Verify hash
|
|
204
|
+
calculated_hash = self.generate_hash_data(encrypt_data)
|
|
205
|
+
if calculated_hash != hash_data.upper():
|
|
206
|
+
raise ValueError('Hash verification failed')
|
|
207
|
+
|
|
208
|
+
decrypted = self.aes_decrypt(encrypt_data)
|
|
209
|
+
return json.loads(decrypted)
|
|
210
|
+
|
|
211
|
+
@staticmethod
|
|
212
|
+
def get_timestamp() -> str:
|
|
213
|
+
"""Get current Unix timestamp"""
|
|
214
|
+
return str(int(time.time()))
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
---
|
|
218
|
+
|
|
219
|
+
### 2. Store Map Query
|
|
220
|
+
|
|
221
|
+
Query convenience store locations for pickup or sender.
|
|
222
|
+
|
|
223
|
+
#### TypeScript Example
|
|
224
|
+
|
|
225
|
+
```typescript
|
|
226
|
+
import axios from 'axios';
|
|
227
|
+
|
|
228
|
+
interface StoreMapRequest {
|
|
229
|
+
merchantOrderNo: string;
|
|
230
|
+
lgsType: 'B2C' | 'C2C';
|
|
231
|
+
shipType: '1' | '2' | '3' | '4'; // 1=7-11, 2=FamilyMart, 3=Hi-Life, 4=OK Mart
|
|
232
|
+
returnURL: string;
|
|
233
|
+
extraData?: string;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
class NewebPayStoreMap extends NewebPayLogistics {
|
|
237
|
+
/**
|
|
238
|
+
* Query store map
|
|
239
|
+
*/
|
|
240
|
+
async queryStoreMap(params: StoreMapRequest): Promise<string> {
|
|
241
|
+
const data = {
|
|
242
|
+
MerchantOrderNo: params.merchantOrderNo,
|
|
243
|
+
LgsType: params.lgsType,
|
|
244
|
+
ShipType: params.shipType,
|
|
245
|
+
ReturnURL: params.returnURL,
|
|
246
|
+
TimeStamp: this.getTimestamp(),
|
|
247
|
+
ExtraData: params.extraData || '',
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
const { EncryptData_, HashData_ } = this.encryptData(data);
|
|
251
|
+
|
|
252
|
+
const requestData = {
|
|
253
|
+
UID_: this.config.merchantId,
|
|
254
|
+
EncryptData_,
|
|
255
|
+
HashData_,
|
|
256
|
+
Version_: '1.0',
|
|
257
|
+
RespondType_: 'JSON',
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const response = await axios.post(
|
|
261
|
+
`${this.baseUrl}/storeMap`,
|
|
262
|
+
new URLSearchParams(requestData as any),
|
|
263
|
+
{
|
|
264
|
+
headers: {
|
|
265
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
266
|
+
},
|
|
267
|
+
}
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
// NewebPay will redirect to store selection page
|
|
271
|
+
// Return the redirect URL or HTML
|
|
272
|
+
return response.data;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Handle store map callback
|
|
277
|
+
*/
|
|
278
|
+
handleStoreMapCallback(
|
|
279
|
+
encryptData: string,
|
|
280
|
+
hashData: string
|
|
281
|
+
): {
|
|
282
|
+
lgsType: string;
|
|
283
|
+
shipType: string;
|
|
284
|
+
merchantOrderNo: string;
|
|
285
|
+
storeName: string;
|
|
286
|
+
storeTel: string;
|
|
287
|
+
storeAddr: string;
|
|
288
|
+
storeID: string;
|
|
289
|
+
extraData: string;
|
|
290
|
+
} {
|
|
291
|
+
const decrypted = this.decryptData(encryptData, hashData);
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
lgsType: decrypted.LgsType,
|
|
295
|
+
shipType: decrypted.ShipType,
|
|
296
|
+
merchantOrderNo: decrypted.MerchantOrderNo,
|
|
297
|
+
storeName: decrypted.StoreName,
|
|
298
|
+
storeTel: decrypted.StoreTel,
|
|
299
|
+
storeAddr: decrypted.StoreAddr,
|
|
300
|
+
storeID: decrypted.StoreID,
|
|
301
|
+
extraData: decrypted.ExtraData,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Usage Example
|
|
307
|
+
const logistics = new NewebPayStoreMap({
|
|
308
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
309
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
310
|
+
hashIV: 'YOUR_HASH_IV',
|
|
311
|
+
isProduction: false,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Open store map
|
|
315
|
+
await logistics.queryStoreMap({
|
|
316
|
+
merchantOrderNo: `ORD${Date.now()}`,
|
|
317
|
+
lgsType: 'C2C',
|
|
318
|
+
shipType: '1', // 7-ELEVEN
|
|
319
|
+
returnURL: 'https://your-site.com/callback/store-map',
|
|
320
|
+
extraData: 'order_id=123',
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
export { NewebPayStoreMap };
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
#### Python Example
|
|
327
|
+
|
|
328
|
+
```python
|
|
329
|
+
"""Store Map Query - Python Example"""
|
|
330
|
+
|
|
331
|
+
import requests
|
|
332
|
+
from typing import Dict, Optional
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
class NewebPayStoreMap(NewebPayLogistics):
|
|
336
|
+
"""Store Map Query Operations"""
|
|
337
|
+
|
|
338
|
+
def query_store_map(
|
|
339
|
+
self,
|
|
340
|
+
merchant_order_no: str,
|
|
341
|
+
lgs_type: str, # 'B2C' or 'C2C'
|
|
342
|
+
ship_type: str, # '1'=7-11, '2'=FamilyMart, '3'=Hi-Life, '4'=OK Mart
|
|
343
|
+
return_url: str,
|
|
344
|
+
extra_data: str = '',
|
|
345
|
+
) -> str:
|
|
346
|
+
"""Query store map"""
|
|
347
|
+
|
|
348
|
+
data = {
|
|
349
|
+
'MerchantOrderNo': merchant_order_no,
|
|
350
|
+
'LgsType': lgs_type,
|
|
351
|
+
'ShipType': ship_type,
|
|
352
|
+
'ReturnURL': return_url,
|
|
353
|
+
'TimeStamp': self.get_timestamp(),
|
|
354
|
+
'ExtraData': extra_data,
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
encrypted = self.encrypt_data(data)
|
|
358
|
+
|
|
359
|
+
request_data = {
|
|
360
|
+
'UID_': self.merchant_id,
|
|
361
|
+
'EncryptData_': encrypted['EncryptData_'],
|
|
362
|
+
'HashData_': encrypted['HashData_'],
|
|
363
|
+
'Version_': '1.0',
|
|
364
|
+
'RespondType_': 'JSON',
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
response = requests.post(
|
|
368
|
+
f'{self.base_url}/storeMap',
|
|
369
|
+
data=request_data,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
return response.text
|
|
373
|
+
|
|
374
|
+
def handle_store_map_callback(
|
|
375
|
+
self,
|
|
376
|
+
encrypt_data: str,
|
|
377
|
+
hash_data: str,
|
|
378
|
+
) -> Dict[str, str]:
|
|
379
|
+
"""Handle store map callback"""
|
|
380
|
+
|
|
381
|
+
decrypted = self.decrypt_data(encrypt_data, hash_data)
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
'lgs_type': decrypted['LgsType'],
|
|
385
|
+
'ship_type': decrypted['ShipType'],
|
|
386
|
+
'merchant_order_no': decrypted['MerchantOrderNo'],
|
|
387
|
+
'store_name': decrypted['StoreName'],
|
|
388
|
+
'store_tel': decrypted['StoreTel'],
|
|
389
|
+
'store_addr': decrypted['StoreAddr'],
|
|
390
|
+
'store_id': decrypted['StoreID'],
|
|
391
|
+
'extra_data': decrypted.get('ExtraData', ''),
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# Usage Example
|
|
396
|
+
logistics = NewebPayStoreMap(
|
|
397
|
+
merchant_id='YOUR_MERCHANT_ID',
|
|
398
|
+
hash_key='YOUR_HASH_KEY',
|
|
399
|
+
hash_iv='YOUR_HASH_IV',
|
|
400
|
+
is_production=False,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Open store map
|
|
404
|
+
html = logistics.query_store_map(
|
|
405
|
+
merchant_order_no=f'ORD{int(time.time())}',
|
|
406
|
+
lgs_type='C2C',
|
|
407
|
+
ship_type='1', # 7-ELEVEN
|
|
408
|
+
return_url='https://your-site.com/callback/store-map',
|
|
409
|
+
extra_data='order_id=123',
|
|
410
|
+
)
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
### 3. Create Shipment
|
|
416
|
+
|
|
417
|
+
Create logistics shipment order.
|
|
418
|
+
|
|
419
|
+
#### TypeScript Example
|
|
420
|
+
|
|
421
|
+
```typescript
|
|
422
|
+
interface CreateShipmentRequest {
|
|
423
|
+
merchantOrderNo: string;
|
|
424
|
+
tradeType: 1 | 3; // 1=COD, 3=No Payment
|
|
425
|
+
userName: string;
|
|
426
|
+
userTel: string;
|
|
427
|
+
userEmail: string;
|
|
428
|
+
storeID: string;
|
|
429
|
+
amt: number;
|
|
430
|
+
itemDesc?: string;
|
|
431
|
+
notifyURL?: string;
|
|
432
|
+
lgsType: 'B2C' | 'C2C';
|
|
433
|
+
shipType: '1' | '2' | '3' | '4';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
class NewebPayShipment extends NewebPayLogistics {
|
|
437
|
+
/**
|
|
438
|
+
* Create shipment order
|
|
439
|
+
*/
|
|
440
|
+
async createShipment(params: CreateShipmentRequest) {
|
|
441
|
+
const data = {
|
|
442
|
+
MerchantOrderNo: params.merchantOrderNo,
|
|
443
|
+
TradeType: params.tradeType,
|
|
444
|
+
UserName: params.userName,
|
|
445
|
+
UserTel: params.userTel,
|
|
446
|
+
UserEmail: params.userEmail,
|
|
447
|
+
StoreID: params.storeID,
|
|
448
|
+
Amt: params.amt,
|
|
449
|
+
NotifyURL: params.notifyURL || '',
|
|
450
|
+
ItemDesc: params.itemDesc || '',
|
|
451
|
+
LgsType: params.lgsType,
|
|
452
|
+
ShipType: params.shipType,
|
|
453
|
+
TimeStamp: this.getTimestamp(),
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const { EncryptData_, HashData_ } = this.encryptData(data);
|
|
457
|
+
|
|
458
|
+
const requestData = {
|
|
459
|
+
UID_: this.config.merchantId,
|
|
460
|
+
EncryptData_,
|
|
461
|
+
HashData_,
|
|
462
|
+
Version_: '1.0',
|
|
463
|
+
RespondType_: 'JSON',
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const response = await axios.post(
|
|
467
|
+
`${this.baseUrl}/createShipment`,
|
|
468
|
+
new URLSearchParams(requestData as any),
|
|
469
|
+
{
|
|
470
|
+
headers: {
|
|
471
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
472
|
+
},
|
|
473
|
+
}
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const result = response.data;
|
|
477
|
+
|
|
478
|
+
if (result.Status !== 'SUCCESS') {
|
|
479
|
+
throw new Error(`Create shipment failed: ${result.Message}`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Decrypt response
|
|
483
|
+
const decrypted = this.decryptData(result.EncryptData, result.HashData);
|
|
484
|
+
|
|
485
|
+
return {
|
|
486
|
+
merchantID: decrypted.MerchantID,
|
|
487
|
+
amt: decrypted.Amt,
|
|
488
|
+
merchantOrderNo: decrypted.MerchantOrderNo,
|
|
489
|
+
tradeNo: decrypted.TradeNo,
|
|
490
|
+
lgsType: decrypted.LgsType,
|
|
491
|
+
shipType: decrypted.ShipType,
|
|
492
|
+
storeID: decrypted.StoreID,
|
|
493
|
+
tradeType: decrypted.TradeType,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Usage Example
|
|
499
|
+
const shipment = new NewebPayShipment({
|
|
500
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
501
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
502
|
+
hashIV: 'YOUR_HASH_IV',
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const result = await shipment.createShipment({
|
|
506
|
+
merchantOrderNo: `ORD${Date.now()}`,
|
|
507
|
+
tradeType: 1, // Cash on Delivery
|
|
508
|
+
userName: 'John Doe',
|
|
509
|
+
userTel: '0912345678',
|
|
510
|
+
userEmail: 'john@example.com',
|
|
511
|
+
storeID: '123456', // From store map query
|
|
512
|
+
amt: 1500,
|
|
513
|
+
itemDesc: 'T-shirt x 2',
|
|
514
|
+
notifyURL: 'https://your-site.com/callback/shipment',
|
|
515
|
+
lgsType: 'C2C',
|
|
516
|
+
shipType: '1', // 7-ELEVEN
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
console.log('Trade No:', result.tradeNo);
|
|
520
|
+
|
|
521
|
+
export { NewebPayShipment };
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
#### Python Example
|
|
525
|
+
|
|
526
|
+
```python
|
|
527
|
+
"""Create Shipment - Python Example"""
|
|
528
|
+
|
|
529
|
+
from typing import Dict, Optional
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class NewebPayShipment(NewebPayLogistics):
|
|
533
|
+
"""Shipment Creation Operations"""
|
|
534
|
+
|
|
535
|
+
def create_shipment(
|
|
536
|
+
self,
|
|
537
|
+
merchant_order_no: str,
|
|
538
|
+
trade_type: int, # 1=COD, 3=No Payment
|
|
539
|
+
user_name: str,
|
|
540
|
+
user_tel: str,
|
|
541
|
+
user_email: str,
|
|
542
|
+
store_id: str,
|
|
543
|
+
amt: int,
|
|
544
|
+
lgs_type: str, # 'B2C' or 'C2C'
|
|
545
|
+
ship_type: str, # '1'=7-11, '2'=FamilyMart, '3'=Hi-Life, '4'=OK Mart
|
|
546
|
+
item_desc: str = '',
|
|
547
|
+
notify_url: str = '',
|
|
548
|
+
) -> Dict[str, any]:
|
|
549
|
+
"""Create shipment order"""
|
|
550
|
+
|
|
551
|
+
data = {
|
|
552
|
+
'MerchantOrderNo': merchant_order_no,
|
|
553
|
+
'TradeType': trade_type,
|
|
554
|
+
'UserName': user_name,
|
|
555
|
+
'UserTel': user_tel,
|
|
556
|
+
'UserEmail': user_email,
|
|
557
|
+
'StoreID': store_id,
|
|
558
|
+
'Amt': amt,
|
|
559
|
+
'NotifyURL': notify_url,
|
|
560
|
+
'ItemDesc': item_desc,
|
|
561
|
+
'LgsType': lgs_type,
|
|
562
|
+
'ShipType': ship_type,
|
|
563
|
+
'TimeStamp': self.get_timestamp(),
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
encrypted = self.encrypt_data(data)
|
|
567
|
+
|
|
568
|
+
request_data = {
|
|
569
|
+
'UID_': self.merchant_id,
|
|
570
|
+
'EncryptData_': encrypted['EncryptData_'],
|
|
571
|
+
'HashData_': encrypted['HashData_'],
|
|
572
|
+
'Version_': '1.0',
|
|
573
|
+
'RespondType_': 'JSON',
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
response = requests.post(
|
|
577
|
+
f'{self.base_url}/createShipment',
|
|
578
|
+
data=request_data,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
result = response.json()
|
|
582
|
+
|
|
583
|
+
if result['Status'] != 'SUCCESS':
|
|
584
|
+
raise Exception(f"Create shipment failed: {result['Message']}")
|
|
585
|
+
|
|
586
|
+
# Decrypt response
|
|
587
|
+
decrypted = self.decrypt_data(result['EncryptData'], result['HashData'])
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
'merchant_id': decrypted['MerchantID'],
|
|
591
|
+
'amt': decrypted['Amt'],
|
|
592
|
+
'merchant_order_no': decrypted['MerchantOrderNo'],
|
|
593
|
+
'trade_no': decrypted['TradeNo'],
|
|
594
|
+
'lgs_type': decrypted['LgsType'],
|
|
595
|
+
'ship_type': decrypted['ShipType'],
|
|
596
|
+
'store_id': decrypted['StoreID'],
|
|
597
|
+
'trade_type': decrypted['TradeType'],
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# Usage Example
|
|
602
|
+
shipment = NewebPayShipment(
|
|
603
|
+
merchant_id='YOUR_MERCHANT_ID',
|
|
604
|
+
hash_key='YOUR_HASH_KEY',
|
|
605
|
+
hash_iv='YOUR_HASH_IV',
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
result = shipment.create_shipment(
|
|
609
|
+
merchant_order_no=f'ORD{int(time.time())}',
|
|
610
|
+
trade_type=1, # Cash on Delivery
|
|
611
|
+
user_name='John Doe',
|
|
612
|
+
user_tel='0912345678',
|
|
613
|
+
user_email='john@example.com',
|
|
614
|
+
store_id='123456', # From store map query
|
|
615
|
+
amt=1500,
|
|
616
|
+
item_desc='T-shirt x 2',
|
|
617
|
+
notify_url='https://your-site.com/callback/shipment',
|
|
618
|
+
lgs_type='C2C',
|
|
619
|
+
ship_type='1', # 7-ELEVEN
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
print(f"Trade No: {result['trade_no']}")
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
### 4. Get Shipment Number
|
|
628
|
+
|
|
629
|
+
Get shipping code for Kiosk printing.
|
|
630
|
+
|
|
631
|
+
#### TypeScript Example
|
|
632
|
+
|
|
633
|
+
```typescript
|
|
634
|
+
class NewebPayShipmentNumber extends NewebPayLogistics {
|
|
635
|
+
/**
|
|
636
|
+
* Get shipment numbers (max 10 orders)
|
|
637
|
+
*/
|
|
638
|
+
async getShipmentNumbers(merchantOrderNos: string[]) {
|
|
639
|
+
if (merchantOrderNos.length > 10) {
|
|
640
|
+
throw new Error('Maximum 10 orders per request');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const data = {
|
|
644
|
+
MerchantOrderNo: merchantOrderNos,
|
|
645
|
+
TimeStamp: this.getTimestamp(),
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
const { EncryptData_, HashData_ } = this.encryptData(data);
|
|
649
|
+
|
|
650
|
+
const requestData = {
|
|
651
|
+
UID_: this.config.merchantId,
|
|
652
|
+
EncryptData_,
|
|
653
|
+
HashData_,
|
|
654
|
+
Version_: '1.0',
|
|
655
|
+
RespondType_: 'JSON',
|
|
656
|
+
};
|
|
657
|
+
|
|
658
|
+
const response = await axios.post(
|
|
659
|
+
`${this.baseUrl}/getShipmentNo`,
|
|
660
|
+
new URLSearchParams(requestData as any),
|
|
661
|
+
{
|
|
662
|
+
headers: {
|
|
663
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
664
|
+
},
|
|
665
|
+
}
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
const result = response.data;
|
|
669
|
+
|
|
670
|
+
if (result.Status !== 'SUCCESS') {
|
|
671
|
+
throw new Error(`Get shipment number failed: ${result.Message}`);
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Decrypt response
|
|
675
|
+
const decrypted = this.decryptData(result.EncryptData, result.HashData);
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
success: decrypted.SUCCESS || [],
|
|
679
|
+
error: decrypted.ERROR || [],
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Usage Example
|
|
685
|
+
const shipmentNum = new NewebPayShipmentNumber({
|
|
686
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
687
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
688
|
+
hashIV: 'YOUR_HASH_IV',
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
const result = await shipmentNum.getShipmentNumbers([
|
|
692
|
+
'ORD001',
|
|
693
|
+
'ORD002',
|
|
694
|
+
'ORD003',
|
|
695
|
+
]);
|
|
696
|
+
|
|
697
|
+
result.success.forEach((item: any) => {
|
|
698
|
+
console.log(`Order ${item.MerchantOrderNo}:`);
|
|
699
|
+
console.log(` Shipment No: ${item.LgsNo}`);
|
|
700
|
+
console.log(` Store Print No: ${item.StorePrintNo}`);
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
result.error.forEach((item: any) => {
|
|
704
|
+
console.error(`Order ${item.MerchantOrderNo}: ${item.ErrorCode}`);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
export { NewebPayShipmentNumber };
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
### 5. Print Label
|
|
713
|
+
|
|
714
|
+
Print shipping labels (Form POST method).
|
|
715
|
+
|
|
716
|
+
#### TypeScript Example
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
class NewebPayPrintLabel extends NewebPayLogistics {
|
|
720
|
+
/**
|
|
721
|
+
* Generate print label HTML form
|
|
722
|
+
*/
|
|
723
|
+
generatePrintLabelForm(params: {
|
|
724
|
+
merchantOrderNos: string[];
|
|
725
|
+
lgsType: 'B2C' | 'C2C';
|
|
726
|
+
shipType: '1' | '2' | '3' | '4';
|
|
727
|
+
}): string {
|
|
728
|
+
// Validate batch limits
|
|
729
|
+
const limits: Record<string, number> = {
|
|
730
|
+
'1': 18, // 7-ELEVEN
|
|
731
|
+
'2': 8, // FamilyMart
|
|
732
|
+
'3': 18, // Hi-Life
|
|
733
|
+
'4': 18, // OK Mart
|
|
734
|
+
};
|
|
735
|
+
|
|
736
|
+
if (params.merchantOrderNos.length > limits[params.shipType]) {
|
|
737
|
+
throw new Error(`Maximum ${limits[params.shipType]} labels for this provider`);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const data = {
|
|
741
|
+
LgsType: params.lgsType,
|
|
742
|
+
ShipType: params.shipType,
|
|
743
|
+
MerchantOrderNo: params.merchantOrderNos,
|
|
744
|
+
TimeStamp: this.getTimestamp(),
|
|
745
|
+
};
|
|
746
|
+
|
|
747
|
+
const { EncryptData_, HashData_ } = this.encryptData(data);
|
|
748
|
+
|
|
749
|
+
const formData = {
|
|
750
|
+
UID_: this.config.merchantId,
|
|
751
|
+
EncryptData_,
|
|
752
|
+
HashData_,
|
|
753
|
+
Version_: '1.0',
|
|
754
|
+
RespondType_: 'JSON',
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
// Generate HTML form for auto-submit
|
|
758
|
+
const inputs = Object.entries(formData)
|
|
759
|
+
.map(([key, value]) => `<input type="hidden" name="${key}" value="${value}">`)
|
|
760
|
+
.join('\n');
|
|
761
|
+
|
|
762
|
+
return `
|
|
763
|
+
<!DOCTYPE html>
|
|
764
|
+
<html>
|
|
765
|
+
<head>
|
|
766
|
+
<meta charset="UTF-8">
|
|
767
|
+
<title>Print Shipping Label</title>
|
|
768
|
+
</head>
|
|
769
|
+
<body onload="document.getElementById('printForm').submit();">
|
|
770
|
+
<form id="printForm" method="post" action="${this.baseUrl}/printLabel">
|
|
771
|
+
${inputs}
|
|
772
|
+
</form>
|
|
773
|
+
<p>Redirecting to print page...</p>
|
|
774
|
+
</body>
|
|
775
|
+
</html>
|
|
776
|
+
`;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Usage Example
|
|
781
|
+
const printLabel = new NewebPayPrintLabel({
|
|
782
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
783
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
784
|
+
hashIV: 'YOUR_HASH_IV',
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
const html = printLabel.generatePrintLabelForm({
|
|
788
|
+
merchantOrderNos: ['ORD001', 'ORD002'],
|
|
789
|
+
lgsType: 'C2C',
|
|
790
|
+
shipType: '1', // 7-ELEVEN (max 18 labels)
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
// Send HTML to browser or save to file
|
|
794
|
+
export { NewebPayPrintLabel };
|
|
795
|
+
```
|
|
796
|
+
|
|
797
|
+
---
|
|
798
|
+
|
|
799
|
+
### 6. Query Shipment
|
|
800
|
+
|
|
801
|
+
Query logistics order status.
|
|
802
|
+
|
|
803
|
+
#### TypeScript Example
|
|
804
|
+
|
|
805
|
+
```typescript
|
|
806
|
+
class NewebPayQueryShipment extends NewebPayLogistics {
|
|
807
|
+
/**
|
|
808
|
+
* Query shipment status
|
|
809
|
+
*/
|
|
810
|
+
async queryShipment(merchantOrderNo: string) {
|
|
811
|
+
const data = {
|
|
812
|
+
MerchantOrderNo: merchantOrderNo,
|
|
813
|
+
TimeStamp: this.getTimestamp(),
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const { EncryptData_, HashData_ } = this.encryptData(data);
|
|
817
|
+
|
|
818
|
+
const requestData = {
|
|
819
|
+
UID_: this.config.merchantId,
|
|
820
|
+
EncryptData_,
|
|
821
|
+
HashData_,
|
|
822
|
+
Version_: '1.0',
|
|
823
|
+
RespondType_: 'JSON',
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const response = await axios.post(
|
|
827
|
+
`${this.baseUrl}/queryShipment`,
|
|
828
|
+
new URLSearchParams(requestData as any),
|
|
829
|
+
{
|
|
830
|
+
headers: {
|
|
831
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
832
|
+
},
|
|
833
|
+
}
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
const result = response.data;
|
|
837
|
+
|
|
838
|
+
if (result.Status !== 'SUCCESS') {
|
|
839
|
+
throw new Error(`Query shipment failed: ${result.Message}`);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Decrypt response
|
|
843
|
+
const decrypted = this.decryptData(result.EncryptData, result.HashData);
|
|
844
|
+
|
|
845
|
+
return {
|
|
846
|
+
merchantID: decrypted.MerchantID,
|
|
847
|
+
lgsType: decrypted.LgsType,
|
|
848
|
+
tradeNo: decrypted.TradeNo,
|
|
849
|
+
merchantOrderNo: decrypted.MerchantOrderNo,
|
|
850
|
+
amt: decrypted.Amt,
|
|
851
|
+
itemDesc: decrypted.ItemDesc,
|
|
852
|
+
lgsNo: decrypted.LgsNo,
|
|
853
|
+
storePrintNo: decrypted.StorePrintNo,
|
|
854
|
+
collectionAmt: decrypted.collectionAmt,
|
|
855
|
+
tradeType: decrypted.TradeType,
|
|
856
|
+
type: decrypted.Type,
|
|
857
|
+
shopDate: decrypted.ShopDate,
|
|
858
|
+
userName: decrypted.UserName,
|
|
859
|
+
userTel: decrypted.UserTel,
|
|
860
|
+
userEmail: decrypted.UserEmail,
|
|
861
|
+
storeID: decrypted.StoreID,
|
|
862
|
+
shipType: decrypted.ShipType,
|
|
863
|
+
storeName: decrypted.StoreName,
|
|
864
|
+
retId: decrypted.Retld,
|
|
865
|
+
retString: decrypted.RetString,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/**
|
|
870
|
+
* Get human-readable status
|
|
871
|
+
*/
|
|
872
|
+
getStatusDescription(retId: string): string {
|
|
873
|
+
const statusMap: Record<string, string> = {
|
|
874
|
+
'0_1': 'Order not processed',
|
|
875
|
+
'0_2': 'Shipment number expired',
|
|
876
|
+
'0_3': 'Shipment canceled',
|
|
877
|
+
'1': 'Order processing',
|
|
878
|
+
'2': 'Store received shipment',
|
|
879
|
+
'3': 'Store reselected',
|
|
880
|
+
'4': 'Arrived at logistics center',
|
|
881
|
+
'5': 'Arrived at pickup store',
|
|
882
|
+
'6': 'Customer picked up',
|
|
883
|
+
'-1': 'Returned to merchant',
|
|
884
|
+
// ... more statuses
|
|
885
|
+
};
|
|
886
|
+
|
|
887
|
+
return statusMap[retId] || 'Unknown status';
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Usage Example
|
|
892
|
+
const query = new NewebPayQueryShipment({
|
|
893
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
894
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
895
|
+
hashIV: 'YOUR_HASH_IV',
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const status = await query.queryShipment('ORD123456');
|
|
899
|
+
|
|
900
|
+
console.log(`Order: ${status.merchantOrderNo}`);
|
|
901
|
+
console.log(`Status: ${status.retString} (${status.retId})`);
|
|
902
|
+
console.log(`Tracking No: ${status.lgsNo}`);
|
|
903
|
+
console.log(`Store: ${status.storeName}`);
|
|
904
|
+
|
|
905
|
+
export { NewebPayQueryShipment };
|
|
906
|
+
```
|
|
907
|
+
|
|
908
|
+
---
|
|
909
|
+
|
|
910
|
+
### 7. Modify Shipment
|
|
911
|
+
|
|
912
|
+
Modify shipment order details.
|
|
913
|
+
|
|
914
|
+
#### TypeScript Example
|
|
915
|
+
|
|
916
|
+
```typescript
|
|
917
|
+
interface ModifyShipmentRequest {
|
|
918
|
+
merchantOrderNo: string;
|
|
919
|
+
lgsType: 'B2C' | 'C2C';
|
|
920
|
+
shipType: '1' | '2' | '3' | '4';
|
|
921
|
+
userName?: string;
|
|
922
|
+
userTel?: string;
|
|
923
|
+
userEmail?: string;
|
|
924
|
+
storeID?: string;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
class NewebPayModifyShipment extends NewebPayLogistics {
|
|
928
|
+
/**
|
|
929
|
+
* Modify shipment order
|
|
930
|
+
*/
|
|
931
|
+
async modifyShipment(params: ModifyShipmentRequest) {
|
|
932
|
+
const data: any = {
|
|
933
|
+
MerchantOrderNo: params.merchantOrderNo,
|
|
934
|
+
LgsType: params.lgsType,
|
|
935
|
+
ShipType: params.shipType,
|
|
936
|
+
TimeStamp: this.getTimestamp(),
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
// Add optional fields
|
|
940
|
+
if (params.userName) data.UserName = params.userName;
|
|
941
|
+
if (params.userTel) data.UserTel = params.userTel;
|
|
942
|
+
if (params.userEmail) data.UserEmail = params.userEmail;
|
|
943
|
+
if (params.storeID) data.StoreID = params.storeID;
|
|
944
|
+
|
|
945
|
+
const { EncryptData_, HashData_ } = this.encryptData(data);
|
|
946
|
+
|
|
947
|
+
const requestData = {
|
|
948
|
+
UID_: this.config.merchantId,
|
|
949
|
+
EncryptData_,
|
|
950
|
+
HashData_,
|
|
951
|
+
Version_: '1.0',
|
|
952
|
+
RespondType_: 'JSON',
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
const response = await axios.post(
|
|
956
|
+
`${this.baseUrl}/modifyShipment`,
|
|
957
|
+
new URLSearchParams(requestData as any),
|
|
958
|
+
{
|
|
959
|
+
headers: {
|
|
960
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
961
|
+
},
|
|
962
|
+
}
|
|
963
|
+
);
|
|
964
|
+
|
|
965
|
+
const result = response.data;
|
|
966
|
+
|
|
967
|
+
if (result.Status !== 'SUCCESS') {
|
|
968
|
+
throw new Error(`Modify shipment failed: ${result.Message}`);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// Decrypt response
|
|
972
|
+
const decrypted = this.decryptData(result.EncryptData, result.HashData);
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
merchantID: decrypted.MerchantID,
|
|
976
|
+
merchantOrderNo: decrypted.MerchantOrderNo,
|
|
977
|
+
lgsType: decrypted.LgsType,
|
|
978
|
+
shipType: decrypted.ShipType,
|
|
979
|
+
};
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Usage Example
|
|
984
|
+
const modify = new NewebPayModifyShipment({
|
|
985
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
986
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
987
|
+
hashIV: 'YOUR_HASH_IV',
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// Change recipient information
|
|
991
|
+
await modify.modifyShipment({
|
|
992
|
+
merchantOrderNo: 'ORD123456',
|
|
993
|
+
lgsType: 'C2C',
|
|
994
|
+
shipType: '1',
|
|
995
|
+
userName: 'Jane Doe',
|
|
996
|
+
userTel: '0987654321',
|
|
997
|
+
userEmail: 'jane@example.com',
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
// Change pickup store
|
|
1001
|
+
await modify.modifyShipment({
|
|
1002
|
+
merchantOrderNo: 'ORD123456',
|
|
1003
|
+
lgsType: 'C2C',
|
|
1004
|
+
shipType: '1',
|
|
1005
|
+
storeID: '654321',
|
|
1006
|
+
});
|
|
1007
|
+
|
|
1008
|
+
export { NewebPayModifyShipment };
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
---
|
|
1012
|
+
|
|
1013
|
+
### 8. Track Shipment
|
|
1014
|
+
|
|
1015
|
+
Track logistics delivery history.
|
|
1016
|
+
|
|
1017
|
+
#### Python Example
|
|
1018
|
+
|
|
1019
|
+
```python
|
|
1020
|
+
"""Track Shipment History - Python Example"""
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
class NewebPayTrackShipment(NewebPayLogistics):
|
|
1024
|
+
"""Track Shipment Operations"""
|
|
1025
|
+
|
|
1026
|
+
def track_shipment(self, merchant_order_no: str) -> Dict[str, any]:
|
|
1027
|
+
"""Track shipment history"""
|
|
1028
|
+
|
|
1029
|
+
data = {
|
|
1030
|
+
'MerchantOrderNo': merchant_order_no,
|
|
1031
|
+
'TimeStamp': self.get_timestamp(),
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
encrypted = self.encrypt_data(data)
|
|
1035
|
+
|
|
1036
|
+
request_data = {
|
|
1037
|
+
'UID_': self.merchant_id,
|
|
1038
|
+
'EncryptData_': encrypted['EncryptData_'],
|
|
1039
|
+
'HashData_': encrypted['HashData_'],
|
|
1040
|
+
'Version_': '1.0',
|
|
1041
|
+
'RespondType_': 'JSON',
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
response = requests.post(
|
|
1045
|
+
f'{self.base_url}/trace',
|
|
1046
|
+
data=request_data,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
result = response.json()
|
|
1050
|
+
|
|
1051
|
+
if result['Status'] != 'SUCCESS':
|
|
1052
|
+
raise Exception(f"Track shipment failed: {result['Message']}")
|
|
1053
|
+
|
|
1054
|
+
# Decrypt response
|
|
1055
|
+
decrypted = self.decrypt_data(result['EncryptData'], result['HashData'])
|
|
1056
|
+
|
|
1057
|
+
return {
|
|
1058
|
+
'lgs_type': decrypted['LgsType'],
|
|
1059
|
+
'merchant_order_no': decrypted['MerchantOrderNo'],
|
|
1060
|
+
'lgs_no': decrypted['LgsNo'],
|
|
1061
|
+
'trade_type': decrypted['TradeType'],
|
|
1062
|
+
'ship_type': decrypted['ShipType'],
|
|
1063
|
+
'history': decrypted.get('History', []),
|
|
1064
|
+
'ret_id': decrypted.get('Retld', ''),
|
|
1065
|
+
'ret_string': decrypted.get('RetString', ''),
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
def print_tracking_history(self, merchant_order_no: str):
|
|
1069
|
+
"""Print tracking history in readable format"""
|
|
1070
|
+
|
|
1071
|
+
tracking = self.track_shipment(merchant_order_no)
|
|
1072
|
+
|
|
1073
|
+
print(f"Order: {tracking['merchant_order_no']}")
|
|
1074
|
+
print(f"Tracking No: {tracking['lgs_no']}")
|
|
1075
|
+
print(f"Current Status: {tracking['ret_string']}")
|
|
1076
|
+
print("\nHistory:")
|
|
1077
|
+
|
|
1078
|
+
for event in tracking['history']:
|
|
1079
|
+
print(f" {event.get('EventTime')}: {event.get('RetString')}")
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
# Usage Example
|
|
1083
|
+
track = NewebPayTrackShipment(
|
|
1084
|
+
merchant_id='YOUR_MERCHANT_ID',
|
|
1085
|
+
hash_key='YOUR_HASH_KEY',
|
|
1086
|
+
hash_iv='YOUR_HASH_IV',
|
|
1087
|
+
)
|
|
1088
|
+
|
|
1089
|
+
# Get tracking history
|
|
1090
|
+
tracking = track.track_shipment('ORD123456')
|
|
1091
|
+
|
|
1092
|
+
# Print formatted history
|
|
1093
|
+
track.print_tracking_history('ORD123456')
|
|
1094
|
+
```
|
|
1095
|
+
|
|
1096
|
+
---
|
|
1097
|
+
|
|
1098
|
+
### 9. Status Notification (Callback)
|
|
1099
|
+
|
|
1100
|
+
Handle real-time status notifications from NewebPay.
|
|
1101
|
+
|
|
1102
|
+
#### Express.js Example
|
|
1103
|
+
|
|
1104
|
+
```typescript
|
|
1105
|
+
import express from 'express';
|
|
1106
|
+
|
|
1107
|
+
const app = express();
|
|
1108
|
+
|
|
1109
|
+
app.use(express.urlencoded({ extended: true }));
|
|
1110
|
+
app.use(express.json());
|
|
1111
|
+
|
|
1112
|
+
const logistics = new NewebPayLogistics({
|
|
1113
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
1114
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
1115
|
+
hashIV: 'YOUR_HASH_IV',
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Handle shipment status notification
|
|
1120
|
+
*/
|
|
1121
|
+
app.post('/callback/shipment-status', async (req, res) => {
|
|
1122
|
+
try {
|
|
1123
|
+
const { Status, Message, EncryptData_, HashData_, UID_, Version_ } = req.body;
|
|
1124
|
+
|
|
1125
|
+
console.log('Received notification:', {
|
|
1126
|
+
Status,
|
|
1127
|
+
Message,
|
|
1128
|
+
UID: UID_,
|
|
1129
|
+
Version: Version_,
|
|
1130
|
+
});
|
|
1131
|
+
|
|
1132
|
+
if (Status !== 'SUCCESS') {
|
|
1133
|
+
console.error('Notification error:', Message);
|
|
1134
|
+
return res.send('0|Error');
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// Decrypt data
|
|
1138
|
+
const data = logistics.decryptData(EncryptData_, HashData_);
|
|
1139
|
+
|
|
1140
|
+
console.log('Notification data:', data);
|
|
1141
|
+
|
|
1142
|
+
// Process the notification
|
|
1143
|
+
await processShipmentStatusUpdate({
|
|
1144
|
+
lgsType: data.LgsType,
|
|
1145
|
+
merchantOrderNo: data.MerchantOrderNo,
|
|
1146
|
+
lgsNo: data.LgsNo,
|
|
1147
|
+
tradeType: data.TradeType,
|
|
1148
|
+
shipType: data.ShipType,
|
|
1149
|
+
retId: data.Retld,
|
|
1150
|
+
retString: data.RetString,
|
|
1151
|
+
eventTime: data.EventTime,
|
|
1152
|
+
});
|
|
1153
|
+
|
|
1154
|
+
// Return success
|
|
1155
|
+
res.send('1|OK');
|
|
1156
|
+
} catch (error) {
|
|
1157
|
+
console.error('Callback error:', error);
|
|
1158
|
+
res.send('0|Error');
|
|
1159
|
+
}
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
/**
|
|
1163
|
+
* Process shipment status update
|
|
1164
|
+
*/
|
|
1165
|
+
async function processShipmentStatusUpdate(data: {
|
|
1166
|
+
lgsType: string;
|
|
1167
|
+
merchantOrderNo: string;
|
|
1168
|
+
lgsNo: string;
|
|
1169
|
+
tradeType: number;
|
|
1170
|
+
shipType: string;
|
|
1171
|
+
retId: string;
|
|
1172
|
+
retString: string;
|
|
1173
|
+
eventTime: string;
|
|
1174
|
+
}) {
|
|
1175
|
+
console.log(`Processing status update for order ${data.merchantOrderNo}`);
|
|
1176
|
+
|
|
1177
|
+
// Update database
|
|
1178
|
+
// await db.orders.updateOne(
|
|
1179
|
+
// { orderNo: data.merchantOrderNo },
|
|
1180
|
+
// {
|
|
1181
|
+
// $set: {
|
|
1182
|
+
// 'logistics.status': data.retId,
|
|
1183
|
+
// 'logistics.statusDesc': data.retString,
|
|
1184
|
+
// 'logistics.trackingNo': data.lgsNo,
|
|
1185
|
+
// 'logistics.lastUpdate': new Date(data.eventTime),
|
|
1186
|
+
// },
|
|
1187
|
+
// }
|
|
1188
|
+
// );
|
|
1189
|
+
|
|
1190
|
+
// Send notification to customer
|
|
1191
|
+
if (data.retId === '6') {
|
|
1192
|
+
// Customer picked up
|
|
1193
|
+
// await sendEmail({
|
|
1194
|
+
// to: customerEmail,
|
|
1195
|
+
// subject: 'Order Delivered',
|
|
1196
|
+
// body: `Your order ${data.merchantOrderNo} has been picked up.`,
|
|
1197
|
+
// });
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
console.log(`Status update completed for order ${data.merchantOrderNo}`);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
app.listen(3000, () => {
|
|
1204
|
+
console.log('Callback server listening on port 3000');
|
|
1205
|
+
});
|
|
1206
|
+
```
|
|
1207
|
+
|
|
1208
|
+
#### Flask Example
|
|
1209
|
+
|
|
1210
|
+
```python
|
|
1211
|
+
"""Status Notification Callback - Flask Example"""
|
|
1212
|
+
|
|
1213
|
+
from flask import Flask, request
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
app = Flask(__name__)
|
|
1217
|
+
|
|
1218
|
+
logistics = NewebPayLogistics(
|
|
1219
|
+
merchant_id='YOUR_MERCHANT_ID',
|
|
1220
|
+
hash_key='YOUR_HASH_KEY',
|
|
1221
|
+
hash_iv='YOUR_HASH_IV',
|
|
1222
|
+
)
|
|
1223
|
+
|
|
1224
|
+
|
|
1225
|
+
@app.route('/callback/shipment-status', methods=['POST'])
|
|
1226
|
+
def shipment_status_callback():
|
|
1227
|
+
"""Handle shipment status notification"""
|
|
1228
|
+
|
|
1229
|
+
try:
|
|
1230
|
+
data = request.form.to_dict()
|
|
1231
|
+
|
|
1232
|
+
status = data.get('Status')
|
|
1233
|
+
message = data.get('Message')
|
|
1234
|
+
encrypt_data = data.get('EncryptData_')
|
|
1235
|
+
hash_data = data.get('HashData_')
|
|
1236
|
+
|
|
1237
|
+
app.logger.info(f'Received notification: {status} - {message}')
|
|
1238
|
+
|
|
1239
|
+
if status != 'SUCCESS':
|
|
1240
|
+
app.logger.error(f'Notification error: {message}')
|
|
1241
|
+
return '0|Error'
|
|
1242
|
+
|
|
1243
|
+
# Decrypt data
|
|
1244
|
+
decrypted = logistics.decrypt_data(encrypt_data, hash_data)
|
|
1245
|
+
|
|
1246
|
+
app.logger.info(f'Notification data: {decrypted}')
|
|
1247
|
+
|
|
1248
|
+
# Process the notification
|
|
1249
|
+
process_shipment_status_update(decrypted)
|
|
1250
|
+
|
|
1251
|
+
# Return success
|
|
1252
|
+
return '1|OK'
|
|
1253
|
+
|
|
1254
|
+
except Exception as e:
|
|
1255
|
+
app.logger.error(f'Callback error: {str(e)}')
|
|
1256
|
+
return '0|Error'
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def process_shipment_status_update(data: Dict[str, any]):
|
|
1260
|
+
"""Process shipment status update"""
|
|
1261
|
+
|
|
1262
|
+
merchant_order_no = data['MerchantOrderNo']
|
|
1263
|
+
ret_id = data.get('Retld')
|
|
1264
|
+
ret_string = data.get('RetString')
|
|
1265
|
+
|
|
1266
|
+
app.logger.info(f'Processing status update for order {merchant_order_no}')
|
|
1267
|
+
|
|
1268
|
+
# Update database
|
|
1269
|
+
# db.orders.update_one(
|
|
1270
|
+
# {'order_no': merchant_order_no},
|
|
1271
|
+
# {
|
|
1272
|
+
# '$set': {
|
|
1273
|
+
# 'logistics.status': ret_id,
|
|
1274
|
+
# 'logistics.status_desc': ret_string,
|
|
1275
|
+
# 'logistics.tracking_no': data['LgsNo'],
|
|
1276
|
+
# 'logistics.last_update': datetime.now(),
|
|
1277
|
+
# }
|
|
1278
|
+
# }
|
|
1279
|
+
# )
|
|
1280
|
+
|
|
1281
|
+
# Send notification to customer
|
|
1282
|
+
if ret_id == '6':
|
|
1283
|
+
# Customer picked up
|
|
1284
|
+
# send_email(
|
|
1285
|
+
# to=customer_email,
|
|
1286
|
+
# subject='Order Delivered',
|
|
1287
|
+
# body=f'Your order {merchant_order_no} has been picked up.',
|
|
1288
|
+
# )
|
|
1289
|
+
pass
|
|
1290
|
+
|
|
1291
|
+
app.logger.info(f'Status update completed for order {merchant_order_no}')
|
|
1292
|
+
|
|
1293
|
+
|
|
1294
|
+
if __name__ == '__main__':
|
|
1295
|
+
app.run(port=3000)
|
|
1296
|
+
```
|
|
1297
|
+
|
|
1298
|
+
---
|
|
1299
|
+
|
|
1300
|
+
## PAYUNi Logistics Examples
|
|
1301
|
+
|
|
1302
|
+
### 1. Basic Integration (PAYUNi)
|
|
1303
|
+
|
|
1304
|
+
#### TypeScript - AES-256-GCM Encryption Helper
|
|
1305
|
+
|
|
1306
|
+
```typescript
|
|
1307
|
+
import crypto from 'crypto';
|
|
1308
|
+
|
|
1309
|
+
interface PAYUNiConfig {
|
|
1310
|
+
merchantId: string;
|
|
1311
|
+
hashKey: string;
|
|
1312
|
+
hashIV: string;
|
|
1313
|
+
isProduction?: boolean;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
class PAYUNiLogistics {
|
|
1317
|
+
private config: Required<PAYUNiConfig>;
|
|
1318
|
+
private baseUrl: string;
|
|
1319
|
+
|
|
1320
|
+
constructor(config: PAYUNiConfig) {
|
|
1321
|
+
this.config = {
|
|
1322
|
+
...config,
|
|
1323
|
+
isProduction: config.isProduction ?? false,
|
|
1324
|
+
};
|
|
1325
|
+
|
|
1326
|
+
this.baseUrl = this.config.isProduction
|
|
1327
|
+
? 'https://api.payuni.com.tw/api'
|
|
1328
|
+
: 'https://sandbox-api.payuni.com.tw/api';
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
/**
|
|
1332
|
+
* AES-256-GCM Encryption
|
|
1333
|
+
*/
|
|
1334
|
+
encrypt(data: Record<string, any>): string {
|
|
1335
|
+
// Convert to query string
|
|
1336
|
+
const queryString = new URLSearchParams(data).toString();
|
|
1337
|
+
|
|
1338
|
+
// Create cipher
|
|
1339
|
+
const cipher = crypto.createCipheriv(
|
|
1340
|
+
'aes-256-gcm',
|
|
1341
|
+
this.config.hashKey,
|
|
1342
|
+
this.config.hashIV
|
|
1343
|
+
);
|
|
1344
|
+
|
|
1345
|
+
// Encrypt
|
|
1346
|
+
let encrypted = cipher.update(queryString, 'utf8', 'hex');
|
|
1347
|
+
encrypted += cipher.final('hex');
|
|
1348
|
+
|
|
1349
|
+
// Get auth tag
|
|
1350
|
+
const authTag = cipher.getAuthTag().toString('hex');
|
|
1351
|
+
|
|
1352
|
+
// Combine encrypted + tag
|
|
1353
|
+
return encrypted + authTag;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* AES-256-GCM Decryption
|
|
1358
|
+
*/
|
|
1359
|
+
decrypt(encryptInfo: string): Record<string, any> {
|
|
1360
|
+
// Split encrypted data and tag (tag is last 32 hex chars = 16 bytes)
|
|
1361
|
+
const encrypted = encryptInfo.slice(0, -32);
|
|
1362
|
+
const authTag = encryptInfo.slice(-32);
|
|
1363
|
+
|
|
1364
|
+
// Create decipher
|
|
1365
|
+
const decipher = crypto.createDecipheriv(
|
|
1366
|
+
'aes-256-gcm',
|
|
1367
|
+
this.config.hashKey,
|
|
1368
|
+
this.config.hashIV
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
// Set auth tag
|
|
1372
|
+
decipher.setAuthTag(Buffer.from(authTag, 'hex'));
|
|
1373
|
+
|
|
1374
|
+
// Decrypt
|
|
1375
|
+
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
|
1376
|
+
decrypted += decipher.final('utf8');
|
|
1377
|
+
|
|
1378
|
+
// Parse query string
|
|
1379
|
+
const params = new URLSearchParams(decrypted);
|
|
1380
|
+
const result: Record<string, any> = {};
|
|
1381
|
+
params.forEach((value, key) => {
|
|
1382
|
+
result[key] = value;
|
|
1383
|
+
});
|
|
1384
|
+
|
|
1385
|
+
return result;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/**
|
|
1389
|
+
* Generate HashInfo (SHA256)
|
|
1390
|
+
*/
|
|
1391
|
+
generateHashInfo(encryptInfo: string): string {
|
|
1392
|
+
const raw = encryptInfo + this.config.hashKey + this.config.hashIV;
|
|
1393
|
+
return crypto.createHash('sha256').update(raw).digest('hex').toUpperCase();
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/**
|
|
1397
|
+
* Get current Unix timestamp
|
|
1398
|
+
*/
|
|
1399
|
+
getTimestamp(): number {
|
|
1400
|
+
return Math.floor(Date.now() / 1000);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
export { PAYUNiLogistics };
|
|
1405
|
+
```
|
|
1406
|
+
|
|
1407
|
+
#### Python - AES-256-GCM Encryption Helper
|
|
1408
|
+
|
|
1409
|
+
```python
|
|
1410
|
+
"""PAYUNi Logistics Encryption Helper - Python"""
|
|
1411
|
+
|
|
1412
|
+
from Crypto.Cipher import AES
|
|
1413
|
+
from urllib.parse import urlencode, parse_qs
|
|
1414
|
+
import hashlib
|
|
1415
|
+
import time
|
|
1416
|
+
from typing import Dict, Any
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
class PAYUNiLogistics:
|
|
1420
|
+
"""PAYUNi Logistics API Client"""
|
|
1421
|
+
|
|
1422
|
+
def __init__(
|
|
1423
|
+
self,
|
|
1424
|
+
merchant_id: str,
|
|
1425
|
+
hash_key: str,
|
|
1426
|
+
hash_iv: str,
|
|
1427
|
+
is_production: bool = False
|
|
1428
|
+
):
|
|
1429
|
+
self.merchant_id = merchant_id
|
|
1430
|
+
self.hash_key = hash_key.encode('utf-8')
|
|
1431
|
+
self.hash_iv = hash_iv.encode('utf-8')
|
|
1432
|
+
|
|
1433
|
+
self.base_url = (
|
|
1434
|
+
'https://api.payuni.com.tw/api'
|
|
1435
|
+
if is_production
|
|
1436
|
+
else 'https://sandbox-api.payuni.com.tw/api'
|
|
1437
|
+
)
|
|
1438
|
+
|
|
1439
|
+
def encrypt(self, data: Dict[str, Any]) -> str:
|
|
1440
|
+
"""AES-256-GCM Encryption"""
|
|
1441
|
+
# Convert to query string
|
|
1442
|
+
query_string = urlencode(data)
|
|
1443
|
+
|
|
1444
|
+
# Create cipher
|
|
1445
|
+
cipher = AES.new(self.hash_key, AES.MODE_GCM, nonce=self.hash_iv)
|
|
1446
|
+
|
|
1447
|
+
# Encrypt and get tag
|
|
1448
|
+
encrypted, tag = cipher.encrypt_and_digest(query_string.encode('utf-8'))
|
|
1449
|
+
|
|
1450
|
+
# Combine encrypted + tag and convert to hex
|
|
1451
|
+
return (encrypted + tag).hex()
|
|
1452
|
+
|
|
1453
|
+
def decrypt(self, encrypt_info: str) -> Dict[str, Any]:
|
|
1454
|
+
"""AES-256-GCM Decryption"""
|
|
1455
|
+
# Convert hex to binary
|
|
1456
|
+
data = bytes.fromhex(encrypt_info)
|
|
1457
|
+
|
|
1458
|
+
# Split encrypted data and tag (last 16 bytes)
|
|
1459
|
+
encrypted = data[:-16]
|
|
1460
|
+
tag = data[-16:]
|
|
1461
|
+
|
|
1462
|
+
# Create cipher
|
|
1463
|
+
cipher = AES.new(self.hash_key, AES.MODE_GCM, nonce=self.hash_iv)
|
|
1464
|
+
|
|
1465
|
+
# Decrypt and verify
|
|
1466
|
+
decrypted = cipher.decrypt_and_verify(encrypted, tag)
|
|
1467
|
+
|
|
1468
|
+
# Parse query string
|
|
1469
|
+
result = dict(parse_qs(decrypted.decode('utf-8')))
|
|
1470
|
+
return {k: v[0] if len(v) == 1 else v for k, v in result.items()}
|
|
1471
|
+
|
|
1472
|
+
def generate_hash_info(self, encrypt_info: str) -> str:
|
|
1473
|
+
"""Generate HashInfo (SHA256)"""
|
|
1474
|
+
raw = encrypt_info + self.hash_key.decode('utf-8') + self.hash_iv.decode('utf-8')
|
|
1475
|
+
return hashlib.sha256(raw.encode('utf-8')).hexdigest().upper()
|
|
1476
|
+
|
|
1477
|
+
@staticmethod
|
|
1478
|
+
def get_timestamp() -> int:
|
|
1479
|
+
"""Get current Unix timestamp"""
|
|
1480
|
+
return int(time.time())
|
|
1481
|
+
```
|
|
1482
|
+
|
|
1483
|
+
---
|
|
1484
|
+
|
|
1485
|
+
### 2. Create 7-11 C2C Shipment
|
|
1486
|
+
|
|
1487
|
+
#### TypeScript Example
|
|
1488
|
+
|
|
1489
|
+
```typescript
|
|
1490
|
+
import axios from 'axios';
|
|
1491
|
+
|
|
1492
|
+
interface Create711ShipmentRequest {
|
|
1493
|
+
merTradeNo: string;
|
|
1494
|
+
goodsType: 1 | 2; // 1=Normal, 2=Frozen
|
|
1495
|
+
goodsAmount: number;
|
|
1496
|
+
goodsName: string;
|
|
1497
|
+
senderName: string;
|
|
1498
|
+
senderPhone: string;
|
|
1499
|
+
senderStoreID: string;
|
|
1500
|
+
receiverName: string;
|
|
1501
|
+
receiverPhone: string;
|
|
1502
|
+
receiverStoreID: string;
|
|
1503
|
+
notifyURL: string;
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
class PAYUNi711Logistics extends PAYUNiLogistics {
|
|
1507
|
+
/**
|
|
1508
|
+
* Create 7-11 C2C shipment
|
|
1509
|
+
*/
|
|
1510
|
+
async create711Shipment(params: Create711ShipmentRequest) {
|
|
1511
|
+
const data = {
|
|
1512
|
+
MerID: this.config.merchantId,
|
|
1513
|
+
MerTradeNo: params.merTradeNo,
|
|
1514
|
+
LogisticsType: 'PAYUNi_Logistic_711',
|
|
1515
|
+
GoodsType: params.goodsType,
|
|
1516
|
+
GoodsAmount: params.goodsAmount,
|
|
1517
|
+
GoodsName: params.goodsName,
|
|
1518
|
+
SenderName: params.senderName,
|
|
1519
|
+
SenderPhone: params.senderPhone,
|
|
1520
|
+
SenderStoreID: params.senderStoreID,
|
|
1521
|
+
ReceiverName: params.receiverName,
|
|
1522
|
+
ReceiverPhone: params.receiverPhone,
|
|
1523
|
+
ReceiverStoreID: params.receiverStoreID,
|
|
1524
|
+
NotifyURL: params.notifyURL,
|
|
1525
|
+
Timestamp: this.getTimestamp(),
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
const encryptInfo = this.encrypt(data);
|
|
1529
|
+
const hashInfo = this.generateHashInfo(encryptInfo);
|
|
1530
|
+
|
|
1531
|
+
const response = await axios.post(
|
|
1532
|
+
`${this.baseUrl}/logistics/create`,
|
|
1533
|
+
new URLSearchParams({
|
|
1534
|
+
MerID: this.config.merchantId,
|
|
1535
|
+
Version: '1.0',
|
|
1536
|
+
EncryptInfo: encryptInfo,
|
|
1537
|
+
HashInfo: hashInfo,
|
|
1538
|
+
}),
|
|
1539
|
+
{
|
|
1540
|
+
headers: {
|
|
1541
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1542
|
+
},
|
|
1543
|
+
}
|
|
1544
|
+
);
|
|
1545
|
+
|
|
1546
|
+
const result = response.data;
|
|
1547
|
+
|
|
1548
|
+
if (result.Status !== 'SUCCESS') {
|
|
1549
|
+
throw new Error(`Create shipment failed: ${result.Message}`);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
// Decrypt response
|
|
1553
|
+
const decrypted = this.decrypt(result.EncryptInfo);
|
|
1554
|
+
|
|
1555
|
+
return {
|
|
1556
|
+
logisticsID: decrypted.LogisticsID,
|
|
1557
|
+
merTradeNo: decrypted.MerTradeNo,
|
|
1558
|
+
cvsPaymentNo: decrypted.CVSPaymentNo,
|
|
1559
|
+
cvsValidationNo: decrypted.CVSValidationNo,
|
|
1560
|
+
expireDate: decrypted.ExpireDate,
|
|
1561
|
+
};
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// Usage Example
|
|
1566
|
+
const logistics = new PAYUNi711Logistics({
|
|
1567
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
1568
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
1569
|
+
hashIV: 'YOUR_HASH_IV',
|
|
1570
|
+
isProduction: false,
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
const result = await logistics.create711Shipment({
|
|
1574
|
+
merTradeNo: `LOG${Date.now()}`,
|
|
1575
|
+
goodsType: 1, // Normal temperature
|
|
1576
|
+
goodsAmount: 500,
|
|
1577
|
+
goodsName: 'T-shirt',
|
|
1578
|
+
senderName: 'John Doe',
|
|
1579
|
+
senderPhone: '0912345678',
|
|
1580
|
+
senderStoreID: '123456', // 7-11 sender store
|
|
1581
|
+
receiverName: 'Jane Doe',
|
|
1582
|
+
receiverPhone: '0987654321',
|
|
1583
|
+
receiverStoreID: '654321', // 7-11 receiver store
|
|
1584
|
+
notifyURL: 'https://your-site.com/callback/payuni-711',
|
|
1585
|
+
});
|
|
1586
|
+
|
|
1587
|
+
console.log('Logistics ID:', result.logisticsID);
|
|
1588
|
+
console.log('Payment Code:', result.cvsPaymentNo);
|
|
1589
|
+
|
|
1590
|
+
export { PAYUNi711Logistics };
|
|
1591
|
+
```
|
|
1592
|
+
|
|
1593
|
+
#### Python Example
|
|
1594
|
+
|
|
1595
|
+
```python
|
|
1596
|
+
"""Create 7-11 C2C Shipment - Python Example"""
|
|
1597
|
+
|
|
1598
|
+
import requests
|
|
1599
|
+
from typing import Dict
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
class PAYUNi711Logistics(PAYUNiLogistics):
|
|
1603
|
+
"""7-11 C2C Logistics Operations"""
|
|
1604
|
+
|
|
1605
|
+
def create_711_shipment(
|
|
1606
|
+
self,
|
|
1607
|
+
mer_trade_no: str,
|
|
1608
|
+
goods_type: int, # 1=Normal, 2=Frozen
|
|
1609
|
+
goods_amount: int,
|
|
1610
|
+
goods_name: str,
|
|
1611
|
+
sender_name: str,
|
|
1612
|
+
sender_phone: str,
|
|
1613
|
+
sender_store_id: str,
|
|
1614
|
+
receiver_name: str,
|
|
1615
|
+
receiver_phone: str,
|
|
1616
|
+
receiver_store_id: str,
|
|
1617
|
+
notify_url: str,
|
|
1618
|
+
) -> Dict[str, any]:
|
|
1619
|
+
"""Create 7-11 C2C shipment"""
|
|
1620
|
+
|
|
1621
|
+
data = {
|
|
1622
|
+
'MerID': self.merchant_id,
|
|
1623
|
+
'MerTradeNo': mer_trade_no,
|
|
1624
|
+
'LogisticsType': 'PAYUNi_Logistic_711',
|
|
1625
|
+
'GoodsType': goods_type,
|
|
1626
|
+
'GoodsAmount': goods_amount,
|
|
1627
|
+
'GoodsName': goods_name,
|
|
1628
|
+
'SenderName': sender_name,
|
|
1629
|
+
'SenderPhone': sender_phone,
|
|
1630
|
+
'SenderStoreID': sender_store_id,
|
|
1631
|
+
'ReceiverName': receiver_name,
|
|
1632
|
+
'ReceiverPhone': receiver_phone,
|
|
1633
|
+
'ReceiverStoreID': receiver_store_id,
|
|
1634
|
+
'NotifyURL': notify_url,
|
|
1635
|
+
'Timestamp': self.get_timestamp(),
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
encrypt_info = self.encrypt(data)
|
|
1639
|
+
hash_info = self.generate_hash_info(encrypt_info)
|
|
1640
|
+
|
|
1641
|
+
response = requests.post(
|
|
1642
|
+
f'{self.base_url}/logistics/create',
|
|
1643
|
+
data={
|
|
1644
|
+
'MerID': self.merchant_id,
|
|
1645
|
+
'Version': '1.0',
|
|
1646
|
+
'EncryptInfo': encrypt_info,
|
|
1647
|
+
'HashInfo': hash_info,
|
|
1648
|
+
},
|
|
1649
|
+
)
|
|
1650
|
+
|
|
1651
|
+
result = response.json()
|
|
1652
|
+
|
|
1653
|
+
if result['Status'] != 'SUCCESS':
|
|
1654
|
+
raise Exception(f"Create shipment failed: {result['Message']}")
|
|
1655
|
+
|
|
1656
|
+
# Decrypt response
|
|
1657
|
+
decrypted = self.decrypt(result['EncryptInfo'])
|
|
1658
|
+
|
|
1659
|
+
return {
|
|
1660
|
+
'logistics_id': decrypted['LogisticsID'],
|
|
1661
|
+
'mer_trade_no': decrypted['MerTradeNo'],
|
|
1662
|
+
'cvs_payment_no': decrypted['CVSPaymentNo'],
|
|
1663
|
+
'cvs_validation_no': decrypted['CVSValidationNo'],
|
|
1664
|
+
'expire_date': decrypted['ExpireDate'],
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
# Usage Example
|
|
1669
|
+
logistics = PAYUNi711Logistics(
|
|
1670
|
+
merchant_id='YOUR_MERCHANT_ID',
|
|
1671
|
+
hash_key='YOUR_HASH_KEY',
|
|
1672
|
+
hash_iv='YOUR_HASH_IV',
|
|
1673
|
+
is_production=False,
|
|
1674
|
+
)
|
|
1675
|
+
|
|
1676
|
+
result = logistics.create_711_shipment(
|
|
1677
|
+
mer_trade_no=f'LOG{int(time.time())}',
|
|
1678
|
+
goods_type=1, # Normal temperature
|
|
1679
|
+
goods_amount=500,
|
|
1680
|
+
goods_name='T-shirt',
|
|
1681
|
+
sender_name='John Doe',
|
|
1682
|
+
sender_phone='0912345678',
|
|
1683
|
+
sender_store_id='123456',
|
|
1684
|
+
receiver_name='Jane Doe',
|
|
1685
|
+
receiver_phone='0987654321',
|
|
1686
|
+
receiver_store_id='654321',
|
|
1687
|
+
notify_url='https://your-site.com/callback/payuni-711',
|
|
1688
|
+
)
|
|
1689
|
+
|
|
1690
|
+
print(f"Logistics ID: {result['logistics_id']}")
|
|
1691
|
+
print(f"Payment Code: {result['cvs_payment_no']}")
|
|
1692
|
+
```
|
|
1693
|
+
|
|
1694
|
+
---
|
|
1695
|
+
|
|
1696
|
+
### 3. Create T-Cat Home Delivery
|
|
1697
|
+
|
|
1698
|
+
#### TypeScript Example
|
|
1699
|
+
|
|
1700
|
+
```typescript
|
|
1701
|
+
interface CreateTCatShipmentRequest {
|
|
1702
|
+
merTradeNo: string;
|
|
1703
|
+
goodsType: 1 | 2 | 3; // 1=Normal, 2=Frozen, 3=Refrigerated
|
|
1704
|
+
goodsAmount: number;
|
|
1705
|
+
goodsName: string;
|
|
1706
|
+
goodsWeight?: number;
|
|
1707
|
+
senderName: string;
|
|
1708
|
+
senderPhone: string;
|
|
1709
|
+
senderZipCode: string;
|
|
1710
|
+
senderAddress: string;
|
|
1711
|
+
receiverName: string;
|
|
1712
|
+
receiverPhone: string;
|
|
1713
|
+
receiverZipCode: string;
|
|
1714
|
+
receiverAddress: string;
|
|
1715
|
+
scheduledDeliveryTime?: '01' | '02' | '03';
|
|
1716
|
+
notifyURL: string;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
class PAYUNiTCatLogistics extends PAYUNiLogistics {
|
|
1720
|
+
/**
|
|
1721
|
+
* Create T-Cat home delivery shipment
|
|
1722
|
+
*/
|
|
1723
|
+
async createTCatShipment(params: CreateTCatShipmentRequest) {
|
|
1724
|
+
const logisticsType =
|
|
1725
|
+
params.goodsType === 2 ? 'PAYUNi_Logistic_Tcat_Freeze' :
|
|
1726
|
+
params.goodsType === 3 ? 'PAYUNi_Logistic_Tcat_Cold' :
|
|
1727
|
+
'PAYUNi_Logistic_Tcat';
|
|
1728
|
+
|
|
1729
|
+
const data: any = {
|
|
1730
|
+
MerID: this.config.merchantId,
|
|
1731
|
+
MerTradeNo: params.merTradeNo,
|
|
1732
|
+
LogisticsType: logisticsType,
|
|
1733
|
+
GoodsType: params.goodsType,
|
|
1734
|
+
GoodsAmount: params.goodsAmount,
|
|
1735
|
+
GoodsName: params.goodsName,
|
|
1736
|
+
SenderName: params.senderName,
|
|
1737
|
+
SenderPhone: params.senderPhone,
|
|
1738
|
+
SenderZipCode: params.senderZipCode,
|
|
1739
|
+
SenderAddress: params.senderAddress,
|
|
1740
|
+
ReceiverName: params.receiverName,
|
|
1741
|
+
ReceiverPhone: params.receiverPhone,
|
|
1742
|
+
ReceiverZipCode: params.receiverZipCode,
|
|
1743
|
+
ReceiverAddress: params.receiverAddress,
|
|
1744
|
+
NotifyURL: params.notifyURL,
|
|
1745
|
+
Timestamp: this.getTimestamp(),
|
|
1746
|
+
};
|
|
1747
|
+
|
|
1748
|
+
if (params.goodsWeight) data.GoodsWeight = params.goodsWeight;
|
|
1749
|
+
if (params.scheduledDeliveryTime) data.ScheduledDeliveryTime = params.scheduledDeliveryTime;
|
|
1750
|
+
|
|
1751
|
+
const encryptInfo = this.encrypt(data);
|
|
1752
|
+
const hashInfo = this.generateHashInfo(encryptInfo);
|
|
1753
|
+
|
|
1754
|
+
const response = await axios.post(
|
|
1755
|
+
`${this.baseUrl}/logistics/create`,
|
|
1756
|
+
new URLSearchParams({
|
|
1757
|
+
MerID: this.config.merchantId,
|
|
1758
|
+
Version: '1.0',
|
|
1759
|
+
EncryptInfo: encryptInfo,
|
|
1760
|
+
HashInfo: hashInfo,
|
|
1761
|
+
}),
|
|
1762
|
+
{
|
|
1763
|
+
headers: {
|
|
1764
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1765
|
+
},
|
|
1766
|
+
}
|
|
1767
|
+
);
|
|
1768
|
+
|
|
1769
|
+
const result = response.data;
|
|
1770
|
+
|
|
1771
|
+
if (result.Status !== 'SUCCESS') {
|
|
1772
|
+
throw new Error(`Create shipment failed: ${result.Message}`);
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
const decrypted = this.decrypt(result.EncryptInfo);
|
|
1776
|
+
|
|
1777
|
+
return {
|
|
1778
|
+
logisticsID: decrypted.LogisticsID,
|
|
1779
|
+
merTradeNo: decrypted.MerTradeNo,
|
|
1780
|
+
shipmentNo: decrypted.ShipmentNo,
|
|
1781
|
+
bookingNote: decrypted.BookingNote,
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
// Usage Example
|
|
1787
|
+
const tcat = new PAYUNiTCatLogistics({
|
|
1788
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
1789
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
1790
|
+
hashIV: 'YOUR_HASH_IV',
|
|
1791
|
+
});
|
|
1792
|
+
|
|
1793
|
+
const result = await tcat.createTCatShipment({
|
|
1794
|
+
merTradeNo: `TCAT${Date.now()}`,
|
|
1795
|
+
goodsType: 1, // Normal temperature
|
|
1796
|
+
goodsAmount: 1000,
|
|
1797
|
+
goodsName: 'Electronics',
|
|
1798
|
+
goodsWeight: 500, // 500g
|
|
1799
|
+
senderName: 'Sender Name',
|
|
1800
|
+
senderPhone: '0912345678',
|
|
1801
|
+
senderZipCode: '100',
|
|
1802
|
+
senderAddress: 'Taipei City, Zhongzheng Dist., XXX Road No.1',
|
|
1803
|
+
receiverName: 'Receiver Name',
|
|
1804
|
+
receiverPhone: '0987654321',
|
|
1805
|
+
receiverZipCode: '300',
|
|
1806
|
+
receiverAddress: 'Hsinchu City, East Dist., YYY Road No.2',
|
|
1807
|
+
scheduledDeliveryTime: '02', // 14:00-18:00
|
|
1808
|
+
notifyURL: 'https://your-site.com/callback/payuni-tcat',
|
|
1809
|
+
});
|
|
1810
|
+
|
|
1811
|
+
console.log('Tracking No:', result.shipmentNo);
|
|
1812
|
+
|
|
1813
|
+
export { PAYUNiTCatLogistics };
|
|
1814
|
+
```
|
|
1815
|
+
|
|
1816
|
+
---
|
|
1817
|
+
|
|
1818
|
+
### 4. Query Shipment Status
|
|
1819
|
+
|
|
1820
|
+
#### TypeScript Example
|
|
1821
|
+
|
|
1822
|
+
```typescript
|
|
1823
|
+
class PAYUNiQueryLogistics extends PAYUNiLogistics {
|
|
1824
|
+
/**
|
|
1825
|
+
* Query shipment status
|
|
1826
|
+
*/
|
|
1827
|
+
async queryShipment(merTradeNo: string) {
|
|
1828
|
+
const data = {
|
|
1829
|
+
MerID: this.config.merchantId,
|
|
1830
|
+
MerTradeNo: merTradeNo,
|
|
1831
|
+
Timestamp: this.getTimestamp(),
|
|
1832
|
+
};
|
|
1833
|
+
|
|
1834
|
+
const encryptInfo = this.encrypt(data);
|
|
1835
|
+
const hashInfo = this.generateHashInfo(encryptInfo);
|
|
1836
|
+
|
|
1837
|
+
const response = await axios.post(
|
|
1838
|
+
`${this.baseUrl}/logistics/query`,
|
|
1839
|
+
new URLSearchParams({
|
|
1840
|
+
MerID: this.config.merchantId,
|
|
1841
|
+
Version: '1.0',
|
|
1842
|
+
EncryptInfo: encryptInfo,
|
|
1843
|
+
HashInfo: hashInfo,
|
|
1844
|
+
}),
|
|
1845
|
+
{
|
|
1846
|
+
headers: {
|
|
1847
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
1848
|
+
},
|
|
1849
|
+
}
|
|
1850
|
+
);
|
|
1851
|
+
|
|
1852
|
+
const result = response.data;
|
|
1853
|
+
|
|
1854
|
+
if (result.Status !== 'SUCCESS') {
|
|
1855
|
+
throw new Error(`Query shipment failed: ${result.Message}`);
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
const decrypted = this.decrypt(result.EncryptInfo);
|
|
1859
|
+
|
|
1860
|
+
return {
|
|
1861
|
+
logisticsID: decrypted.LogisticsID,
|
|
1862
|
+
merTradeNo: decrypted.MerTradeNo,
|
|
1863
|
+
logisticsType: decrypted.LogisticsType,
|
|
1864
|
+
logisticsStatus: decrypted.LogisticsStatus,
|
|
1865
|
+
logisticsStatusMsg: decrypted.LogisticsStatusMsg,
|
|
1866
|
+
shipmentNo: decrypted.ShipmentNo,
|
|
1867
|
+
receiverStoreID: decrypted.ReceiverStoreID,
|
|
1868
|
+
updateTime: decrypted.UpdateTime,
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/**
|
|
1873
|
+
* Get human-readable status
|
|
1874
|
+
*/
|
|
1875
|
+
getStatusDescription(statusCode: string): string {
|
|
1876
|
+
const statusMap: Record<string, string> = {
|
|
1877
|
+
'11': 'Shipped',
|
|
1878
|
+
'21': 'Arrived at store / In delivery',
|
|
1879
|
+
'22': 'Picked up / Delivered',
|
|
1880
|
+
'31': 'Returning',
|
|
1881
|
+
'32': 'Return completed',
|
|
1882
|
+
};
|
|
1883
|
+
|
|
1884
|
+
return statusMap[statusCode] || 'Unknown status';
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
// Usage Example
|
|
1889
|
+
const query = new PAYUNiQueryLogistics({
|
|
1890
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
1891
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
1892
|
+
hashIV: 'YOUR_HASH_IV',
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
const status = await query.queryShipment('LOG123456');
|
|
1896
|
+
|
|
1897
|
+
console.log(`Order: ${status.merTradeNo}`);
|
|
1898
|
+
console.log(`Status: ${status.logisticsStatusMsg} (${status.logisticsStatus})`);
|
|
1899
|
+
console.log(`Tracking No: ${status.shipmentNo}`);
|
|
1900
|
+
|
|
1901
|
+
export { PAYUNiQueryLogistics };
|
|
1902
|
+
```
|
|
1903
|
+
|
|
1904
|
+
---
|
|
1905
|
+
|
|
1906
|
+
### 5. Status Notification Callback
|
|
1907
|
+
|
|
1908
|
+
#### Express.js Example
|
|
1909
|
+
|
|
1910
|
+
```typescript
|
|
1911
|
+
import express from 'express';
|
|
1912
|
+
|
|
1913
|
+
const app = express();
|
|
1914
|
+
|
|
1915
|
+
app.use(express.urlencoded({ extended: true }));
|
|
1916
|
+
app.use(express.json());
|
|
1917
|
+
|
|
1918
|
+
const logistics = new PAYUNiLogistics({
|
|
1919
|
+
merchantId: 'YOUR_MERCHANT_ID',
|
|
1920
|
+
hashKey: 'YOUR_HASH_KEY',
|
|
1921
|
+
hashIV: 'YOUR_HASH_IV',
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
/**
|
|
1925
|
+
* Handle PAYUNi logistics status notification
|
|
1926
|
+
*/
|
|
1927
|
+
app.post('/callback/payuni-logistics', async (req, res) => {
|
|
1928
|
+
try {
|
|
1929
|
+
const { MerID, EncryptInfo, HashInfo } = req.body;
|
|
1930
|
+
|
|
1931
|
+
console.log('Received notification from PAYUNi');
|
|
1932
|
+
|
|
1933
|
+
// Verify HashInfo
|
|
1934
|
+
const calculatedHash = logistics.generateHashInfo(EncryptInfo);
|
|
1935
|
+
if (calculatedHash !== HashInfo.toUpperCase()) {
|
|
1936
|
+
console.error('Hash verification failed');
|
|
1937
|
+
return res.send('Hash Error');
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// Decrypt data
|
|
1941
|
+
const data = logistics.decrypt(EncryptInfo);
|
|
1942
|
+
|
|
1943
|
+
console.log('Notification data:', data);
|
|
1944
|
+
|
|
1945
|
+
// Process the notification
|
|
1946
|
+
await processLogisticsStatusUpdate({
|
|
1947
|
+
merTradeNo: data.MerTradeNo,
|
|
1948
|
+
logisticsID: data.LogisticsID,
|
|
1949
|
+
logisticsType: data.LogisticsType,
|
|
1950
|
+
logisticsStatus: data.LogisticsStatus,
|
|
1951
|
+
logisticsStatusMsg: data.LogisticsStatusMsg,
|
|
1952
|
+
updateTime: data.UpdateTime,
|
|
1953
|
+
});
|
|
1954
|
+
|
|
1955
|
+
// Return SUCCESS
|
|
1956
|
+
res.send('SUCCESS');
|
|
1957
|
+
} catch (error) {
|
|
1958
|
+
console.error('Callback error:', error);
|
|
1959
|
+
res.send('Error');
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
|
|
1963
|
+
/**
|
|
1964
|
+
* Process logistics status update
|
|
1965
|
+
*/
|
|
1966
|
+
async function processLogisticsStatusUpdate(data: {
|
|
1967
|
+
merTradeNo: string;
|
|
1968
|
+
logisticsID: string;
|
|
1969
|
+
logisticsType: string;
|
|
1970
|
+
logisticsStatus: string;
|
|
1971
|
+
logisticsStatusMsg: string;
|
|
1972
|
+
updateTime: string;
|
|
1973
|
+
}) {
|
|
1974
|
+
console.log(`Processing status update for order ${data.merTradeNo}`);
|
|
1975
|
+
|
|
1976
|
+
// Update database
|
|
1977
|
+
// await db.orders.updateOne(
|
|
1978
|
+
// { orderNo: data.merTradeNo },
|
|
1979
|
+
// {
|
|
1980
|
+
// $set: {
|
|
1981
|
+
// 'logistics.status': data.logisticsStatus,
|
|
1982
|
+
// 'logistics.statusMsg': data.logisticsStatusMsg,
|
|
1983
|
+
// 'logistics.logisticsID': data.logisticsID,
|
|
1984
|
+
// 'logistics.lastUpdate': new Date(data.updateTime),
|
|
1985
|
+
// },
|
|
1986
|
+
// }
|
|
1987
|
+
// );
|
|
1988
|
+
|
|
1989
|
+
// Send notification to customer based on status
|
|
1990
|
+
switch (data.logisticsStatus) {
|
|
1991
|
+
case '11':
|
|
1992
|
+
// Shipped
|
|
1993
|
+
console.log('Order shipped');
|
|
1994
|
+
break;
|
|
1995
|
+
case '21':
|
|
1996
|
+
// Arrived
|
|
1997
|
+
console.log('Arrived at destination');
|
|
1998
|
+
break;
|
|
1999
|
+
case '22':
|
|
2000
|
+
// Picked up / Delivered
|
|
2001
|
+
console.log('Delivery completed');
|
|
2002
|
+
// await sendEmail({ ... });
|
|
2003
|
+
break;
|
|
2004
|
+
case '31':
|
|
2005
|
+
case '32':
|
|
2006
|
+
// Return
|
|
2007
|
+
console.log('Order returned');
|
|
2008
|
+
break;
|
|
2009
|
+
}
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
app.listen(3000, () => {
|
|
2013
|
+
console.log('PAYUNi callback server listening on port 3000');
|
|
2014
|
+
});
|
|
2015
|
+
```
|
|
2016
|
+
|
|
2017
|
+
---
|
|
2018
|
+
|
|
2019
|
+
## ECPay Logistics Examples
|
|
2020
|
+
|
|
2021
|
+
### 1. Basic Integration (ECPay)
|
|
2022
|
+
|
|
2023
|
+
#### TypeScript - MD5 CheckMacValue Helper
|
|
2024
|
+
|
|
2025
|
+
```typescript
|
|
2026
|
+
import crypto from 'crypto';
|
|
2027
|
+
|
|
2028
|
+
interface ECPayConfig {
|
|
2029
|
+
merchantId: string;
|
|
2030
|
+
hashKey: string;
|
|
2031
|
+
hashIV: string;
|
|
2032
|
+
isProduction?: boolean;
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
class ECPayLogistics {
|
|
2036
|
+
private config: Required<ECPayConfig>;
|
|
2037
|
+
private baseUrl: string;
|
|
2038
|
+
|
|
2039
|
+
constructor(config: ECPayConfig) {
|
|
2040
|
+
this.config = {
|
|
2041
|
+
...config,
|
|
2042
|
+
isProduction: config.isProduction ?? false,
|
|
2043
|
+
};
|
|
2044
|
+
|
|
2045
|
+
this.baseUrl = this.config.isProduction
|
|
2046
|
+
? 'https://logistics.ecpay.com.tw'
|
|
2047
|
+
: 'https://logistics-stage.ecpay.com.tw';
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Generate CheckMacValue (MD5)
|
|
2052
|
+
*/
|
|
2053
|
+
generateCheckMacValue(params: Record<string, any>): string {
|
|
2054
|
+
// Sort parameters
|
|
2055
|
+
const sorted = Object.keys(params)
|
|
2056
|
+
.sort()
|
|
2057
|
+
.reduce((acc, key) => {
|
|
2058
|
+
acc[key] = params[key];
|
|
2059
|
+
return acc;
|
|
2060
|
+
}, {} as Record<string, any>);
|
|
2061
|
+
|
|
2062
|
+
// Create query string
|
|
2063
|
+
const paramStr = Object.entries(sorted)
|
|
2064
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
2065
|
+
.join('&');
|
|
2066
|
+
|
|
2067
|
+
// Add HashKey and HashIV
|
|
2068
|
+
const raw = `HashKey=${this.config.hashKey}&${paramStr}&HashIV=${this.config.hashIV}`;
|
|
2069
|
+
|
|
2070
|
+
// URL encode and convert to lowercase
|
|
2071
|
+
const encoded = encodeURIComponent(raw).toLowerCase();
|
|
2072
|
+
|
|
2073
|
+
// MD5 hash and uppercase
|
|
2074
|
+
return crypto.createHash('md5').update(encoded).digest('hex').toUpperCase();
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* Verify CheckMacValue from callback
|
|
2079
|
+
*/
|
|
2080
|
+
verifyCheckMacValue(params: Record<string, any>): boolean {
|
|
2081
|
+
const { CheckMacValue, ...data } = params;
|
|
2082
|
+
const calculated = this.generateCheckMacValue(data);
|
|
2083
|
+
return calculated === CheckMacValue;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
|
|
2087
|
+
export { ECPayLogistics };
|
|
2088
|
+
```
|
|
2089
|
+
|
|
2090
|
+
#### Python - MD5 CheckMacValue Helper
|
|
2091
|
+
|
|
2092
|
+
```python
|
|
2093
|
+
"""ECPay Logistics Encryption Helper - Python"""
|
|
2094
|
+
|
|
2095
|
+
import hashlib
|
|
2096
|
+
import urllib.parse
|
|
2097
|
+
from typing import Dict, Any
|
|
2098
|
+
|
|
2099
|
+
|
|
2100
|
+
class ECPayLogistics:
|
|
2101
|
+
"""ECPay Logistics API Client"""
|
|
2102
|
+
|
|
2103
|
+
def __init__(
|
|
2104
|
+
self,
|
|
2105
|
+
merchant_id: str,
|
|
2106
|
+
hash_key: str,
|
|
2107
|
+
hash_iv: str,
|
|
2108
|
+
is_production: bool = False
|
|
2109
|
+
):
|
|
2110
|
+
self.merchant_id = merchant_id
|
|
2111
|
+
self.hash_key = hash_key
|
|
2112
|
+
self.hash_iv = hash_iv
|
|
2113
|
+
|
|
2114
|
+
self.base_url = (
|
|
2115
|
+
'https://logistics.ecpay.com.tw'
|
|
2116
|
+
if is_production
|
|
2117
|
+
else 'https://logistics-stage.ecpay.com.tw'
|
|
2118
|
+
)
|
|
2119
|
+
|
|
2120
|
+
def generate_check_mac_value(self, params: Dict[str, Any]) -> str:
|
|
2121
|
+
"""Generate CheckMacValue (MD5)"""
|
|
2122
|
+
# Sort parameters
|
|
2123
|
+
sorted_params = sorted(params.items())
|
|
2124
|
+
|
|
2125
|
+
# Create query string
|
|
2126
|
+
param_str = '&'.join(f'{k}={v}' for k, v in sorted_params)
|
|
2127
|
+
|
|
2128
|
+
# Add HashKey and HashIV
|
|
2129
|
+
raw = f'HashKey={self.hash_key}&{param_str}&HashIV={self.hash_iv}'
|
|
2130
|
+
|
|
2131
|
+
# URL encode and convert to lowercase
|
|
2132
|
+
encoded = urllib.parse.quote_plus(raw).lower()
|
|
2133
|
+
|
|
2134
|
+
# MD5 hash and uppercase
|
|
2135
|
+
return hashlib.md5(encoded.encode('utf-8')).hexdigest().upper()
|
|
2136
|
+
|
|
2137
|
+
def verify_check_mac_value(self, params: Dict[str, Any]) -> bool:
|
|
2138
|
+
"""Verify CheckMacValue from callback"""
|
|
2139
|
+
check_mac = params.pop('CheckMacValue', None)
|
|
2140
|
+
if not check_mac:
|
|
2141
|
+
return False
|
|
2142
|
+
|
|
2143
|
+
calculated = self.generate_check_mac_value(params)
|
|
2144
|
+
return calculated == check_mac
|
|
2145
|
+
```
|
|
2146
|
+
|
|
2147
|
+
---
|
|
2148
|
+
|
|
2149
|
+
### 2. Create CVS C2C Shipment (ECPay)
|
|
2150
|
+
|
|
2151
|
+
#### TypeScript Example
|
|
2152
|
+
|
|
2153
|
+
```typescript
|
|
2154
|
+
import axios from 'axios';
|
|
2155
|
+
|
|
2156
|
+
interface CreateCVSShipmentRequest {
|
|
2157
|
+
merTradeNo: string;
|
|
2158
|
+
logisticsSubType: 'FAMI' | 'UNIMART' | 'UNIMARTFREEZE' | 'HILIFE' | 'OKMART';
|
|
2159
|
+
goodsAmount: number;
|
|
2160
|
+
goodsName: string;
|
|
2161
|
+
senderName: string;
|
|
2162
|
+
senderCellPhone: string;
|
|
2163
|
+
receiverName: string;
|
|
2164
|
+
receiverCellPhone: string;
|
|
2165
|
+
receiverStoreID: string;
|
|
2166
|
+
isCollection?: 'Y' | 'N';
|
|
2167
|
+
serverReplyURL: string;
|
|
2168
|
+
}
|
|
2169
|
+
|
|
2170
|
+
class ECPayCVSLogistics extends ECPayLogistics {
|
|
2171
|
+
/**
|
|
2172
|
+
* Create CVS C2C shipment
|
|
2173
|
+
*/
|
|
2174
|
+
async createCVSShipment(params: CreateCVSShipmentRequest) {
|
|
2175
|
+
const tradeDate = new Date().toLocaleString('zh-TW', {
|
|
2176
|
+
year: 'numeric',
|
|
2177
|
+
month: '2-digit',
|
|
2178
|
+
day: '2-digit',
|
|
2179
|
+
hour: '2-digit',
|
|
2180
|
+
minute: '2-digit',
|
|
2181
|
+
second: '2-digit',
|
|
2182
|
+
hour12: false,
|
|
2183
|
+
}).replace(/\//g, '/');
|
|
2184
|
+
|
|
2185
|
+
const data: Record<string, any> = {
|
|
2186
|
+
MerchantID: this.config.merchantId,
|
|
2187
|
+
MerchantTradeNo: params.merTradeNo,
|
|
2188
|
+
MerchantTradeDate: tradeDate,
|
|
2189
|
+
LogisticsType: 'CVS',
|
|
2190
|
+
LogisticsSubType: params.logisticsSubType,
|
|
2191
|
+
GoodsAmount: params.goodsAmount,
|
|
2192
|
+
GoodsName: params.goodsName,
|
|
2193
|
+
SenderName: params.senderName,
|
|
2194
|
+
SenderCellPhone: params.senderCellPhone,
|
|
2195
|
+
ReceiverName: params.receiverName,
|
|
2196
|
+
ReceiverCellPhone: params.receiverCellPhone,
|
|
2197
|
+
ReceiverStoreID: params.receiverStoreID,
|
|
2198
|
+
IsCollection: params.isCollection || 'N',
|
|
2199
|
+
ServerReplyURL: params.serverReplyURL,
|
|
2200
|
+
};
|
|
2201
|
+
|
|
2202
|
+
// Generate CheckMacValue
|
|
2203
|
+
data.CheckMacValue = this.generateCheckMacValue(data);
|
|
2204
|
+
|
|
2205
|
+
const response = await axios.post(
|
|
2206
|
+
`${this.baseUrl}/Express/Create`,
|
|
2207
|
+
new URLSearchParams(data),
|
|
2208
|
+
{
|
|
2209
|
+
headers: {
|
|
2210
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2211
|
+
},
|
|
2212
|
+
}
|
|
2213
|
+
);
|
|
2214
|
+
|
|
2215
|
+
// Parse response (1=key&value format)
|
|
2216
|
+
const result: Record<string, string> = {};
|
|
2217
|
+
response.data.split('&').forEach((item: string) => {
|
|
2218
|
+
const [key, value] = item.split('=');
|
|
2219
|
+
if (key && value) {
|
|
2220
|
+
result[key] = decodeURIComponent(value);
|
|
2221
|
+
}
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
if (result.RtnCode !== '300' && result.RtnCode !== '2001') {
|
|
2225
|
+
throw new Error(`Create shipment failed: ${result.RtnMsg}`);
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
return {
|
|
2229
|
+
allPayLogisticsID: result.AllPayLogisticsID,
|
|
2230
|
+
cvsPaymentNo: result.CVSPaymentNo,
|
|
2231
|
+
cvsValidationNo: result.CVSValidationNo,
|
|
2232
|
+
bookingNote: result.BookingNote,
|
|
2233
|
+
};
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// Usage Example
|
|
2238
|
+
const logistics = new ECPayCVSLogistics({
|
|
2239
|
+
merchantId: '2000132',
|
|
2240
|
+
hashKey: '5294y06JbISpM5x9',
|
|
2241
|
+
hashIV: 'v77hoKGq4kWxNNIS',
|
|
2242
|
+
isProduction: false,
|
|
2243
|
+
});
|
|
2244
|
+
|
|
2245
|
+
const result = await logistics.createCVSShipment({
|
|
2246
|
+
merTradeNo: `CVS${Date.now()}`,
|
|
2247
|
+
logisticsSubType: 'UNIMART', // 7-11
|
|
2248
|
+
goodsAmount: 500,
|
|
2249
|
+
goodsName: 'Test Product',
|
|
2250
|
+
senderName: 'Sender Name',
|
|
2251
|
+
senderCellPhone: '0912345678',
|
|
2252
|
+
receiverName: 'Receiver Name',
|
|
2253
|
+
receiverCellPhone: '0987654321',
|
|
2254
|
+
receiverStoreID: '131386', // 7-11 store code
|
|
2255
|
+
isCollection: 'N',
|
|
2256
|
+
serverReplyURL: 'https://your-site.com/callback/ecpay-cvs',
|
|
2257
|
+
});
|
|
2258
|
+
|
|
2259
|
+
console.log('Logistics ID:', result.allPayLogisticsID);
|
|
2260
|
+
console.log('Payment No:', result.cvsPaymentNo);
|
|
2261
|
+
|
|
2262
|
+
export { ECPayCVSLogistics };
|
|
2263
|
+
```
|
|
2264
|
+
|
|
2265
|
+
#### Python Example
|
|
2266
|
+
|
|
2267
|
+
```python
|
|
2268
|
+
#!/usr/bin/env python3
|
|
2269
|
+
"""
|
|
2270
|
+
Create CVS C2C Shipment - ECPay Python Example
|
|
2271
|
+
|
|
2272
|
+
依照 taiwan-logistics-skill 嚴格規範撰寫
|
|
2273
|
+
"""
|
|
2274
|
+
|
|
2275
|
+
import requests
|
|
2276
|
+
import urllib.parse
|
|
2277
|
+
import time
|
|
2278
|
+
from datetime import datetime
|
|
2279
|
+
from typing import Dict, Literal, Optional
|
|
2280
|
+
from dataclasses import dataclass
|
|
2281
|
+
|
|
2282
|
+
|
|
2283
|
+
@dataclass
|
|
2284
|
+
class CVSShipmentData:
|
|
2285
|
+
"""CVS C2C 物流訂單資料"""
|
|
2286
|
+
mer_trade_no: str
|
|
2287
|
+
logistics_sub_type: Literal['FAMI', 'UNIMART', 'UNIMARTFREEZE', 'HILIFE', 'OKMART']
|
|
2288
|
+
goods_amount: int
|
|
2289
|
+
goods_name: str
|
|
2290
|
+
sender_name: str
|
|
2291
|
+
sender_cell_phone: str
|
|
2292
|
+
receiver_name: str
|
|
2293
|
+
receiver_cell_phone: str
|
|
2294
|
+
receiver_store_id: str
|
|
2295
|
+
is_collection: str = 'N'
|
|
2296
|
+
server_reply_url: str = ''
|
|
2297
|
+
|
|
2298
|
+
|
|
2299
|
+
@dataclass
|
|
2300
|
+
class CVSShipmentResponse:
|
|
2301
|
+
"""CVS C2C 物流訂單回應"""
|
|
2302
|
+
success: bool
|
|
2303
|
+
rtn_code: str
|
|
2304
|
+
rtn_msg: str
|
|
2305
|
+
all_pay_logistics_id: str = ''
|
|
2306
|
+
cvs_payment_no: str = ''
|
|
2307
|
+
cvs_validation_no: str = ''
|
|
2308
|
+
booking_note: str = ''
|
|
2309
|
+
raw: Dict[str, str] = None
|
|
2310
|
+
|
|
2311
|
+
|
|
2312
|
+
class ECPayCVSLogistics(ECPayLogistics):
|
|
2313
|
+
"""
|
|
2314
|
+
ECPay CVS 超商物流服務
|
|
2315
|
+
|
|
2316
|
+
支援超商類型:
|
|
2317
|
+
- FAMI: FamilyMart 全家便利商店
|
|
2318
|
+
- UNIMART: 7-ELEVEN 統一超商 (常溫)
|
|
2319
|
+
- UNIMARTFREEZE: 7-ELEVEN 統一超商 (冷凍)
|
|
2320
|
+
- HILIFE: Hi-Life 萊爾富便利商店
|
|
2321
|
+
- OKMART: OK Mart OK 便利商店
|
|
2322
|
+
|
|
2323
|
+
回傳碼:
|
|
2324
|
+
- 300: 訂單建立成功 (尚未寄貨)
|
|
2325
|
+
- 2001: 訂單建立成功 (門市已出貨)
|
|
2326
|
+
"""
|
|
2327
|
+
|
|
2328
|
+
def create_cvs_shipment(
|
|
2329
|
+
self,
|
|
2330
|
+
data: CVSShipmentData,
|
|
2331
|
+
) -> CVSShipmentResponse:
|
|
2332
|
+
"""
|
|
2333
|
+
建立 CVS C2C 超商物流訂單
|
|
2334
|
+
|
|
2335
|
+
Args:
|
|
2336
|
+
data: CVS 物流訂單資料 (CVSShipmentData)
|
|
2337
|
+
|
|
2338
|
+
Returns:
|
|
2339
|
+
CVSShipmentResponse: 物流訂單回應
|
|
2340
|
+
|
|
2341
|
+
Raises:
|
|
2342
|
+
Exception: API 請求失敗或訂單建立失敗
|
|
2343
|
+
|
|
2344
|
+
Example:
|
|
2345
|
+
>>> shipment_data = CVSShipmentData(
|
|
2346
|
+
... mer_trade_no=f'CVS{int(time.time())}',
|
|
2347
|
+
... logistics_sub_type='UNIMART',
|
|
2348
|
+
... goods_amount=500,
|
|
2349
|
+
... goods_name='Test Product',
|
|
2350
|
+
... sender_name='Sender Name',
|
|
2351
|
+
... sender_cell_phone='0912345678',
|
|
2352
|
+
... receiver_name='Receiver Name',
|
|
2353
|
+
... receiver_cell_phone='0987654321',
|
|
2354
|
+
... receiver_store_id='131386',
|
|
2355
|
+
... is_collection='N',
|
|
2356
|
+
... server_reply_url='https://your-site.com/callback',
|
|
2357
|
+
... )
|
|
2358
|
+
>>> result = logistics.create_cvs_shipment(shipment_data)
|
|
2359
|
+
>>> print(result.all_pay_logistics_id)
|
|
2360
|
+
"""
|
|
2361
|
+
# 產生交易日期時間 (格式: YYYY/MM/DD HH:MM:SS)
|
|
2362
|
+
trade_date = datetime.now().strftime('%Y/%m/%d %H:%M:%S')
|
|
2363
|
+
|
|
2364
|
+
# 準備 API 請求參數
|
|
2365
|
+
api_params = {
|
|
2366
|
+
'MerchantID': self.merchant_id,
|
|
2367
|
+
'MerchantTradeNo': data.mer_trade_no,
|
|
2368
|
+
'MerchantTradeDate': trade_date,
|
|
2369
|
+
'LogisticsType': 'CVS',
|
|
2370
|
+
'LogisticsSubType': data.logistics_sub_type,
|
|
2371
|
+
'GoodsAmount': data.goods_amount,
|
|
2372
|
+
'GoodsName': data.goods_name,
|
|
2373
|
+
'SenderName': data.sender_name,
|
|
2374
|
+
'SenderCellPhone': data.sender_cell_phone,
|
|
2375
|
+
'ReceiverName': data.receiver_name,
|
|
2376
|
+
'ReceiverCellPhone': data.receiver_cell_phone,
|
|
2377
|
+
'ReceiverStoreID': data.receiver_store_id,
|
|
2378
|
+
'IsCollection': data.is_collection,
|
|
2379
|
+
'ServerReplyURL': data.server_reply_url,
|
|
2380
|
+
}
|
|
2381
|
+
|
|
2382
|
+
# 產生 CheckMacValue (MD5 雜湊驗證碼)
|
|
2383
|
+
api_params['CheckMacValue'] = self.generate_check_mac_value(api_params)
|
|
2384
|
+
|
|
2385
|
+
# 發送 API 請求
|
|
2386
|
+
try:
|
|
2387
|
+
response = requests.post(
|
|
2388
|
+
f'{self.base_url}/Express/Create',
|
|
2389
|
+
data=api_params,
|
|
2390
|
+
timeout=30,
|
|
2391
|
+
)
|
|
2392
|
+
response.raise_for_status()
|
|
2393
|
+
except requests.RequestException as e:
|
|
2394
|
+
raise Exception(f"API 請求失敗: {str(e)}")
|
|
2395
|
+
|
|
2396
|
+
# 解析回應 (格式: key1=value1&key2=value2)
|
|
2397
|
+
result = {}
|
|
2398
|
+
for item in response.text.split('&'):
|
|
2399
|
+
if '=' in item:
|
|
2400
|
+
key, value = item.split('=', 1)
|
|
2401
|
+
result[key] = urllib.parse.unquote(value)
|
|
2402
|
+
|
|
2403
|
+
# 檢查訂單狀態
|
|
2404
|
+
rtn_code = result.get('RtnCode', '')
|
|
2405
|
+
success = rtn_code in ['300', '2001']
|
|
2406
|
+
|
|
2407
|
+
if not success:
|
|
2408
|
+
return CVSShipmentResponse(
|
|
2409
|
+
success=False,
|
|
2410
|
+
rtn_code=rtn_code,
|
|
2411
|
+
rtn_msg=result.get('RtnMsg', '未知錯誤'),
|
|
2412
|
+
raw=result,
|
|
2413
|
+
)
|
|
2414
|
+
|
|
2415
|
+
# 回傳成功結果
|
|
2416
|
+
return CVSShipmentResponse(
|
|
2417
|
+
success=True,
|
|
2418
|
+
rtn_code=rtn_code,
|
|
2419
|
+
rtn_msg=result.get('RtnMsg', ''),
|
|
2420
|
+
all_pay_logistics_id=result.get('AllPayLogisticsID', ''),
|
|
2421
|
+
cvs_payment_no=result.get('CVSPaymentNo', ''),
|
|
2422
|
+
cvs_validation_no=result.get('CVSValidationNo', ''),
|
|
2423
|
+
booking_note=result.get('BookingNote', ''),
|
|
2424
|
+
raw=result,
|
|
2425
|
+
)
|
|
2426
|
+
|
|
2427
|
+
|
|
2428
|
+
# Usage Example
|
|
2429
|
+
if __name__ == '__main__':
|
|
2430
|
+
# 初始化物流服務 (使用測試環境)
|
|
2431
|
+
logistics = ECPayCVSLogistics(
|
|
2432
|
+
merchant_id='2000132', # ECPay 測試商店代號
|
|
2433
|
+
hash_key='5294y06JbISpM5x9', # ECPay 測試 HashKey
|
|
2434
|
+
hash_iv='v77hoKGq4kWxNNIS', # ECPay 測試 HashIV
|
|
2435
|
+
is_production=False, # 使用測試環境
|
|
2436
|
+
)
|
|
2437
|
+
|
|
2438
|
+
# 準備物流訂單資料
|
|
2439
|
+
shipment_data = CVSShipmentData(
|
|
2440
|
+
mer_trade_no=f'CVS{int(time.time())}', # 訂單編號 (唯一值)
|
|
2441
|
+
logistics_sub_type='UNIMART', # 7-11 超商
|
|
2442
|
+
goods_amount=500, # 商品金額
|
|
2443
|
+
goods_name='測試商品', # 商品名稱
|
|
2444
|
+
sender_name='寄件人姓名', # 寄件人姓名
|
|
2445
|
+
sender_cell_phone='0912345678', # 寄件人手機
|
|
2446
|
+
receiver_name='收件人姓名', # 收件人姓名
|
|
2447
|
+
receiver_cell_phone='0987654321', # 收件人手機
|
|
2448
|
+
receiver_store_id='131386', # 收件門市代號 (7-11)
|
|
2449
|
+
is_collection='N', # 不代收貨款
|
|
2450
|
+
server_reply_url='https://your-site.com/callback/ecpay-cvs', # 回傳網址
|
|
2451
|
+
)
|
|
2452
|
+
|
|
2453
|
+
# 建立物流訂單
|
|
2454
|
+
try:
|
|
2455
|
+
result = logistics.create_cvs_shipment(shipment_data)
|
|
2456
|
+
|
|
2457
|
+
if result.success:
|
|
2458
|
+
print(f"✓ 訂單建立成功")
|
|
2459
|
+
print(f" 物流編號: {result.all_pay_logistics_id}")
|
|
2460
|
+
print(f" 寄貨編號: {result.cvs_payment_no}")
|
|
2461
|
+
print(f" 驗證碼: {result.cvs_validation_no}")
|
|
2462
|
+
print(f" 托運單號: {result.booking_note}")
|
|
2463
|
+
else:
|
|
2464
|
+
print(f"✗ 訂單建立失敗")
|
|
2465
|
+
print(f" 錯誤代碼: {result.rtn_code}")
|
|
2466
|
+
print(f" 錯誤訊息: {result.rtn_msg}")
|
|
2467
|
+
except Exception as e:
|
|
2468
|
+
print(f"✗ 發生例外: {str(e)}")
|
|
2469
|
+
```
|
|
2470
|
+
|
|
2471
|
+
---
|
|
2472
|
+
|
|
2473
|
+
### 3. Create Home Delivery (ECPay)
|
|
2474
|
+
|
|
2475
|
+
#### TypeScript Example
|
|
2476
|
+
|
|
2477
|
+
```typescript
|
|
2478
|
+
interface CreateHomeShipmentRequest {
|
|
2479
|
+
merTradeNo: string;
|
|
2480
|
+
logisticsSubType: 'TCAT' | 'ECAN' | 'POST';
|
|
2481
|
+
goodsAmount: number;
|
|
2482
|
+
goodsName: string;
|
|
2483
|
+
senderName: string;
|
|
2484
|
+
senderCellPhone: string;
|
|
2485
|
+
senderZipCode: string;
|
|
2486
|
+
senderAddress: string;
|
|
2487
|
+
receiverName: string;
|
|
2488
|
+
receiverCellPhone: string;
|
|
2489
|
+
receiverZipCode: string;
|
|
2490
|
+
receiverAddress: string;
|
|
2491
|
+
temperature?: '0001' | '0002' | '0003';
|
|
2492
|
+
specification?: '0001' | '0002' | '0003' | '0004';
|
|
2493
|
+
distance?: '00' | '01' | '02' | '03';
|
|
2494
|
+
scheduledPickupTime?: '1' | '2' | '3' | '4';
|
|
2495
|
+
scheduledDeliveryTime?: '1' | '2' | '3' | '4' | '5';
|
|
2496
|
+
serverReplyURL: string;
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2499
|
+
class ECPayHomeLogistics extends ECPayLogistics {
|
|
2500
|
+
async createHomeShipment(params: CreateHomeShipmentRequest) {
|
|
2501
|
+
const tradeDate = new Date().toLocaleString('zh-TW', {
|
|
2502
|
+
year: 'numeric',
|
|
2503
|
+
month: '2-digit',
|
|
2504
|
+
day: '2-digit',
|
|
2505
|
+
hour: '2-digit',
|
|
2506
|
+
minute: '2-digit',
|
|
2507
|
+
second: '2-digit',
|
|
2508
|
+
hour12: false,
|
|
2509
|
+
}).replace(/\//g, '/');
|
|
2510
|
+
|
|
2511
|
+
const data: Record<string, any> = {
|
|
2512
|
+
MerchantID: this.config.merchantId,
|
|
2513
|
+
MerchantTradeNo: params.merTradeNo,
|
|
2514
|
+
MerchantTradeDate: tradeDate,
|
|
2515
|
+
LogisticsType: 'HOME',
|
|
2516
|
+
LogisticsSubType: params.logisticsSubType,
|
|
2517
|
+
GoodsAmount: params.goodsAmount,
|
|
2518
|
+
GoodsName: params.goodsName,
|
|
2519
|
+
SenderName: params.senderName,
|
|
2520
|
+
SenderCellPhone: params.senderCellPhone,
|
|
2521
|
+
SenderZipCode: params.senderZipCode,
|
|
2522
|
+
SenderAddress: params.senderAddress,
|
|
2523
|
+
ReceiverName: params.receiverName,
|
|
2524
|
+
ReceiverCellPhone: params.receiverCellPhone,
|
|
2525
|
+
ReceiverZipCode: params.receiverZipCode,
|
|
2526
|
+
ReceiverAddress: params.receiverAddress,
|
|
2527
|
+
ServerReplyURL: params.serverReplyURL,
|
|
2528
|
+
};
|
|
2529
|
+
|
|
2530
|
+
// Optional parameters
|
|
2531
|
+
if (params.temperature) data.Temperature = params.temperature;
|
|
2532
|
+
if (params.specification) data.Specification = params.specification;
|
|
2533
|
+
if (params.distance) data.Distance = params.distance;
|
|
2534
|
+
if (params.scheduledPickupTime) data.ScheduledPickupTime = params.scheduledPickupTime;
|
|
2535
|
+
if (params.scheduledDeliveryTime) data.ScheduledDeliveryTime = params.scheduledDeliveryTime;
|
|
2536
|
+
|
|
2537
|
+
data.CheckMacValue = this.generateCheckMacValue(data);
|
|
2538
|
+
|
|
2539
|
+
const response = await axios.post(
|
|
2540
|
+
`${this.baseUrl}/Express/Create`,
|
|
2541
|
+
new URLSearchParams(data),
|
|
2542
|
+
{
|
|
2543
|
+
headers: {
|
|
2544
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2545
|
+
},
|
|
2546
|
+
}
|
|
2547
|
+
);
|
|
2548
|
+
|
|
2549
|
+
const result: Record<string, string> = {};
|
|
2550
|
+
response.data.split('&').forEach((item: string) => {
|
|
2551
|
+
const [key, value] = item.split('=');
|
|
2552
|
+
if (key && value) {
|
|
2553
|
+
result[key] = decodeURIComponent(value);
|
|
2554
|
+
}
|
|
2555
|
+
});
|
|
2556
|
+
|
|
2557
|
+
if (result.RtnCode !== '300') {
|
|
2558
|
+
throw new Error(`Create shipment failed: ${result.RtnMsg}`);
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
return {
|
|
2562
|
+
allPayLogisticsID: result.AllPayLogisticsID,
|
|
2563
|
+
bookingNote: result.BookingNote,
|
|
2564
|
+
};
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
|
|
2568
|
+
// Usage Example
|
|
2569
|
+
const home = new ECPayHomeLogistics({
|
|
2570
|
+
merchantId: '2000132',
|
|
2571
|
+
hashKey: '5294y06JbISpM5x9',
|
|
2572
|
+
hashIV: 'v77hoKGq4kWxNNIS',
|
|
2573
|
+
});
|
|
2574
|
+
|
|
2575
|
+
const result = await home.createHomeShipment({
|
|
2576
|
+
merTradeNo: `HOME${Date.now()}`,
|
|
2577
|
+
logisticsSubType: 'TCAT',
|
|
2578
|
+
goodsAmount: 1000,
|
|
2579
|
+
goodsName: 'Electronics',
|
|
2580
|
+
senderName: 'Store Name',
|
|
2581
|
+
senderCellPhone: '0912345678',
|
|
2582
|
+
senderZipCode: '100',
|
|
2583
|
+
senderAddress: 'Taipei City, Zhongzheng Dist., XXX Road',
|
|
2584
|
+
receiverName: 'Customer Name',
|
|
2585
|
+
receiverCellPhone: '0987654321',
|
|
2586
|
+
receiverZipCode: '300',
|
|
2587
|
+
receiverAddress: 'Hsinchu City, East Dist., YYY Road',
|
|
2588
|
+
temperature: '0001', // Normal
|
|
2589
|
+
specification: '0001', // 60cm
|
|
2590
|
+
distance: '02', // Cross city
|
|
2591
|
+
scheduledDeliveryTime: '4', // No preference
|
|
2592
|
+
serverReplyURL: 'https://your-site.com/callback/ecpay-home',
|
|
2593
|
+
});
|
|
2594
|
+
|
|
2595
|
+
console.log('Logistics ID:', result.allPayLogisticsID);
|
|
2596
|
+
console.log('Booking Note:', result.bookingNote);
|
|
2597
|
+
|
|
2598
|
+
export { ECPayHomeLogistics };
|
|
2599
|
+
```
|
|
2600
|
+
|
|
2601
|
+
---
|
|
2602
|
+
|
|
2603
|
+
### 4. Query Shipment Status (ECPay)
|
|
2604
|
+
|
|
2605
|
+
#### TypeScript Example
|
|
2606
|
+
|
|
2607
|
+
```typescript
|
|
2608
|
+
class ECPayQueryLogistics extends ECPayLogistics {
|
|
2609
|
+
/**
|
|
2610
|
+
* Query shipment status
|
|
2611
|
+
*/
|
|
2612
|
+
async queryShipment(allPayLogisticsID: string) {
|
|
2613
|
+
const data = {
|
|
2614
|
+
MerchantID: this.config.merchantId,
|
|
2615
|
+
AllPayLogisticsID: allPayLogisticsID,
|
|
2616
|
+
TimeStamp: Math.floor(Date.now() / 1000).toString(),
|
|
2617
|
+
};
|
|
2618
|
+
|
|
2619
|
+
data['CheckMacValue'] = this.generateCheckMacValue(data);
|
|
2620
|
+
|
|
2621
|
+
const response = await axios.post(
|
|
2622
|
+
`${this.baseUrl}/Helper/QueryLogisticsTradeInfo/V2`,
|
|
2623
|
+
new URLSearchParams(data),
|
|
2624
|
+
{
|
|
2625
|
+
headers: {
|
|
2626
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
2627
|
+
},
|
|
2628
|
+
}
|
|
2629
|
+
);
|
|
2630
|
+
|
|
2631
|
+
const result: Record<string, string> = {};
|
|
2632
|
+
response.data.split('&').forEach((item: string) => {
|
|
2633
|
+
const [key, value] = item.split('=');
|
|
2634
|
+
if (key && value) {
|
|
2635
|
+
result[key] = decodeURIComponent(value);
|
|
2636
|
+
}
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
return {
|
|
2640
|
+
merchantTradeNo: result.MerchantTradeNo,
|
|
2641
|
+
goodsAmount: result.GoodsAmount,
|
|
2642
|
+
logisticsStatus: result.LogisticsStatus,
|
|
2643
|
+
receiverName: result.ReceiverName,
|
|
2644
|
+
receiverStoreID: result.ReceiverStoreID,
|
|
2645
|
+
updateStatusDate: result.UpdateStatusDate,
|
|
2646
|
+
};
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
// Usage Example
|
|
2651
|
+
const query = new ECPayQueryLogistics({
|
|
2652
|
+
merchantId: '2000132',
|
|
2653
|
+
hashKey: '5294y06JbISpM5x9',
|
|
2654
|
+
hashIV: 'v77hoKGq4kWxNNIS',
|
|
2655
|
+
});
|
|
2656
|
+
|
|
2657
|
+
const status = await query.queryShipment('1718546');
|
|
2658
|
+
|
|
2659
|
+
console.log(`Order: ${status.merchantTradeNo}`);
|
|
2660
|
+
console.log(`Status: ${status.logisticsStatus}`);
|
|
2661
|
+
console.log(`Store: ${status.receiverStoreID}`);
|
|
2662
|
+
|
|
2663
|
+
export { ECPayQueryLogistics };
|
|
2664
|
+
```
|
|
2665
|
+
|
|
2666
|
+
---
|
|
2667
|
+
|
|
2668
|
+
## Real-World Scenarios
|
|
2669
|
+
|
|
2670
|
+
### Scenario 1: E-commerce Checkout Flow
|
|
2671
|
+
|
|
2672
|
+
Complete integration from store selection to shipment creation.
|
|
2673
|
+
|
|
2674
|
+
```typescript
|
|
2675
|
+
class EcommerceLogistics {
|
|
2676
|
+
private logistics: NewebPayStoreMap & NewebPayShipment;
|
|
2677
|
+
|
|
2678
|
+
constructor() {
|
|
2679
|
+
this.logistics = new (class extends NewebPayStoreMap {})({
|
|
2680
|
+
merchantId: process.env.NEWEBPAY_MERCHANT_ID!,
|
|
2681
|
+
hashKey: process.env.NEWEBPAY_HASH_KEY!,
|
|
2682
|
+
hashIV: process.env.NEWEBPAY_HASH_IV!,
|
|
2683
|
+
isProduction: process.env.NODE_ENV === 'production',
|
|
2684
|
+
}) as any;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
/**
|
|
2688
|
+
* Step 1: Customer selects convenience store
|
|
2689
|
+
*/
|
|
2690
|
+
async showStoreSelection(orderId: string) {
|
|
2691
|
+
const html = await this.logistics.queryStoreMap({
|
|
2692
|
+
merchantOrderNo: orderId,
|
|
2693
|
+
lgsType: 'C2C',
|
|
2694
|
+
shipType: '1', // 7-ELEVEN
|
|
2695
|
+
returnURL: `https://your-site.com/api/store-selected`,
|
|
2696
|
+
extraData: orderId,
|
|
2697
|
+
});
|
|
2698
|
+
|
|
2699
|
+
return html;
|
|
2700
|
+
}
|
|
2701
|
+
|
|
2702
|
+
/**
|
|
2703
|
+
* Step 2: Handle store selection callback
|
|
2704
|
+
*/
|
|
2705
|
+
async handleStoreSelection(encryptData: string, hashData: string) {
|
|
2706
|
+
const storeInfo = this.logistics.handleStoreMapCallback(encryptData, hashData);
|
|
2707
|
+
|
|
2708
|
+
// Save store info to order
|
|
2709
|
+
await this.saveStoreToOrder(storeInfo.merchantOrderNo, {
|
|
2710
|
+
storeID: storeInfo.storeID,
|
|
2711
|
+
storeName: storeInfo.storeName,
|
|
2712
|
+
storeAddr: storeInfo.storeAddr,
|
|
2713
|
+
storeTel: storeInfo.storeTel,
|
|
2714
|
+
});
|
|
2715
|
+
|
|
2716
|
+
return storeInfo;
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2719
|
+
/**
|
|
2720
|
+
* Step 3: Create shipment after payment
|
|
2721
|
+
*/
|
|
2722
|
+
async createShipmentAfterPayment(orderId: string) {
|
|
2723
|
+
// Get order details from database
|
|
2724
|
+
const order = await this.getOrder(orderId);
|
|
2725
|
+
|
|
2726
|
+
// Create shipment
|
|
2727
|
+
const shipment = await (this.logistics as any).createShipment({
|
|
2728
|
+
merchantOrderNo: orderId,
|
|
2729
|
+
tradeType: 1, // COD
|
|
2730
|
+
userName: order.customer.name,
|
|
2731
|
+
userTel: order.customer.phone,
|
|
2732
|
+
userEmail: order.customer.email,
|
|
2733
|
+
storeID: order.logistics.storeID,
|
|
2734
|
+
amt: order.total,
|
|
2735
|
+
itemDesc: order.items.map((i: any) => i.name).join(', '),
|
|
2736
|
+
notifyURL: 'https://your-site.com/api/shipment-status',
|
|
2737
|
+
lgsType: 'C2C',
|
|
2738
|
+
shipType: '1',
|
|
2739
|
+
});
|
|
2740
|
+
|
|
2741
|
+
// Save trade number
|
|
2742
|
+
await this.saveTradeNumber(orderId, shipment.tradeNo);
|
|
2743
|
+
|
|
2744
|
+
return shipment;
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
/**
|
|
2748
|
+
* Step 4: Get shipment number for printing
|
|
2749
|
+
*/
|
|
2750
|
+
async getShipmentNumberForPrinting(orderIds: string[]) {
|
|
2751
|
+
const shipmentNums = await (this.logistics as any).getShipmentNumbers(orderIds);
|
|
2752
|
+
|
|
2753
|
+
return shipmentNums.success.map((item: any) => ({
|
|
2754
|
+
orderId: item.MerchantOrderNo,
|
|
2755
|
+
trackingNo: item.LgsNo,
|
|
2756
|
+
printCode: item.StorePrintNo,
|
|
2757
|
+
}));
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
// Helper methods
|
|
2761
|
+
private async saveStoreToOrder(orderId: string, storeInfo: any) {
|
|
2762
|
+
// Implementation
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
private async getOrder(orderId: string) {
|
|
2766
|
+
// Implementation
|
|
2767
|
+
return {} as any;
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
private async saveTradeNumber(orderId: string, tradeNo: string) {
|
|
2771
|
+
// Implementation
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
```
|
|
2775
|
+
|
|
2776
|
+
---
|
|
2777
|
+
|
|
2778
|
+
### Scenario 2: Batch Shipment Processing
|
|
2779
|
+
|
|
2780
|
+
Process multiple orders in batch.
|
|
2781
|
+
|
|
2782
|
+
```python
|
|
2783
|
+
"""Batch Shipment Processing - Python Example"""
|
|
2784
|
+
|
|
2785
|
+
from typing import List, Dict
|
|
2786
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
2787
|
+
|
|
2788
|
+
|
|
2789
|
+
class BatchLogisticsProcessor:
|
|
2790
|
+
"""Process multiple shipments in batch"""
|
|
2791
|
+
|
|
2792
|
+
def __init__(self, logistics: NewebPayShipment):
|
|
2793
|
+
self.logistics = logistics
|
|
2794
|
+
|
|
2795
|
+
def create_shipments_batch(
|
|
2796
|
+
self,
|
|
2797
|
+
orders: List[Dict[str, any]],
|
|
2798
|
+
max_workers: int = 5,
|
|
2799
|
+
) -> Dict[str, any]:
|
|
2800
|
+
"""Create shipments for multiple orders"""
|
|
2801
|
+
|
|
2802
|
+
results = {
|
|
2803
|
+
'success': [],
|
|
2804
|
+
'failed': [],
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
2808
|
+
futures = {
|
|
2809
|
+
executor.submit(
|
|
2810
|
+
self.logistics.create_shipment,
|
|
2811
|
+
**self._prepare_shipment_data(order)
|
|
2812
|
+
): order['order_id']
|
|
2813
|
+
for order in orders
|
|
2814
|
+
}
|
|
2815
|
+
|
|
2816
|
+
for future in as_completed(futures):
|
|
2817
|
+
order_id = futures[future]
|
|
2818
|
+
try:
|
|
2819
|
+
result = future.result()
|
|
2820
|
+
results['success'].append({
|
|
2821
|
+
'order_id': order_id,
|
|
2822
|
+
'trade_no': result['trade_no'],
|
|
2823
|
+
})
|
|
2824
|
+
except Exception as e:
|
|
2825
|
+
results['failed'].append({
|
|
2826
|
+
'order_id': order_id,
|
|
2827
|
+
'error': str(e),
|
|
2828
|
+
})
|
|
2829
|
+
|
|
2830
|
+
return results
|
|
2831
|
+
|
|
2832
|
+
def get_shipment_numbers_batch(
|
|
2833
|
+
self,
|
|
2834
|
+
order_ids: List[str],
|
|
2835
|
+
) -> Dict[str, List[Dict]]:
|
|
2836
|
+
"""Get shipment numbers for multiple orders (max 10 per request)"""
|
|
2837
|
+
|
|
2838
|
+
results = {
|
|
2839
|
+
'success': [],
|
|
2840
|
+
'failed': [],
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
# Split into batches of 10
|
|
2844
|
+
for i in range(0, len(order_ids), 10):
|
|
2845
|
+
batch = order_ids[i:i+10]
|
|
2846
|
+
|
|
2847
|
+
try:
|
|
2848
|
+
result = self.logistics.get_shipment_numbers(batch)
|
|
2849
|
+
results['success'].extend(result['success'])
|
|
2850
|
+
results['failed'].extend(result['error'])
|
|
2851
|
+
except Exception as e:
|
|
2852
|
+
results['failed'].extend([
|
|
2853
|
+
{'order_id': order_id, 'error': str(e)}
|
|
2854
|
+
for order_id in batch
|
|
2855
|
+
])
|
|
2856
|
+
|
|
2857
|
+
return results
|
|
2858
|
+
|
|
2859
|
+
def _prepare_shipment_data(self, order: Dict) -> Dict:
|
|
2860
|
+
"""Prepare shipment data from order"""
|
|
2861
|
+
return {
|
|
2862
|
+
'merchant_order_no': order['order_id'],
|
|
2863
|
+
'trade_type': 1 if order['cod'] else 3,
|
|
2864
|
+
'user_name': order['customer']['name'],
|
|
2865
|
+
'user_tel': order['customer']['phone'],
|
|
2866
|
+
'user_email': order['customer']['email'],
|
|
2867
|
+
'store_id': order['logistics']['store_id'],
|
|
2868
|
+
'amt': order['total'],
|
|
2869
|
+
'item_desc': order['description'],
|
|
2870
|
+
'lgs_type': 'C2C',
|
|
2871
|
+
'ship_type': '1',
|
|
2872
|
+
}
|
|
2873
|
+
|
|
2874
|
+
|
|
2875
|
+
# Usage Example
|
|
2876
|
+
logistics = NewebPayShipment(
|
|
2877
|
+
merchant_id='YOUR_MERCHANT_ID',
|
|
2878
|
+
hash_key='YOUR_HASH_KEY',
|
|
2879
|
+
hash_iv='YOUR_HASH_IV',
|
|
2880
|
+
)
|
|
2881
|
+
|
|
2882
|
+
processor = BatchLogisticsProcessor(logistics)
|
|
2883
|
+
|
|
2884
|
+
# Create shipments for 100 orders
|
|
2885
|
+
orders = [
|
|
2886
|
+
{
|
|
2887
|
+
'order_id': f'ORD{i:05d}',
|
|
2888
|
+
'cod': True,
|
|
2889
|
+
'customer': {
|
|
2890
|
+
'name': f'Customer {i}',
|
|
2891
|
+
'phone': '0912345678',
|
|
2892
|
+
'email': f'customer{i}@example.com',
|
|
2893
|
+
},
|
|
2894
|
+
'logistics': {
|
|
2895
|
+
'store_id': '123456',
|
|
2896
|
+
},
|
|
2897
|
+
'total': 1000 + i * 10,
|
|
2898
|
+
'description': f'Order {i} items',
|
|
2899
|
+
}
|
|
2900
|
+
for i in range(100)
|
|
2901
|
+
]
|
|
2902
|
+
|
|
2903
|
+
results = processor.create_shipments_batch(orders)
|
|
2904
|
+
|
|
2905
|
+
print(f"Success: {len(results['success'])}")
|
|
2906
|
+
print(f"Failed: {len(results['failed'])}")
|
|
2907
|
+
```
|
|
2908
|
+
|
|
2909
|
+
---
|
|
2910
|
+
|
|
2911
|
+
### Scenario 3: Order Modification Workflow
|
|
2912
|
+
|
|
2913
|
+
Handle customer requests to change delivery details.
|
|
2914
|
+
|
|
2915
|
+
```typescript
|
|
2916
|
+
class OrderModificationWorkflow {
|
|
2917
|
+
private query: NewebPayQueryShipment;
|
|
2918
|
+
private modify: NewebPayModifyShipment;
|
|
2919
|
+
private storeMap: NewebPayStoreMap;
|
|
2920
|
+
|
|
2921
|
+
constructor() {
|
|
2922
|
+
const config = {
|
|
2923
|
+
merchantId: process.env.NEWEBPAY_MERCHANT_ID!,
|
|
2924
|
+
hashKey: process.env.NEWEBPAY_HASH_KEY!,
|
|
2925
|
+
hashIV: process.env.NEWEBPAY_HASH_IV!,
|
|
2926
|
+
};
|
|
2927
|
+
|
|
2928
|
+
this.query = new NewebPayQueryShipment(config);
|
|
2929
|
+
this.modify = new NewebPayModifyShipment(config);
|
|
2930
|
+
this.storeMap = new NewebPayStoreMap(config);
|
|
2931
|
+
}
|
|
2932
|
+
|
|
2933
|
+
/**
|
|
2934
|
+
* Check if order can be modified
|
|
2935
|
+
*/
|
|
2936
|
+
async canModifyOrder(orderId: string): Promise<boolean> {
|
|
2937
|
+
const status = await this.query.queryShipment(orderId);
|
|
2938
|
+
|
|
2939
|
+
// Can only modify if not yet shipped
|
|
2940
|
+
const modifiableStatuses = ['0_1', '0_2', '0_3'];
|
|
2941
|
+
return modifiableStatuses.includes(status.retId);
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
/**
|
|
2945
|
+
* Change recipient information
|
|
2946
|
+
*/
|
|
2947
|
+
async changeRecipient(
|
|
2948
|
+
orderId: string,
|
|
2949
|
+
newRecipient: {
|
|
2950
|
+
name: string;
|
|
2951
|
+
phone: string;
|
|
2952
|
+
email: string;
|
|
2953
|
+
}
|
|
2954
|
+
) {
|
|
2955
|
+
// Check if can modify
|
|
2956
|
+
if (!(await this.canModifyOrder(orderId))) {
|
|
2957
|
+
throw new Error('Order cannot be modified at current status');
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
// Get current order info
|
|
2961
|
+
const current = await this.query.queryShipment(orderId);
|
|
2962
|
+
|
|
2963
|
+
// Modify order
|
|
2964
|
+
await this.modify.modifyShipment({
|
|
2965
|
+
merchantOrderNo: orderId,
|
|
2966
|
+
lgsType: current.lgsType as 'B2C' | 'C2C',
|
|
2967
|
+
shipType: current.shipType as '1' | '2' | '3' | '4',
|
|
2968
|
+
userName: newRecipient.name,
|
|
2969
|
+
userTel: newRecipient.phone,
|
|
2970
|
+
userEmail: newRecipient.email,
|
|
2971
|
+
});
|
|
2972
|
+
|
|
2973
|
+
return { success: true };
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
/**
|
|
2977
|
+
* Change pickup store
|
|
2978
|
+
*/
|
|
2979
|
+
async changePickupStore(orderId: string) {
|
|
2980
|
+
// Check if can modify
|
|
2981
|
+
if (!(await this.canModifyOrder(orderId))) {
|
|
2982
|
+
throw new Error('Order cannot be modified at current status');
|
|
2983
|
+
}
|
|
2984
|
+
|
|
2985
|
+
// Get current order info
|
|
2986
|
+
const current = await this.query.queryShipment(orderId);
|
|
2987
|
+
|
|
2988
|
+
// Show store map for new selection
|
|
2989
|
+
const html = await this.storeMap.queryStoreMap({
|
|
2990
|
+
merchantOrderNo: orderId,
|
|
2991
|
+
lgsType: current.lgsType as 'B2C' | 'C2C',
|
|
2992
|
+
shipType: current.shipType as '1' | '2' | '3' | '4',
|
|
2993
|
+
returnURL: `https://your-site.com/api/store-changed`,
|
|
2994
|
+
extraData: orderId,
|
|
2995
|
+
});
|
|
2996
|
+
|
|
2997
|
+
return html;
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
/**
|
|
3001
|
+
* Handle new store selection
|
|
3002
|
+
*/
|
|
3003
|
+
async handleStoreChange(encryptData: string, hashData: string) {
|
|
3004
|
+
const storeInfo = this.storeMap.handleStoreMapCallback(encryptData, hashData);
|
|
3005
|
+
|
|
3006
|
+
// Get current order info
|
|
3007
|
+
const current = await this.query.queryShipment(storeInfo.merchantOrderNo);
|
|
3008
|
+
|
|
3009
|
+
// Modify order with new store
|
|
3010
|
+
await this.modify.modifyShipment({
|
|
3011
|
+
merchantOrderNo: storeInfo.merchantOrderNo,
|
|
3012
|
+
lgsType: storeInfo.lgsType as 'B2C' | 'C2C',
|
|
3013
|
+
shipType: storeInfo.shipType as '1' | '2' | '3' | '4',
|
|
3014
|
+
storeID: storeInfo.storeID,
|
|
3015
|
+
});
|
|
3016
|
+
|
|
3017
|
+
// Update database
|
|
3018
|
+
await this.updateOrderStore(storeInfo.merchantOrderNo, {
|
|
3019
|
+
storeID: storeInfo.storeID,
|
|
3020
|
+
storeName: storeInfo.storeName,
|
|
3021
|
+
storeAddr: storeInfo.storeAddr,
|
|
3022
|
+
storeTel: storeInfo.storeTel,
|
|
3023
|
+
});
|
|
3024
|
+
|
|
3025
|
+
return storeInfo;
|
|
3026
|
+
}
|
|
3027
|
+
|
|
3028
|
+
private async updateOrderStore(orderId: string, storeInfo: any) {
|
|
3029
|
+
// Implementation
|
|
3030
|
+
}
|
|
3031
|
+
}
|
|
3032
|
+
```
|
|
3033
|
+
|
|
3034
|
+
---
|
|
3035
|
+
|
|
3036
|
+
## Error Handling
|
|
3037
|
+
|
|
3038
|
+
### Comprehensive Error Handler
|
|
3039
|
+
|
|
3040
|
+
```typescript
|
|
3041
|
+
class LogisticsError extends Error {
|
|
3042
|
+
constructor(
|
|
3043
|
+
public code: string,
|
|
3044
|
+
public message: string,
|
|
3045
|
+
public originalError?: any
|
|
3046
|
+
) {
|
|
3047
|
+
super(message);
|
|
3048
|
+
this.name = 'LogisticsError';
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
|
|
3052
|
+
class LogisticsErrorHandler {
|
|
3053
|
+
/**
|
|
3054
|
+
* Error code mapping
|
|
3055
|
+
*/
|
|
3056
|
+
private static errorMessages: Record<string, string> = {
|
|
3057
|
+
'1101': 'Failed to create logistics order',
|
|
3058
|
+
'1102': 'Merchant not found',
|
|
3059
|
+
'1103': 'Duplicate merchant order number',
|
|
3060
|
+
'1104': 'Logistics service not enabled',
|
|
3061
|
+
'1105': 'Store information invalid or empty',
|
|
3062
|
+
'1106': 'IP not allowed',
|
|
3063
|
+
'1107': 'Payment order not found',
|
|
3064
|
+
'1108': 'System error, cannot query logistics order',
|
|
3065
|
+
'1109': 'Logistics order not found',
|
|
3066
|
+
'1110': 'System error, cannot modify logistics order',
|
|
3067
|
+
'1111': 'Order status cannot be modified',
|
|
3068
|
+
'1112': 'Failed to modify logistics order',
|
|
3069
|
+
'1113': 'System error, cannot query tracking history',
|
|
3070
|
+
'1114': 'Insufficient prepaid balance',
|
|
3071
|
+
'1115': 'Failed to get shipment number',
|
|
3072
|
+
'1116': 'Shipment already created for this transaction',
|
|
3073
|
+
'2100': 'Data format error',
|
|
3074
|
+
'2101': 'Version error',
|
|
3075
|
+
'2102': 'UID_ cannot be empty',
|
|
3076
|
+
'2103': 'COD amount limit: 20000 NTD',
|
|
3077
|
+
'2104': 'No payment amount limit: 20000 NTD',
|
|
3078
|
+
'2105': 'Max 10 shipment numbers per request',
|
|
3079
|
+
'2106': 'Max labels exceeded for this provider',
|
|
3080
|
+
'4101': 'IP restricted',
|
|
3081
|
+
'4103': 'HashData_ verification failed',
|
|
3082
|
+
'4104': 'Encryption error, check Hash_Key and Hash_IV',
|
|
3083
|
+
};
|
|
3084
|
+
|
|
3085
|
+
/**
|
|
3086
|
+
* Handle API error
|
|
3087
|
+
*/
|
|
3088
|
+
static handleError(errorCode: string, originalError?: any): LogisticsError {
|
|
3089
|
+
const message = this.errorMessages[errorCode] || 'Unknown error';
|
|
3090
|
+
return new LogisticsError(errorCode, message, originalError);
|
|
3091
|
+
}
|
|
3092
|
+
|
|
3093
|
+
/**
|
|
3094
|
+
* Retry logic for transient errors
|
|
3095
|
+
*/
|
|
3096
|
+
static async withRetry<T>(
|
|
3097
|
+
fn: () => Promise<T>,
|
|
3098
|
+
maxRetries: number = 3,
|
|
3099
|
+
delay: number = 1000
|
|
3100
|
+
): Promise<T> {
|
|
3101
|
+
let lastError: any;
|
|
3102
|
+
|
|
3103
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
3104
|
+
try {
|
|
3105
|
+
return await fn();
|
|
3106
|
+
} catch (error: any) {
|
|
3107
|
+
lastError = error;
|
|
3108
|
+
|
|
3109
|
+
// Don't retry for non-transient errors
|
|
3110
|
+
if (this.isNonTransientError(error.code)) {
|
|
3111
|
+
throw error;
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
// Wait before retry
|
|
3115
|
+
if (i < maxRetries - 1) {
|
|
3116
|
+
await new Promise((resolve) => setTimeout(resolve, delay * (i + 1)));
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
throw lastError;
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
/**
|
|
3125
|
+
* Check if error is non-transient
|
|
3126
|
+
*/
|
|
3127
|
+
private static isNonTransientError(code: string): boolean {
|
|
3128
|
+
const nonTransientErrors = [
|
|
3129
|
+
'1102', // Merchant not found
|
|
3130
|
+
'1103', // Duplicate order number
|
|
3131
|
+
'1104', // Service not enabled
|
|
3132
|
+
'1106', // IP not allowed
|
|
3133
|
+
'2100', // Data format error
|
|
3134
|
+
'2101', // Version error
|
|
3135
|
+
'2102', // UID_ empty
|
|
3136
|
+
'2103', // Amount limit exceeded
|
|
3137
|
+
'2104', // Amount limit exceeded
|
|
3138
|
+
'2105', // Batch limit exceeded
|
|
3139
|
+
'2106', // Batch limit exceeded
|
|
3140
|
+
'4101', // IP restricted
|
|
3141
|
+
'4103', // Hash verification failed
|
|
3142
|
+
'4104', // Encryption error
|
|
3143
|
+
];
|
|
3144
|
+
|
|
3145
|
+
return nonTransientErrors.includes(code);
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
|
|
3149
|
+
// Usage Example
|
|
3150
|
+
try {
|
|
3151
|
+
const result = await LogisticsErrorHandler.withRetry(async () => {
|
|
3152
|
+
return await logistics.createShipment({
|
|
3153
|
+
/* ... */
|
|
3154
|
+
});
|
|
3155
|
+
});
|
|
3156
|
+
} catch (error) {
|
|
3157
|
+
if (error instanceof LogisticsError) {
|
|
3158
|
+
console.error(`Logistics Error [${error.code}]: ${error.message}`);
|
|
3159
|
+
|
|
3160
|
+
// Handle specific errors
|
|
3161
|
+
switch (error.code) {
|
|
3162
|
+
case '1103':
|
|
3163
|
+
// Duplicate order - use different order number
|
|
3164
|
+
break;
|
|
3165
|
+
case '2103':
|
|
3166
|
+
// Amount too high - split into multiple shipments
|
|
3167
|
+
break;
|
|
3168
|
+
case '4103':
|
|
3169
|
+
// Hash failed - check credentials
|
|
3170
|
+
break;
|
|
3171
|
+
default:
|
|
3172
|
+
// Generic error handling
|
|
3173
|
+
break;
|
|
3174
|
+
}
|
|
3175
|
+
}
|
|
3176
|
+
}
|
|
3177
|
+
```
|
|
3178
|
+
|
|
3179
|
+
---
|
|
3180
|
+
|
|
3181
|
+
**Total Lines**: 1400+
|
|
3182
|
+
|
|
3183
|
+
This comprehensive guide covers all major NewebPay Logistics integration scenarios with production-ready code examples.
|