taiwan-invoice-skill 2.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/assets/taiwan-invoice/EXAMPLES.md +633 -0
- package/assets/taiwan-invoice/references/AMEGO_API_REFERENCE.md +1115 -0
- package/assets/taiwan-invoice/references/ECPAY_API_REFERENCE.md +647 -0
- package/assets/taiwan-invoice/references/SMILEPAY_API_REFERENCE.md +492 -0
- package/assets/taiwan-invoice/scripts/generate-invoice-service.py +192 -0
- package/assets/taiwan-invoice/scripts/test-invoice-amounts.py +129 -0
- package/assets/templates/base/skill-content.md +361 -0
- package/assets/templates/platforms/antigravity.json +21 -0
- package/assets/templates/platforms/claude.json +22 -0
- package/assets/templates/platforms/codebuddy.json +18 -0
- package/assets/templates/platforms/codex.json +21 -0
- package/assets/templates/platforms/continue.json +21 -0
- package/assets/templates/platforms/copilot.json +18 -0
- package/assets/templates/platforms/cursor.json +21 -0
- package/assets/templates/platforms/gemini.json +21 -0
- package/assets/templates/platforms/kiro.json +18 -0
- package/assets/templates/platforms/opencode.json +18 -0
- package/assets/templates/platforms/qoder.json +18 -0
- package/assets/templates/platforms/roocode.json +18 -0
- package/assets/templates/platforms/trae.json +18 -0
- package/assets/templates/platforms/windsurf.json +21 -0
- package/dist/index.js +15428 -0
- package/package.json +56 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
# 台灣電子發票 Skill - 完整範例集
|
|
2
|
+
|
|
3
|
+
這份文件包含使用 `taiwan-invoice` skill 的完整實作範例。
|
|
4
|
+
|
|
5
|
+
## 目錄
|
|
6
|
+
|
|
7
|
+
1. [基礎範例](#基礎範例)
|
|
8
|
+
2. [進階範例](#進階範例)
|
|
9
|
+
3. [實戰場景](#實戰場景)
|
|
10
|
+
4. [常見錯誤與修正](#常見錯誤與修正)
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 基礎範例
|
|
15
|
+
|
|
16
|
+
### 範例 1: 開立簡單的 B2C 發票
|
|
17
|
+
|
|
18
|
+
**場景:** 客戶在網站購買商品,金額 1050 元
|
|
19
|
+
|
|
20
|
+
**Claude 提示詞:**
|
|
21
|
+
```
|
|
22
|
+
使用綠界測試環境開立 B2C 發票
|
|
23
|
+
- 金額: 1050 元
|
|
24
|
+
- 買受人: 王小明
|
|
25
|
+
- Email: test@example.com
|
|
26
|
+
- 商品: 測試商品 x1
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
**生成的程式碼:**
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import { ECPayInvoiceService } from '@/lib/services/ecpay-invoice-service'
|
|
33
|
+
|
|
34
|
+
async function issueSimpleInvoice() {
|
|
35
|
+
const service = new ECPayInvoiceService(false) // 測試環境
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const result = await service.issueInvoice('user-123', {
|
|
39
|
+
OrderId: `ORDER-${Date.now()}`,
|
|
40
|
+
TotalAmount: 1050,
|
|
41
|
+
IsB2B: false,
|
|
42
|
+
BuyerName: '王小明',
|
|
43
|
+
BuyerEmail: 'test@example.com',
|
|
44
|
+
ProductItem: [
|
|
45
|
+
{
|
|
46
|
+
Description: '測試商品',
|
|
47
|
+
Quantity: 1,
|
|
48
|
+
UnitPrice: 1050,
|
|
49
|
+
Amount: 1050,
|
|
50
|
+
}
|
|
51
|
+
],
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
if (result.success) {
|
|
55
|
+
console.log('[OK] 發票開立成功')
|
|
56
|
+
console.log('發票號碼:', result.invoiceNumber)
|
|
57
|
+
console.log('隨機碼:', result.randomNumber)
|
|
58
|
+
} else {
|
|
59
|
+
console.error('[ERROR] 開立失敗:', result.msg)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return result
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error('發票開立異常:', error)
|
|
65
|
+
throw error
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
### 範例 2: 開立 B2B 三聯式發票
|
|
73
|
+
|
|
74
|
+
**場景:** 公司採購,需要打統編
|
|
75
|
+
|
|
76
|
+
**Claude 提示詞:**
|
|
77
|
+
```
|
|
78
|
+
開立 B2B 發票
|
|
79
|
+
- 買方統編: 12345678
|
|
80
|
+
- 買方名稱: 測試公司有限公司
|
|
81
|
+
- 金額: 10500 元(含稅)
|
|
82
|
+
- 使用綠界
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
**生成的程式碼:**
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
async function issueB2BInvoice() {
|
|
89
|
+
const service = new ECPayInvoiceService(false)
|
|
90
|
+
|
|
91
|
+
// 計算 B2B 金額(分拆未稅與稅額)
|
|
92
|
+
const totalAmount = 10500
|
|
93
|
+
const taxAmount = Math.round(totalAmount - (totalAmount / 1.05))
|
|
94
|
+
const salesAmount = totalAmount - taxAmount
|
|
95
|
+
|
|
96
|
+
console.log('金額分析:')
|
|
97
|
+
console.log('- 未稅金額:', salesAmount) // 10000
|
|
98
|
+
console.log('- 稅額 (5%):', taxAmount) // 500
|
|
99
|
+
console.log('- 總計:', totalAmount) // 10500
|
|
100
|
+
|
|
101
|
+
const result = await service.issueInvoice('user-123', {
|
|
102
|
+
OrderId: `B2B-${Date.now()}`,
|
|
103
|
+
TotalAmount: totalAmount,
|
|
104
|
+
SalesAmount: salesAmount,
|
|
105
|
+
TaxAmount: taxAmount,
|
|
106
|
+
IsB2B: true,
|
|
107
|
+
BuyerIdentifier: '12345678',
|
|
108
|
+
BuyerName: '測試公司有限公司',
|
|
109
|
+
BuyerAddress: '台北市信義區信義路五段7號',
|
|
110
|
+
BuyerEmail: 'invoice@testcompany.com',
|
|
111
|
+
ProductItem: [
|
|
112
|
+
{
|
|
113
|
+
Description: '網站開發服務',
|
|
114
|
+
Quantity: 1,
|
|
115
|
+
UnitPrice: salesAmount, // B2B 使用未稅價
|
|
116
|
+
Amount: salesAmount,
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
return result
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
### 範例 3: 發票列印
|
|
128
|
+
|
|
129
|
+
**Claude 提示詞:**
|
|
130
|
+
```
|
|
131
|
+
實作發票列印功能,需要處理三種回應格式
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**生成的程式碼:**
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// API Route: app/api/v1/invoices/[id]/print/route.ts
|
|
138
|
+
|
|
139
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
140
|
+
import { InvoiceServiceFactory } from '@/lib/services/invoice-service-factory'
|
|
141
|
+
import { getServerSession } from 'next-auth'
|
|
142
|
+
|
|
143
|
+
export async function POST(
|
|
144
|
+
request: NextRequest,
|
|
145
|
+
{ params }: { params: { id: string } }
|
|
146
|
+
) {
|
|
147
|
+
const session = await getServerSession()
|
|
148
|
+
if (!session) {
|
|
149
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const recordId = params.id
|
|
153
|
+
|
|
154
|
+
// 取得發票記錄
|
|
155
|
+
const record = await prisma.financialRecord.findUnique({
|
|
156
|
+
where: { id: recordId },
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
if (!record || !record.invoiceNo) {
|
|
160
|
+
return NextResponse.json({ error: '發票不存在' }, { status: 404 })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// 使用開立時的服務商
|
|
164
|
+
const service = record.invoiceProvider
|
|
165
|
+
? InvoiceServiceFactory.getService(record.invoiceProvider)
|
|
166
|
+
: await InvoiceServiceFactory.getServiceForUser(session.user.id)
|
|
167
|
+
|
|
168
|
+
try {
|
|
169
|
+
const result = await service.printInvoice(
|
|
170
|
+
session.user.id,
|
|
171
|
+
record.invoiceNo
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
return NextResponse.json(result)
|
|
175
|
+
} catch (error) {
|
|
176
|
+
console.error('列印發票失敗:', error)
|
|
177
|
+
return NextResponse.json(
|
|
178
|
+
{ error: '列印發票失敗' },
|
|
179
|
+
{ status: 500 }
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
**前端處理:**
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// components/InvoicePrintButton.tsx
|
|
189
|
+
|
|
190
|
+
'use client'
|
|
191
|
+
|
|
192
|
+
import { useState } from 'react'
|
|
193
|
+
import { Button } from '@/components/ui/button'
|
|
194
|
+
|
|
195
|
+
export function InvoicePrintButton({ invoiceId }: { invoiceId: string }) {
|
|
196
|
+
const [loading, setLoading] = useState(false)
|
|
197
|
+
|
|
198
|
+
const handlePrint = async () => {
|
|
199
|
+
setLoading(true)
|
|
200
|
+
try {
|
|
201
|
+
const response = await fetch(`/api/v1/invoices/${invoiceId}/print`, {
|
|
202
|
+
method: 'POST',
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
const result = await response.json()
|
|
206
|
+
|
|
207
|
+
if (!result.success) {
|
|
208
|
+
alert('列印失敗: ' + result.msg)
|
|
209
|
+
return
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// 根據回應類型處理
|
|
213
|
+
switch (result.type) {
|
|
214
|
+
case 'html':
|
|
215
|
+
// 綠界:開新視窗顯示 HTML
|
|
216
|
+
const win = window.open('', '_blank')
|
|
217
|
+
if (win) {
|
|
218
|
+
win.document.write(result.htmlContent)
|
|
219
|
+
win.document.close()
|
|
220
|
+
}
|
|
221
|
+
break
|
|
222
|
+
|
|
223
|
+
case 'redirect':
|
|
224
|
+
// 速買配/光貿:跳轉 URL
|
|
225
|
+
window.open(result.printUrl || result.url, '_blank')
|
|
226
|
+
break
|
|
227
|
+
|
|
228
|
+
case 'form':
|
|
229
|
+
// 表單提交方式
|
|
230
|
+
const form = document.createElement('form')
|
|
231
|
+
form.method = 'POST'
|
|
232
|
+
form.action = result.formUrl
|
|
233
|
+
form.target = '_blank'
|
|
234
|
+
|
|
235
|
+
Object.entries(result.formParams || {}).forEach(([key, value]) => {
|
|
236
|
+
const input = document.createElement('input')
|
|
237
|
+
input.type = 'hidden'
|
|
238
|
+
input.name = key
|
|
239
|
+
input.value = value as string
|
|
240
|
+
form.appendChild(input)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
document.body.appendChild(form)
|
|
244
|
+
form.submit()
|
|
245
|
+
document.body.removeChild(form)
|
|
246
|
+
break
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
console.error('列印發票錯誤:', error)
|
|
250
|
+
alert('列印發票失敗')
|
|
251
|
+
} finally {
|
|
252
|
+
setLoading(false)
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return (
|
|
257
|
+
<Button onClick={handlePrint} disabled={loading}>
|
|
258
|
+
{loading ? '處理中...' : '列印發票'}
|
|
259
|
+
</Button>
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## 進階範例
|
|
267
|
+
|
|
268
|
+
### 範例 4: 服務商工廠模式
|
|
269
|
+
|
|
270
|
+
**Claude 提示詞:**
|
|
271
|
+
```
|
|
272
|
+
建立發票服務工廠,支援動態切換服務商
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
**生成的程式碼:**
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
// lib/services/invoice-service-factory.ts
|
|
279
|
+
|
|
280
|
+
import { InvoiceService } from './invoice-provider'
|
|
281
|
+
import { ECPayInvoiceService } from './ecpay-invoice-service'
|
|
282
|
+
import { SmilePayInvoiceService } from './smilepay-invoice-service'
|
|
283
|
+
import { AmegoInvoiceService } from './amego-service'
|
|
284
|
+
import { prisma } from '@/lib/prisma'
|
|
285
|
+
|
|
286
|
+
type InvoiceProvider = 'ECPAY' | 'SMILEPAY' | 'AMEGO'
|
|
287
|
+
|
|
288
|
+
export class InvoiceServiceFactory {
|
|
289
|
+
/**
|
|
290
|
+
* 根據服務商名稱取得服務實例
|
|
291
|
+
*/
|
|
292
|
+
static getService(
|
|
293
|
+
provider: InvoiceProvider,
|
|
294
|
+
isProd: boolean = false
|
|
295
|
+
): InvoiceService {
|
|
296
|
+
switch (provider) {
|
|
297
|
+
case 'ECPAY':
|
|
298
|
+
return new ECPayInvoiceService(isProd)
|
|
299
|
+
case 'SMILEPAY':
|
|
300
|
+
return new SmilePayInvoiceService(isProd)
|
|
301
|
+
case 'AMEGO':
|
|
302
|
+
return new AmegoInvoiceService(isProd)
|
|
303
|
+
default:
|
|
304
|
+
throw new Error(`不支援的服務商: ${provider}`)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 根據使用者設定取得服務實例
|
|
310
|
+
*/
|
|
311
|
+
static async getServiceForUser(userId: string): Promise<InvoiceService> {
|
|
312
|
+
const settings = await prisma.invoiceSettings.findUnique({
|
|
313
|
+
where: { userId },
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
if (!settings || !settings.defaultProvider) {
|
|
317
|
+
throw new Error('未設定預設發票服務商')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return this.getService(
|
|
321
|
+
settings.defaultProvider as InvoiceProvider,
|
|
322
|
+
settings.isProduction
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 取得所有可用的服務商
|
|
328
|
+
*/
|
|
329
|
+
static getAvailableProviders(): InvoiceProvider[] {
|
|
330
|
+
return ['ECPAY', 'SMILEPAY', 'AMEGO']
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
---
|
|
336
|
+
|
|
337
|
+
### 範例 5: 金額計算輔助函數
|
|
338
|
+
|
|
339
|
+
**Claude 提示詞:**
|
|
340
|
+
```
|
|
341
|
+
建立金額計算工具,自動處理 B2C/B2B 差異
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
**生成的程式碼:**
|
|
345
|
+
|
|
346
|
+
```typescript
|
|
347
|
+
// lib/utils/invoice-amounts.ts
|
|
348
|
+
|
|
349
|
+
export interface InvoiceAmounts {
|
|
350
|
+
salesAmount: number // 銷售額(B2C=含稅,B2B=未稅)
|
|
351
|
+
taxAmount: number // 稅額(B2C=0,B2B=5%)
|
|
352
|
+
totalAmount: number // 總計
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* 計算發票金額
|
|
357
|
+
* @param totalAmount 含稅總額
|
|
358
|
+
* @param isB2B 是否為 B2B 發票
|
|
359
|
+
*/
|
|
360
|
+
export function calculateInvoiceAmounts(
|
|
361
|
+
totalAmount: number,
|
|
362
|
+
isB2B: boolean
|
|
363
|
+
): InvoiceAmounts {
|
|
364
|
+
if (isB2B) {
|
|
365
|
+
// B2B: 分拆未稅金額與稅額
|
|
366
|
+
const taxAmount = Math.round(totalAmount - (totalAmount / 1.05))
|
|
367
|
+
const salesAmount = totalAmount - taxAmount
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
salesAmount,
|
|
371
|
+
taxAmount,
|
|
372
|
+
totalAmount,
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
// B2C: 含稅價,稅額為 0
|
|
376
|
+
return {
|
|
377
|
+
salesAmount: totalAmount,
|
|
378
|
+
taxAmount: 0,
|
|
379
|
+
totalAmount,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* 計算商品明細金額
|
|
386
|
+
*/
|
|
387
|
+
export function calculateProductAmounts(
|
|
388
|
+
items: Array<{ price: number; quantity: number }>,
|
|
389
|
+
isB2B: boolean
|
|
390
|
+
) {
|
|
391
|
+
const total = items.reduce((sum, item) => {
|
|
392
|
+
return sum + (item.price * item.quantity)
|
|
393
|
+
}, 0)
|
|
394
|
+
|
|
395
|
+
return calculateInvoiceAmounts(total, isB2B)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* 驗證金額計算是否正確
|
|
400
|
+
*/
|
|
401
|
+
export function validateAmounts(amounts: InvoiceAmounts): boolean {
|
|
402
|
+
const calculated = amounts.salesAmount + amounts.taxAmount
|
|
403
|
+
return Math.abs(calculated - amounts.totalAmount) < 0.01
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// 使用範例
|
|
407
|
+
const amounts = calculateInvoiceAmounts(1050, true)
|
|
408
|
+
console.log(amounts)
|
|
409
|
+
// { salesAmount: 1000, taxAmount: 50, totalAmount: 1050 }
|
|
410
|
+
|
|
411
|
+
const isValid = validateAmounts(amounts)
|
|
412
|
+
console.log('金額驗證:', isValid ? '[PASS]' : '[FAIL]')
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
---
|
|
416
|
+
|
|
417
|
+
## 實戰場景
|
|
418
|
+
|
|
419
|
+
### 場景 1: 電商結帳流程整合
|
|
420
|
+
|
|
421
|
+
**需求:** 用戶結帳時自動開立發票
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
// app/api/checkout/route.ts
|
|
425
|
+
|
|
426
|
+
import { InvoiceServiceFactory } from '@/lib/services/invoice-service-factory'
|
|
427
|
+
import { calculateInvoiceAmounts } from '@/lib/utils/invoice-amounts'
|
|
428
|
+
|
|
429
|
+
export async function POST(request: Request) {
|
|
430
|
+
const data = await request.json()
|
|
431
|
+
const { userId, orderId, cartItems, buyerInfo } = data
|
|
432
|
+
|
|
433
|
+
// 1. 計算訂單金額
|
|
434
|
+
const totalAmount = cartItems.reduce((sum, item) => {
|
|
435
|
+
return sum + (item.price * item.quantity)
|
|
436
|
+
}, 0)
|
|
437
|
+
|
|
438
|
+
// 2. 判斷是否為 B2B
|
|
439
|
+
const isB2B = Boolean(buyerInfo.taxId)
|
|
440
|
+
|
|
441
|
+
// 3. 計算發票金額
|
|
442
|
+
const amounts = calculateInvoiceAmounts(totalAmount, isB2B)
|
|
443
|
+
|
|
444
|
+
// 4. 取得發票服務
|
|
445
|
+
const invoiceService = await InvoiceServiceFactory.getServiceForUser(userId)
|
|
446
|
+
|
|
447
|
+
// 5. 開立發票
|
|
448
|
+
const invoiceResult = await invoiceService.issueInvoice(userId, {
|
|
449
|
+
OrderId: orderId,
|
|
450
|
+
TotalAmount: amounts.totalAmount,
|
|
451
|
+
SalesAmount: amounts.salesAmount,
|
|
452
|
+
TaxAmount: amounts.taxAmount,
|
|
453
|
+
IsB2B: isB2B,
|
|
454
|
+
BuyerIdentifier: buyerInfo.taxId || '0000000000',
|
|
455
|
+
BuyerName: buyerInfo.name,
|
|
456
|
+
BuyerEmail: buyerInfo.email,
|
|
457
|
+
ProductItem: cartItems.map(item => ({
|
|
458
|
+
Description: item.name,
|
|
459
|
+
Quantity: item.quantity,
|
|
460
|
+
UnitPrice: isB2B ? Math.round(item.price / 1.05) : item.price,
|
|
461
|
+
Amount: isB2B
|
|
462
|
+
? Math.round((item.price * item.quantity) / 1.05)
|
|
463
|
+
: item.price * item.quantity,
|
|
464
|
+
})),
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// 6. 儲存發票資訊
|
|
468
|
+
if (invoiceResult.success) {
|
|
469
|
+
await prisma.order.update({
|
|
470
|
+
where: { id: orderId },
|
|
471
|
+
data: {
|
|
472
|
+
invoiceNo: invoiceResult.invoiceNumber,
|
|
473
|
+
invoiceRandomNum: invoiceResult.randomNumber,
|
|
474
|
+
invoiceDate: new Date(),
|
|
475
|
+
},
|
|
476
|
+
})
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return Response.json({
|
|
480
|
+
success: invoiceResult.success,
|
|
481
|
+
invoiceNumber: invoiceResult.invoiceNumber,
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
```
|
|
485
|
+
|
|
486
|
+
---
|
|
487
|
+
|
|
488
|
+
### 場景 2: 批次開立發票
|
|
489
|
+
|
|
490
|
+
**需求:** 每天凌晨批次處理未開發票的訂單
|
|
491
|
+
|
|
492
|
+
```typescript
|
|
493
|
+
// scripts/batch-issue-invoices.ts
|
|
494
|
+
|
|
495
|
+
import { prisma } from '@/lib/prisma'
|
|
496
|
+
import { InvoiceServiceFactory } from '@/lib/services/invoice-service-factory'
|
|
497
|
+
|
|
498
|
+
async function batchIssueInvoices() {
|
|
499
|
+
console.log('開始批次開立發票...')
|
|
500
|
+
|
|
501
|
+
// 查詢未開發票的訂單
|
|
502
|
+
const pendingOrders = await prisma.order.findMany({
|
|
503
|
+
where: {
|
|
504
|
+
status: 'PAID',
|
|
505
|
+
invoiceNo: null,
|
|
506
|
+
},
|
|
507
|
+
include: {
|
|
508
|
+
user: true,
|
|
509
|
+
items: true,
|
|
510
|
+
},
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
console.log(`找到 ${pendingOrders.length} 筆待開立發票`)
|
|
514
|
+
|
|
515
|
+
let successCount = 0
|
|
516
|
+
let failCount = 0
|
|
517
|
+
|
|
518
|
+
for (const order of pendingOrders) {
|
|
519
|
+
try {
|
|
520
|
+
const service = await InvoiceServiceFactory.getServiceForUser(
|
|
521
|
+
order.userId
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
const result = await service.issueInvoice(order.userId, {
|
|
525
|
+
OrderId: order.id,
|
|
526
|
+
TotalAmount: order.totalAmount,
|
|
527
|
+
IsB2B: Boolean(order.buyerTaxId),
|
|
528
|
+
BuyerIdentifier: order.buyerTaxId || '0000000000',
|
|
529
|
+
BuyerName: order.buyerName,
|
|
530
|
+
BuyerEmail: order.buyerEmail,
|
|
531
|
+
ProductItem: order.items.map(item => ({
|
|
532
|
+
Description: item.productName,
|
|
533
|
+
Quantity: item.quantity,
|
|
534
|
+
UnitPrice: item.price,
|
|
535
|
+
Amount: item.price * item.quantity,
|
|
536
|
+
})),
|
|
537
|
+
})
|
|
538
|
+
|
|
539
|
+
if (result.success) {
|
|
540
|
+
await prisma.order.update({
|
|
541
|
+
where: { id: order.id },
|
|
542
|
+
data: {
|
|
543
|
+
invoiceNo: result.invoiceNumber,
|
|
544
|
+
invoiceRandomNum: result.randomNumber,
|
|
545
|
+
invoiceDate: new Date(),
|
|
546
|
+
},
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
successCount++
|
|
550
|
+
console.log(`[OK] ${order.id} 開立成功`)
|
|
551
|
+
} else {
|
|
552
|
+
failCount++
|
|
553
|
+
console.error(`[ERROR] ${order.id} 開立失敗:`, result.msg)
|
|
554
|
+
}
|
|
555
|
+
} catch (error) {
|
|
556
|
+
failCount++
|
|
557
|
+
console.error(`[ERROR] ${order.id} 發生錯誤:`, error)
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 避免 API 限流,間隔 1 秒
|
|
561
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
console.log('\n批次開立完成:')
|
|
565
|
+
console.log(`- 成功: ${successCount} 筆`)
|
|
566
|
+
console.log(`- 失敗: ${failCount} 筆`)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// 執行
|
|
570
|
+
batchIssueInvoices().catch(console.error)
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## 常見錯誤與修正
|
|
576
|
+
|
|
577
|
+
### 錯誤 1: 金額計算錯誤
|
|
578
|
+
|
|
579
|
+
**錯誤訊息:** `ECPay 10000016: 金額計算錯誤`
|
|
580
|
+
|
|
581
|
+
**原因:** B2B 發票使用含稅價
|
|
582
|
+
|
|
583
|
+
**修正前:**
|
|
584
|
+
```typescript
|
|
585
|
+
const result = await service.issueInvoice(userId, {
|
|
586
|
+
IsB2B: true,
|
|
587
|
+
TotalAmount: 1050,
|
|
588
|
+
SalesAmount: 1050, // 錯誤:應為未稅
|
|
589
|
+
TaxAmount: 0, // 錯誤:應為 50
|
|
590
|
+
})
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
**修正後:**
|
|
594
|
+
```typescript
|
|
595
|
+
const totalAmount = 1050
|
|
596
|
+
const taxAmount = Math.round(totalAmount - (totalAmount / 1.05))
|
|
597
|
+
const salesAmount = totalAmount - taxAmount
|
|
598
|
+
|
|
599
|
+
const result = await service.issueInvoice(userId, {
|
|
600
|
+
IsB2B: true,
|
|
601
|
+
TotalAmount: totalAmount, // 1050
|
|
602
|
+
SalesAmount: salesAmount, // 1000
|
|
603
|
+
TaxAmount: taxAmount, // 50
|
|
604
|
+
})
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
### 錯誤 2: 列印時查詢不到發票
|
|
608
|
+
|
|
609
|
+
**錯誤訊息:** `發票不存在`
|
|
610
|
+
|
|
611
|
+
**原因:** 使用錯誤的服務商查詢
|
|
612
|
+
|
|
613
|
+
**修正前:**
|
|
614
|
+
```typescript
|
|
615
|
+
// 使用當前預設服務商
|
|
616
|
+
const service = await InvoiceServiceFactory.getServiceForUser(userId)
|
|
617
|
+
const result = await service.printInvoice(userId, invoiceNo)
|
|
618
|
+
```
|
|
619
|
+
|
|
620
|
+
**修正後:**
|
|
621
|
+
```typescript
|
|
622
|
+
// 使用開立時的服務商
|
|
623
|
+
const record = await prisma.financialRecord.findUnique({
|
|
624
|
+
where: { invoiceNo }
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
const service = InvoiceServiceFactory.getService(record.invoiceProvider)
|
|
628
|
+
const result = await service.printInvoice(userId, invoiceNo)
|
|
629
|
+
```
|
|
630
|
+
|
|
631
|
+
---
|
|
632
|
+
|
|
633
|
+
**更多範例持續更新中...**
|