@gravito/satellite-invoice 0.1.5 → 0.2.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/REFACTOR_PLAN.md +238 -0
- package/dist/index.js +1097 -56
- package/package.json +4 -2
- package/package.json.bak +29 -0
- package/src/Application/Contexts/InvoiceAuditContext.ts +96 -0
- package/src/Application/Contexts/InvoiceCancellationContext.ts +60 -0
- package/src/Application/Contexts/InvoiceIssuanceContext.ts +42 -0
- package/src/Application/Roles/InvoiceCancellerRole.ts +97 -0
- package/src/Application/Roles/InvoiceIssuerRole.ts +72 -0
- package/src/Application/Roles/InvoiceTrackerRole.ts +112 -0
- package/src/Application/UseCases/CancelInvoice.ts +32 -0
- package/src/Application/UseCases/IssueInvoice.ts +13 -26
- package/src/Application/UseCases/QueryInvoiceStatus.ts +56 -0
- package/src/Domain/Contracts/IInvoiceRepository.ts +3 -0
- package/src/Domain/Entities/Invoice.ts +178 -20
- package/src/Domain/Errors/InvoiceError.ts +89 -0
- package/src/Domain/ValueObjects/InvoiceAmount.ts +86 -0
- package/src/Domain/ValueObjects/InvoiceNumber.ts +52 -0
- package/src/Domain/ValueObjects/InvoiceStatus.ts +131 -0
- package/src/Domain/ValueObjects/InvoiceTax.ts +71 -0
- package/src/Infrastructure/Persistence/AtlasInvoiceRepository.ts +135 -6
- package/src/Interface/Http/Controllers/AdminInvoiceController.ts +264 -18
- package/src/index.ts +57 -3
- package/tests/Application/Contexts/InvoiceAuditContext.test.ts +198 -0
- package/tests/Application/Contexts/InvoiceCancellationContext.test.ts +232 -0
- package/tests/Application/Contexts/InvoiceIssuanceContext.test.ts +180 -0
- package/tests/Application/Roles/InvoiceCancellerRole.test.ts +109 -0
- package/tests/Application/Roles/InvoiceIssuerRole.test.ts +66 -0
- package/tests/Application/Roles/InvoiceTrackerRole.test.ts +126 -0
- package/tests/Application/UseCases/CancelInvoice.test.ts +175 -0
- package/tests/Application/UseCases/IssueInvoice.test.ts +169 -0
- package/tests/Application/UseCases/QueryInvoiceStatus.test.ts +191 -0
- package/tests/Domain/Errors/InvoiceError.test.ts +96 -0
- package/tests/Domain/ValueObjects/InvoiceAmount.test.ts +84 -0
- package/tests/Domain/ValueObjects/InvoiceNumber.test.ts +60 -0
- package/tests/Domain/ValueObjects/InvoiceStatus.test.ts +89 -0
- package/tests/Domain/ValueObjects/InvoiceTax.test.ts +66 -0
- package/tests/Interface/Http/Controllers/AdminInvoiceController.test.ts +294 -0
- package/tests/domain.test.ts +244 -0
- package/dist/index.d.ts +0 -8
package/package.json
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gravito/satellite-invoice",
|
|
3
|
-
"
|
|
3
|
+
"sideEffects": false,
|
|
4
|
+
"version": "0.2.0",
|
|
4
5
|
"type": "module",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"types": "dist/index.d.ts",
|
|
7
8
|
"scripts": {
|
|
8
|
-
"build": "tsup src/index.ts --format esm --
|
|
9
|
+
"build": "tsup src/index.ts --format esm --clean --external @gravito/enterprise --external @gravito/atlas --external @gravito/core",
|
|
10
|
+
"build:dts": "tsup src/index.ts --format esm --dts --clean --external @gravito/enterprise --external @gravito/atlas --external @gravito/core",
|
|
9
11
|
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
|
|
10
12
|
},
|
|
11
13
|
"dependencies": {
|
package/package.json.bak
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gravito/satellite-invoice",
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsup src/index.ts --format esm --dts --clean --external @gravito/enterprise --external @gravito/atlas --external @gravito/core",
|
|
9
|
+
"typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@gravito/enterprise": "workspace:*",
|
|
13
|
+
"@gravito/atlas": "workspace:*",
|
|
14
|
+
"@gravito/core": "workspace:*"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"tsup": "^8.3.5",
|
|
18
|
+
"typescript": "^5.9.3"
|
|
19
|
+
},
|
|
20
|
+
"module": "dist/index.js",
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/gravito-framework/gravito.git",
|
|
27
|
+
"directory": "satellites/invoice"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { IInvoiceRepository } from '../../Domain/Contracts/IInvoiceRepository'
|
|
2
|
+
import { InvoiceNotFoundError } from '../../Domain/Errors/InvoiceError'
|
|
3
|
+
import type { InvoiceTrackerRole } from '../Roles/InvoiceTrackerRole'
|
|
4
|
+
|
|
5
|
+
export interface QueryStatusInput {
|
|
6
|
+
invoiceId: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ReportInput {
|
|
10
|
+
startDate: Date
|
|
11
|
+
endDate: Date
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuditTrailOutput {
|
|
15
|
+
invoiceId: string
|
|
16
|
+
logs: Array<{
|
|
17
|
+
action: string
|
|
18
|
+
timestamp: Date
|
|
19
|
+
details?: Record<string, any>
|
|
20
|
+
}>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 發票審計上下文 (DCI)
|
|
25
|
+
* 編排發票查詢和報告的流程
|
|
26
|
+
*/
|
|
27
|
+
export class InvoiceAuditContext {
|
|
28
|
+
constructor(
|
|
29
|
+
private repository: IInvoiceRepository,
|
|
30
|
+
private tracker: InvoiceTrackerRole
|
|
31
|
+
) {}
|
|
32
|
+
|
|
33
|
+
async queryStatus(input: QueryStatusInput) {
|
|
34
|
+
const invoice = await this.repository.findById(input.invoiceId)
|
|
35
|
+
if (!invoice) {
|
|
36
|
+
throw new InvoiceNotFoundError(input.invoiceId)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await this.tracker.trackStatus(invoice)
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
id: invoice.id,
|
|
43
|
+
invoiceNumber: invoice.invoiceNumberObject.value,
|
|
44
|
+
orderId: invoice.orderId,
|
|
45
|
+
status: invoice.statusObject.value,
|
|
46
|
+
amount: invoice.amountObject.value,
|
|
47
|
+
createdAt: invoice.createdAt,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async generateReport(input: ReportInput) {
|
|
52
|
+
const invoices = await this.repository.findByDateRange(input.startDate, input.endDate)
|
|
53
|
+
|
|
54
|
+
// 追蹤這些發票以供審計使用
|
|
55
|
+
for (const invoice of invoices) {
|
|
56
|
+
await this.tracker.trackStatus(invoice)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
period: { startDate: input.startDate, endDate: input.endDate },
|
|
61
|
+
summary: {
|
|
62
|
+
total: invoices.length,
|
|
63
|
+
issued: invoices.filter((inv) => inv.statusObject.isIssued()).length,
|
|
64
|
+
cancelled: invoices.filter((inv) => inv.statusObject.isCancelled()).length,
|
|
65
|
+
returned: invoices.filter((inv) => inv.statusObject.isReturned()).length,
|
|
66
|
+
totalAmount: invoices.reduce((sum: number, inv) => sum + inv.amountObject.value, 0),
|
|
67
|
+
},
|
|
68
|
+
invoices: invoices.map((inv) => ({
|
|
69
|
+
id: inv.id,
|
|
70
|
+
number: inv.invoiceNumberObject.value,
|
|
71
|
+
orderId: inv.orderId,
|
|
72
|
+
amount: inv.amountObject.value,
|
|
73
|
+
status: inv.statusObject.value,
|
|
74
|
+
createdAt: inv.createdAt,
|
|
75
|
+
})),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async getAuditTrail(input: { invoiceId: string }): Promise<AuditTrailOutput> {
|
|
80
|
+
const invoice = await this.repository.findById(input.invoiceId)
|
|
81
|
+
if (!invoice) {
|
|
82
|
+
throw new InvoiceNotFoundError(input.invoiceId)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const logs = await this.tracker.auditTrail(input.invoiceId)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
invoiceId: input.invoiceId,
|
|
89
|
+
logs: logs.map((log) => ({
|
|
90
|
+
action: log.action,
|
|
91
|
+
timestamp: log.timestamp,
|
|
92
|
+
details: log.details,
|
|
93
|
+
})),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { IInvoiceRepository } from '../../Domain/Contracts/IInvoiceRepository'
|
|
2
|
+
import { InvalidCancellationError, InvoiceNotFoundError } from '../../Domain/Errors/InvoiceError'
|
|
3
|
+
import type { CancellationReason, InvoiceCancellerRole } from '../Roles/InvoiceCancellerRole'
|
|
4
|
+
|
|
5
|
+
export interface CancelInvoiceInput {
|
|
6
|
+
invoiceId: string
|
|
7
|
+
reason: CancellationReason
|
|
8
|
+
notes?: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CancelInvoiceOutput {
|
|
12
|
+
id: string
|
|
13
|
+
invoiceNumber: string
|
|
14
|
+
previousStatus: string
|
|
15
|
+
newStatus: string
|
|
16
|
+
cancelledAt: Date
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 發票取消上下文 (DCI)
|
|
21
|
+
* 編排發票取消的完整流程
|
|
22
|
+
*/
|
|
23
|
+
export class InvoiceCancellationContext {
|
|
24
|
+
constructor(
|
|
25
|
+
private repository: IInvoiceRepository,
|
|
26
|
+
private canceller: InvoiceCancellerRole
|
|
27
|
+
) {}
|
|
28
|
+
|
|
29
|
+
async orchestrate(input: CancelInvoiceInput): Promise<CancelInvoiceOutput> {
|
|
30
|
+
// 1. 查詢發票
|
|
31
|
+
const invoice = await this.repository.findById(input.invoiceId)
|
|
32
|
+
if (!invoice) {
|
|
33
|
+
throw new InvoiceNotFoundError(input.invoiceId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 2. 驗證是否可取消
|
|
37
|
+
if (!invoice.statusObject.isIssued()) {
|
|
38
|
+
throw new InvalidCancellationError(`Cannot cancel ${invoice.statusObject.value} invoice`)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 3. 執行取消
|
|
42
|
+
const cancelled = await this.canceller.cancel({
|
|
43
|
+
invoice,
|
|
44
|
+
reason: input.reason,
|
|
45
|
+
notes: input.notes,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
// 4. 保存
|
|
49
|
+
await this.repository.save(cancelled)
|
|
50
|
+
|
|
51
|
+
// 5. 返回結果
|
|
52
|
+
return {
|
|
53
|
+
id: cancelled.id,
|
|
54
|
+
invoiceNumber: cancelled.invoiceNumberObject.value,
|
|
55
|
+
previousStatus: 'ISSUED',
|
|
56
|
+
newStatus: cancelled.statusObject.value,
|
|
57
|
+
cancelledAt: new Date(),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { IInvoiceRepository } from '../../Domain/Contracts/IInvoiceRepository'
|
|
2
|
+
import type { Invoice } from '../../Domain/Entities/Invoice'
|
|
3
|
+
import { DuplicateInvoiceError } from '../../Domain/Errors/InvoiceError'
|
|
4
|
+
import type { InvoiceIssuerRole } from '../Roles/InvoiceIssuerRole'
|
|
5
|
+
|
|
6
|
+
export interface IssueInvoiceInput {
|
|
7
|
+
orderId: string
|
|
8
|
+
amount: number
|
|
9
|
+
currency?: string
|
|
10
|
+
buyerIdentifier?: string
|
|
11
|
+
carrierId?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 發票開立上下文 (DCI)
|
|
16
|
+
* 編排發票開立的完整流程
|
|
17
|
+
*/
|
|
18
|
+
export class InvoiceIssuanceContext {
|
|
19
|
+
constructor(
|
|
20
|
+
private repository: IInvoiceRepository,
|
|
21
|
+
private issuer: InvoiceIssuerRole
|
|
22
|
+
) {}
|
|
23
|
+
|
|
24
|
+
async orchestrate(input: IssueInvoiceInput): Promise<Invoice> {
|
|
25
|
+
// 1. 檢查訂單是否已有發票
|
|
26
|
+
const existing = await this.repository.findByOrderId(input.orderId)
|
|
27
|
+
if (existing) {
|
|
28
|
+
throw new DuplicateInvoiceError(input.orderId)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// 2. 生成新發票
|
|
32
|
+
const invoice = await this.issuer.issueInvoice({
|
|
33
|
+
...input,
|
|
34
|
+
currency: input.currency ?? 'TWD',
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
// 3. 保存發票
|
|
38
|
+
await this.repository.save(invoice)
|
|
39
|
+
|
|
40
|
+
return invoice
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { Invoice } from '../../Domain/Entities/Invoice'
|
|
2
|
+
|
|
3
|
+
export type CancellationReason =
|
|
4
|
+
| 'CUSTOMER_REQUEST'
|
|
5
|
+
| 'PAYMENT_FAILED'
|
|
6
|
+
| 'ORDER_CANCELLED'
|
|
7
|
+
| 'DUPLICATE'
|
|
8
|
+
| 'OTHER'
|
|
9
|
+
|
|
10
|
+
export interface CancelInvoiceInput {
|
|
11
|
+
invoice: Invoice
|
|
12
|
+
reason: CancellationReason
|
|
13
|
+
notes?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CancellationRecord {
|
|
17
|
+
invoiceId: string
|
|
18
|
+
reason: CancellationReason
|
|
19
|
+
notes?: string
|
|
20
|
+
timestamp: Date
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 發票取消者角色
|
|
25
|
+
*/
|
|
26
|
+
export interface InvoiceCancellerRole {
|
|
27
|
+
cancel(input: CancelInvoiceInput): Promise<Invoice>
|
|
28
|
+
validateCancellationEligibility(invoice: Invoice): boolean
|
|
29
|
+
recordCancellationReason(
|
|
30
|
+
invoiceId: string,
|
|
31
|
+
reason: CancellationReason,
|
|
32
|
+
notes?: string
|
|
33
|
+
): CancellationRecord
|
|
34
|
+
notifyRelatedServices(invoiceId: string): Promise<void>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 預設發票取消者實現
|
|
39
|
+
*/
|
|
40
|
+
export class DefaultInvoiceCanceller implements InvoiceCancellerRole {
|
|
41
|
+
private cancellationRecords: Map<string, CancellationRecord> = new Map()
|
|
42
|
+
|
|
43
|
+
validateCancellationEligibility(invoice: Invoice): boolean {
|
|
44
|
+
return invoice.statusObject.canBeCancelled()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
recordCancellationReason(
|
|
48
|
+
invoiceId: string,
|
|
49
|
+
reason: CancellationReason,
|
|
50
|
+
notes?: string
|
|
51
|
+
): CancellationRecord {
|
|
52
|
+
const record: CancellationRecord = {
|
|
53
|
+
invoiceId,
|
|
54
|
+
reason,
|
|
55
|
+
notes,
|
|
56
|
+
timestamp: new Date(),
|
|
57
|
+
}
|
|
58
|
+
this.cancellationRecords.set(invoiceId, record)
|
|
59
|
+
return record
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
getCancellationRecord(invoiceId: string): CancellationRecord | undefined {
|
|
63
|
+
return this.cancellationRecords.get(invoiceId)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async notifyRelatedServices(invoiceId: string): Promise<void> {
|
|
67
|
+
const record = this.cancellationRecords.get(invoiceId)
|
|
68
|
+
if (!record) {
|
|
69
|
+
throw new Error(`No cancellation record found for invoice ${invoiceId}`)
|
|
70
|
+
}
|
|
71
|
+
// 實際實現應該透過 event bus 通知相關服務
|
|
72
|
+
// 這裡只是佔位符
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async cancel(input: CancelInvoiceInput): Promise<Invoice> {
|
|
76
|
+
// 驗證是否可取消
|
|
77
|
+
if (!this.validateCancellationEligibility(input.invoice)) {
|
|
78
|
+
throw new Error(`Invoice ${input.invoice.id} cannot be cancelled`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 記錄取消
|
|
82
|
+
this.recordCancellationReason(input.invoice.id, input.reason, input.notes)
|
|
83
|
+
|
|
84
|
+
// 通知相關服務
|
|
85
|
+
await this.notifyRelatedServices(input.invoice.id)
|
|
86
|
+
|
|
87
|
+
// 執行取消
|
|
88
|
+
return input.invoice.cancel()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 注入函式 - 用於 DI 容器
|
|
94
|
+
*/
|
|
95
|
+
export function injectInvoiceCanceller(): InvoiceCancellerRole {
|
|
96
|
+
return new DefaultInvoiceCanceller()
|
|
97
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Invoice } from '../../Domain/Entities/Invoice'
|
|
2
|
+
import { InvalidOrderIdError } from '../../Domain/Errors/InvoiceError'
|
|
3
|
+
import { InvoiceNumber } from '../../Domain/ValueObjects/InvoiceNumber'
|
|
4
|
+
import { InvoiceTax } from '../../Domain/ValueObjects/InvoiceTax'
|
|
5
|
+
|
|
6
|
+
export interface IssueInvoiceInput {
|
|
7
|
+
orderId: string
|
|
8
|
+
amount: number
|
|
9
|
+
currency?: string
|
|
10
|
+
buyerIdentifier?: string
|
|
11
|
+
carrierId?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IssueInvoiceOutput {
|
|
15
|
+
invoice: Invoice
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 發票發行者角色
|
|
20
|
+
*/
|
|
21
|
+
export interface InvoiceIssuerRole {
|
|
22
|
+
issueInvoice(input: IssueInvoiceInput): Promise<Invoice>
|
|
23
|
+
generateInvoiceNumber(): InvoiceNumber
|
|
24
|
+
calculateTax(amount: number, rate?: number): InvoiceTax
|
|
25
|
+
validateOrderForInvoicing(orderId: string): Promise<boolean>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 預設發票發行者實現
|
|
30
|
+
*/
|
|
31
|
+
export class DefaultInvoiceIssuer implements InvoiceIssuerRole {
|
|
32
|
+
generateInvoiceNumber(): InvoiceNumber {
|
|
33
|
+
return InvoiceNumber.generate()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
calculateTax(amount: number, rate = 0.05): InvoiceTax {
|
|
37
|
+
return InvoiceTax.calculate(amount, rate)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async validateOrderForInvoicing(orderId: string): Promise<boolean> {
|
|
41
|
+
// 基本驗證 - 檢查 orderId 是否為有效字符串
|
|
42
|
+
return typeof orderId === 'string' && orderId.length > 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async issueInvoice(input: IssueInvoiceInput): Promise<Invoice> {
|
|
46
|
+
// 驗證 orderId
|
|
47
|
+
if (!(await this.validateOrderForInvoicing(input.orderId))) {
|
|
48
|
+
throw new InvalidOrderIdError(input.orderId)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const invoiceNumber = this.generateInvoiceNumber()
|
|
52
|
+
const tax = this.calculateTax(input.amount, 0.05)
|
|
53
|
+
|
|
54
|
+
return Invoice.create({
|
|
55
|
+
orderId: input.orderId,
|
|
56
|
+
invoiceNumber: invoiceNumber.value,
|
|
57
|
+
amount: input.amount,
|
|
58
|
+
currency: input.currency ?? 'TWD',
|
|
59
|
+
tax: tax.amount,
|
|
60
|
+
taxRate: tax.rate,
|
|
61
|
+
buyerIdentifier: input.buyerIdentifier,
|
|
62
|
+
carrierId: input.carrierId,
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 注入函式 - 用於 DI 容器
|
|
69
|
+
*/
|
|
70
|
+
export function injectInvoiceIssuer(): InvoiceIssuerRole {
|
|
71
|
+
return new DefaultInvoiceIssuer()
|
|
72
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Invoice } from '../../Domain/Entities/Invoice'
|
|
2
|
+
|
|
3
|
+
export interface AuditLog {
|
|
4
|
+
invoiceId: string
|
|
5
|
+
action: string
|
|
6
|
+
timestamp: Date
|
|
7
|
+
details?: Record<string, any>
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ReportSummary {
|
|
11
|
+
total: number
|
|
12
|
+
issued: number
|
|
13
|
+
cancelled: number
|
|
14
|
+
returned: number
|
|
15
|
+
totalAmount: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ReportData {
|
|
19
|
+
summary: ReportSummary
|
|
20
|
+
invoices: Array<{
|
|
21
|
+
id: string
|
|
22
|
+
number: string
|
|
23
|
+
orderId: string
|
|
24
|
+
amount: number
|
|
25
|
+
status: string
|
|
26
|
+
createdAt: Date
|
|
27
|
+
}>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 發票追蹤者角色
|
|
32
|
+
*/
|
|
33
|
+
export interface InvoiceTrackerRole {
|
|
34
|
+
trackStatus(invoice: Invoice): Promise<void>
|
|
35
|
+
trackStatus(invoiceId: string): Promise<void>
|
|
36
|
+
trackInvoiceStatus(invoiceId: string): Promise<string | null>
|
|
37
|
+
generateReport(startDate: Date, endDate: Date): Promise<ReportData>
|
|
38
|
+
recordAuditLog(log: AuditLog): void
|
|
39
|
+
auditTrail(invoiceId: string): Promise<AuditLog[]>
|
|
40
|
+
trackInvoice(invoice: Invoice): void
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 預設發票追蹤者實現
|
|
45
|
+
*/
|
|
46
|
+
export class DefaultInvoiceTracker implements InvoiceTrackerRole {
|
|
47
|
+
private invoices: Map<string, Invoice> = new Map()
|
|
48
|
+
private auditLogs: Map<string, AuditLog[]> = new Map()
|
|
49
|
+
|
|
50
|
+
trackInvoice(invoice: Invoice): void {
|
|
51
|
+
this.invoices.set(invoice.id, invoice)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async trackStatus(invoiceOrId: Invoice | string): Promise<void> {
|
|
55
|
+
// 接受 Invoice 或 string(invoiceId)
|
|
56
|
+
if (typeof invoiceOrId === 'string') {
|
|
57
|
+
// 如果是字符串,暫時不做任何操作
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
// 追蹤發票狀態
|
|
61
|
+
this.trackInvoice(invoiceOrId)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async trackInvoiceStatus(invoiceId: string): Promise<string | null> {
|
|
65
|
+
const invoice = this.invoices.get(invoiceId)
|
|
66
|
+
return invoice ? invoice.status : null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
recordAuditLog(log: AuditLog): void {
|
|
70
|
+
if (!this.auditLogs.has(log.invoiceId)) {
|
|
71
|
+
this.auditLogs.set(log.invoiceId, [])
|
|
72
|
+
}
|
|
73
|
+
this.auditLogs.get(log.invoiceId)?.push(log)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async auditTrail(invoiceId: string): Promise<AuditLog[]> {
|
|
77
|
+
return this.auditLogs.get(invoiceId) ?? []
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async generateReport(startDate: Date, endDate: Date): Promise<ReportData> {
|
|
81
|
+
const invoices = Array.from(this.invoices.values()).filter(
|
|
82
|
+
(inv) => inv.createdAt >= startDate && inv.createdAt <= endDate
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const summary: ReportSummary = {
|
|
86
|
+
total: invoices.length,
|
|
87
|
+
issued: invoices.filter((inv) => inv.statusObject.isIssued()).length,
|
|
88
|
+
cancelled: invoices.filter((inv) => inv.statusObject.isCancelled()).length,
|
|
89
|
+
returned: invoices.filter((inv) => inv.statusObject.isReturned()).length,
|
|
90
|
+
totalAmount: invoices.reduce((sum, inv) => sum + inv.amountObject.value, 0),
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
summary,
|
|
95
|
+
invoices: invoices.map((inv) => ({
|
|
96
|
+
id: inv.id,
|
|
97
|
+
number: inv.invoiceNumberObject.value,
|
|
98
|
+
orderId: inv.orderId,
|
|
99
|
+
amount: inv.amountObject.value,
|
|
100
|
+
status: inv.statusObject.value,
|
|
101
|
+
createdAt: inv.createdAt,
|
|
102
|
+
})),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 注入函式 - 用於 DI 容器
|
|
109
|
+
*/
|
|
110
|
+
export function injectInvoiceTracker(): InvoiceTrackerRole {
|
|
111
|
+
return new DefaultInvoiceTracker()
|
|
112
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { UseCase } from '@gravito/enterprise'
|
|
2
|
+
import type { IInvoiceRepository } from '../../Domain/Contracts/IInvoiceRepository'
|
|
3
|
+
import {
|
|
4
|
+
type CancelInvoiceInput,
|
|
5
|
+
type CancelInvoiceOutput,
|
|
6
|
+
InvoiceCancellationContext,
|
|
7
|
+
} from '../Contexts/InvoiceCancellationContext'
|
|
8
|
+
import { type CancellationReason, DefaultInvoiceCanceller } from '../Roles/InvoiceCancellerRole'
|
|
9
|
+
|
|
10
|
+
export interface CancelInvoiceUseCaseInput {
|
|
11
|
+
invoiceId: string
|
|
12
|
+
reason: CancellationReason
|
|
13
|
+
notes?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 發票取消 UseCase(薄殼委派)
|
|
18
|
+
* 委派到 InvoiceCancellationContext 進行實際業務流程處理
|
|
19
|
+
*/
|
|
20
|
+
export class CancelInvoice extends UseCase<CancelInvoiceUseCaseInput, CancelInvoiceOutput> {
|
|
21
|
+
private context: InvoiceCancellationContext
|
|
22
|
+
|
|
23
|
+
constructor(repository: IInvoiceRepository) {
|
|
24
|
+
super()
|
|
25
|
+
const canceller = new DefaultInvoiceCanceller()
|
|
26
|
+
this.context = new InvoiceCancellationContext(repository, canceller)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async execute(input: CancelInvoiceUseCaseInput): Promise<CancelInvoiceOutput> {
|
|
30
|
+
return this.context.orchestrate(input as CancelInvoiceInput)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { UseCase } from '@gravito/enterprise'
|
|
2
2
|
import type { IInvoiceRepository } from '../../Domain/Contracts/IInvoiceRepository'
|
|
3
|
-
import { Invoice } from '../../Domain/Entities/Invoice'
|
|
3
|
+
import type { Invoice } from '../../Domain/Entities/Invoice'
|
|
4
|
+
import { InvoiceIssuanceContext } from '../Contexts/InvoiceIssuanceContext'
|
|
5
|
+
import { DefaultInvoiceIssuer } from '../Roles/InvoiceIssuerRole'
|
|
4
6
|
|
|
5
7
|
export interface IssueInvoiceInput {
|
|
6
8
|
orderId: string
|
|
@@ -9,35 +11,20 @@ export interface IssueInvoiceInput {
|
|
|
9
11
|
carrierId?: string
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
/**
|
|
15
|
+
* 發票開立 UseCase(薄殼委派)
|
|
16
|
+
* 委派到 InvoiceIssuanceContext 進行實際業務流程處理
|
|
17
|
+
*/
|
|
12
18
|
export class IssueInvoice extends UseCase<IssueInvoiceInput, Invoice> {
|
|
13
|
-
|
|
19
|
+
private context: InvoiceIssuanceContext
|
|
20
|
+
|
|
21
|
+
constructor(repository: IInvoiceRepository) {
|
|
14
22
|
super()
|
|
23
|
+
const issuer = new DefaultInvoiceIssuer()
|
|
24
|
+
this.context = new InvoiceIssuanceContext(repository, issuer)
|
|
15
25
|
}
|
|
16
26
|
|
|
17
27
|
async execute(input: IssueInvoiceInput): Promise<Invoice> {
|
|
18
|
-
|
|
19
|
-
const existing = await this.repository.findByOrderId(input.orderId)
|
|
20
|
-
if (existing) {
|
|
21
|
-
return existing
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
// 模擬發票號碼產生器
|
|
25
|
-
const randomNum = Math.floor(10000000 + Math.random() * 90000000)
|
|
26
|
-
const invoiceNumber = `GX-${randomNum}`
|
|
27
|
-
|
|
28
|
-
const tax = Math.round(input.amount * 0.05)
|
|
29
|
-
|
|
30
|
-
const invoice = Invoice.create({
|
|
31
|
-
orderId: input.orderId,
|
|
32
|
-
invoiceNumber,
|
|
33
|
-
amount: input.amount,
|
|
34
|
-
tax,
|
|
35
|
-
status: 'ISSUED',
|
|
36
|
-
buyerIdentifier: input.buyerIdentifier,
|
|
37
|
-
carrierId: input.carrierId,
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
await this.repository.save(invoice)
|
|
41
|
-
return invoice
|
|
28
|
+
return this.context.orchestrate(input)
|
|
42
29
|
}
|
|
43
30
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { UseCase } from '@gravito/enterprise'
|
|
2
|
+
import type { IInvoiceRepository } from '../../Domain/Contracts/IInvoiceRepository'
|
|
3
|
+
import { InvoiceAuditContext } from '../Contexts/InvoiceAuditContext'
|
|
4
|
+
import { DefaultInvoiceTracker } from '../Roles/InvoiceTrackerRole'
|
|
5
|
+
|
|
6
|
+
export interface QueryInvoiceStatusInput {
|
|
7
|
+
invoiceId: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface QueryInvoiceStatusOutput {
|
|
11
|
+
id: string
|
|
12
|
+
invoiceNumber: string
|
|
13
|
+
orderId: string
|
|
14
|
+
status: string
|
|
15
|
+
amount: number
|
|
16
|
+
createdAt: Date
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface GenerateInvoiceReportInput {
|
|
20
|
+
startDate: Date
|
|
21
|
+
endDate: Date
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 發票查詢 UseCase(薄殼委派)
|
|
26
|
+
*/
|
|
27
|
+
export class QueryInvoiceStatus extends UseCase<QueryInvoiceStatusInput, QueryInvoiceStatusOutput> {
|
|
28
|
+
private context: InvoiceAuditContext
|
|
29
|
+
|
|
30
|
+
constructor(repository: IInvoiceRepository) {
|
|
31
|
+
super()
|
|
32
|
+
const tracker = new DefaultInvoiceTracker()
|
|
33
|
+
this.context = new InvoiceAuditContext(repository, tracker)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async execute(input: QueryInvoiceStatusInput): Promise<QueryInvoiceStatusOutput> {
|
|
37
|
+
return this.context.queryStatus(input)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 發票報告生成 UseCase(薄殼委派)
|
|
43
|
+
*/
|
|
44
|
+
export class GenerateInvoiceReport extends UseCase<GenerateInvoiceReportInput, any> {
|
|
45
|
+
private context: InvoiceAuditContext
|
|
46
|
+
|
|
47
|
+
constructor(repository: IInvoiceRepository) {
|
|
48
|
+
super()
|
|
49
|
+
const tracker = new DefaultInvoiceTracker()
|
|
50
|
+
this.context = new InvoiceAuditContext(repository, tracker)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async execute(input: GenerateInvoiceReportInput): Promise<any> {
|
|
54
|
+
return this.context.generateReport(input)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -5,4 +5,7 @@ export interface IInvoiceRepository {
|
|
|
5
5
|
findById(id: string): Promise<Invoice | null>
|
|
6
6
|
findByOrderId(orderId: string): Promise<Invoice | null>
|
|
7
7
|
findAll(): Promise<Invoice[]>
|
|
8
|
+
findByInvoiceNumber(invoiceNumber: string): Promise<Invoice | null>
|
|
9
|
+
findByStatus(status: string): Promise<Invoice[]>
|
|
10
|
+
findByDateRange(startDate: Date, endDate: Date): Promise<Invoice[]>
|
|
8
11
|
}
|