@gravito/satellite-commerce 0.1.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/.dockerignore +8 -0
- package/.env.example +3 -0
- package/ARCHITECTURE.md +40 -0
- package/CHANGELOG.md +53 -0
- package/Dockerfile +25 -0
- package/README.md +42 -0
- package/WHITEPAPER.md +37 -0
- package/dist/index.d.ts +113 -0
- package/dist/index.js +108 -0
- package/package.json +33 -0
- package/src/Application/Services/AdjustmentCalculator.ts +34 -0
- package/src/Application/Services/ProductResolver.ts +46 -0
- package/src/Application/Subscribers/RewardSubscriber.ts +27 -0
- package/src/Application/UseCases/AdminListOrders.ts +34 -0
- package/src/Application/UseCases/DeductInventory.ts +86 -0
- package/src/Application/UseCases/PlaceOrder.ts +122 -0
- package/src/CommerceServiceProvider.ts +59 -0
- package/src/Domain/Entities/Commerce.ts +26 -0
- package/src/Domain/Entities/Order.ts +164 -0
- package/src/Domain/Models.ts +95 -0
- package/src/Infrastructure/Persistence/Migrations/20250101_create_commerce_tables.ts +64 -0
- package/src/Interface/Http/Controllers/AdminOrderController.ts +21 -0
- package/src/Interface/Http/Controllers/CheckoutController.ts +50 -0
- package/src/Interface/Http/Requests/PlaceOrderRequest.ts +18 -0
- package/src/index.ts +14 -0
- package/src/manifest.json +12 -0
- package/tests/DeductInventory.test.ts +76 -0
- package/tests/grand-review.ts +153 -0
- package/tests/launchpad-ignition.ts +41 -0
- package/tests/unit.test.ts +7 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 扣減庫存 Use Case
|
|
3
|
+
*
|
|
4
|
+
* 當支付成功時執行此 Use Case:
|
|
5
|
+
* 1. 驗證訂單存在且狀態為 PAID
|
|
6
|
+
* 2. 呼叫 Flash-Sale Satellite 扣減庫存
|
|
7
|
+
* 3. 更新訂單狀態為 CONFIRMED
|
|
8
|
+
* 4. 發送 OrderConfirmed 事件
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PlanetCore } from '@gravito/core'
|
|
12
|
+
import { DeductInventoryRequest, type OrderStatusTransition } from '../../Domain/Models'
|
|
13
|
+
|
|
14
|
+
export interface DeductInventoryResponse {
|
|
15
|
+
success: boolean
|
|
16
|
+
orderId: string
|
|
17
|
+
message: string
|
|
18
|
+
newStatus: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DeductInventory Use Case
|
|
23
|
+
*
|
|
24
|
+
* 這是 Commerce ← Payment 流程的核心
|
|
25
|
+
* 當支付服務通知支付成功時,此 Use Case 執行庫存扣減
|
|
26
|
+
*/
|
|
27
|
+
export class DeductInventory {
|
|
28
|
+
constructor(private core: PlanetCore) {}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 執行 Use Case
|
|
32
|
+
*
|
|
33
|
+
* @param orderId - 訂單 ID
|
|
34
|
+
* @param productId - 商品 ID
|
|
35
|
+
* @param quantity - 扣減數量
|
|
36
|
+
*/
|
|
37
|
+
async execute(
|
|
38
|
+
orderId: string,
|
|
39
|
+
productId: string,
|
|
40
|
+
quantity: number
|
|
41
|
+
): Promise<DeductInventoryResponse> {
|
|
42
|
+
// 1. 驗證請求
|
|
43
|
+
const request = new DeductInventoryRequest(orderId, productId, quantity)
|
|
44
|
+
const validationErrors = request.validate()
|
|
45
|
+
|
|
46
|
+
if (validationErrors.length > 0) {
|
|
47
|
+
throw new Error(`Validation failed: ${validationErrors.join(', ')}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
this.core.logger.info(`[Commerce] Starting inventory deduction for order ${orderId}`)
|
|
51
|
+
|
|
52
|
+
// 2. TODO: 查詢訂單驗證狀態為 PAID
|
|
53
|
+
// const order = await this.orderRepository.findById(orderId)
|
|
54
|
+
// if (!order) {
|
|
55
|
+
// throw new Error(`Order not found: ${orderId}`)
|
|
56
|
+
// }
|
|
57
|
+
// if (order.status !== OrderStatus.PAID) {
|
|
58
|
+
// throw new Error(`Order not in PAID status: ${order.status}`)
|
|
59
|
+
// }
|
|
60
|
+
|
|
61
|
+
// 3. TODO: 呼叫 Flash-Sale Satellite 扣減庫存
|
|
62
|
+
// const productService = this.core.container.make('product.service')
|
|
63
|
+
// await productService.deductStock(productId, quantity)
|
|
64
|
+
|
|
65
|
+
// 4. TODO: 發送訂單狀態轉移事件
|
|
66
|
+
// const transition: OrderStatusTransition = {
|
|
67
|
+
// orderId,
|
|
68
|
+
// fromStatus: OrderStatus.PAID,
|
|
69
|
+
// toStatus: OrderStatus.CONFIRMED,
|
|
70
|
+
// timestamp: new Date(),
|
|
71
|
+
// }
|
|
72
|
+
// this.core.events.dispatch(new OrderStatusChanged(transition))
|
|
73
|
+
|
|
74
|
+
// 5. TODO: 更新訂單狀態
|
|
75
|
+
// await this.orderRepository.updateStatus(orderId, OrderStatus.CONFIRMED)
|
|
76
|
+
|
|
77
|
+
this.core.logger.info(`[Commerce] Inventory deducted for order ${orderId}`)
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
success: true,
|
|
81
|
+
orderId,
|
|
82
|
+
message: 'Inventory deducted successfully',
|
|
83
|
+
newStatus: 'CONFIRMED',
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { DB } from '@gravito/atlas'
|
|
2
|
+
import type { PlanetCore } from '@gravito/core'
|
|
3
|
+
import { UseCase } from '@gravito/enterprise'
|
|
4
|
+
import type { CacheManager } from '@gravito/stasis'
|
|
5
|
+
import { LineItem, Order } from '../../Domain/Entities/Order'
|
|
6
|
+
import { AdjustmentCalculator } from '../Services/AdjustmentCalculator'
|
|
7
|
+
import { ProductResolver } from '../Services/ProductResolver'
|
|
8
|
+
|
|
9
|
+
export interface PlaceOrderInput {
|
|
10
|
+
memberId: string | null
|
|
11
|
+
idempotencyKey?: string
|
|
12
|
+
items: {
|
|
13
|
+
variantId: string
|
|
14
|
+
quantity: number
|
|
15
|
+
}[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class PlaceOrder extends UseCase<PlaceOrderInput, any> {
|
|
19
|
+
constructor(private core: PlanetCore) {
|
|
20
|
+
super()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async execute(input: PlaceOrderInput): Promise<any> {
|
|
24
|
+
const adjCalculator = new AdjustmentCalculator(this.core)
|
|
25
|
+
const mode = process.env.COMMERCE_MODE || 'standard'
|
|
26
|
+
const useCache = mode === 'sport'
|
|
27
|
+
|
|
28
|
+
const cache = this.core.container.make<CacheManager>('cache')
|
|
29
|
+
const productResolver = new ProductResolver(cache)
|
|
30
|
+
|
|
31
|
+
return await DB.transaction(async (db) => {
|
|
32
|
+
const order = Order.create(crypto.randomUUID(), input.memberId)
|
|
33
|
+
|
|
34
|
+
for (const reqItem of input.items) {
|
|
35
|
+
const variantInfo = await productResolver.resolve(reqItem.variantId, useCache)
|
|
36
|
+
|
|
37
|
+
const variant = (await db
|
|
38
|
+
.table('product_variants')
|
|
39
|
+
.where('id', reqItem.variantId)
|
|
40
|
+
.select('stock', 'version', 'sku', 'price')
|
|
41
|
+
.first()) as any
|
|
42
|
+
|
|
43
|
+
if (!variant) {
|
|
44
|
+
throw new Error(`Variant ${reqItem.variantId} not found`)
|
|
45
|
+
}
|
|
46
|
+
if (Number(variant.stock) < reqItem.quantity) {
|
|
47
|
+
throw new Error('Insufficient stock')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const affectedRows = await db
|
|
51
|
+
.table('product_variants')
|
|
52
|
+
.where('id', reqItem.variantId)
|
|
53
|
+
.where('version', variant.version)
|
|
54
|
+
.update({
|
|
55
|
+
stock: Number(variant.stock) - reqItem.quantity,
|
|
56
|
+
version: Number(variant.version) + 1,
|
|
57
|
+
updated_at: new Date(),
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (affectedRows === 0) {
|
|
61
|
+
throw new Error('Concurrency conflict: Please try again')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lineItem = new LineItem(crypto.randomUUID(), {
|
|
65
|
+
variantId: variantInfo.id,
|
|
66
|
+
sku: variantInfo.sku,
|
|
67
|
+
name: variantInfo.name,
|
|
68
|
+
unitPrice: variantInfo.price,
|
|
69
|
+
quantity: reqItem.quantity,
|
|
70
|
+
totalPrice: variantInfo.price * reqItem.quantity,
|
|
71
|
+
})
|
|
72
|
+
order.addItem(lineItem)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await adjCalculator.calculate(order)
|
|
76
|
+
|
|
77
|
+
await db.table('orders').insert({
|
|
78
|
+
id: order.id,
|
|
79
|
+
member_id: order.memberId,
|
|
80
|
+
idempotency_key: input.idempotencyKey,
|
|
81
|
+
status: order.status,
|
|
82
|
+
subtotal_amount: order.subtotalAmount,
|
|
83
|
+
adjustment_amount: order.adjustmentAmount,
|
|
84
|
+
total_amount: order.totalAmount,
|
|
85
|
+
created_at: order.createdAt,
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
for (const item of order.items) {
|
|
89
|
+
await db.table('order_items').insert({
|
|
90
|
+
id: item.id,
|
|
91
|
+
order_id: order.id,
|
|
92
|
+
variant_id: item.props.variantId,
|
|
93
|
+
sku: item.props.sku,
|
|
94
|
+
name: item.props.name,
|
|
95
|
+
unit_price: item.props.unitPrice,
|
|
96
|
+
quantity: item.props.quantity,
|
|
97
|
+
total_price: item.props.totalPrice,
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
for (const adj of order.adjustments) {
|
|
102
|
+
await db.table('order_adjustments').insert({
|
|
103
|
+
id: adj.id,
|
|
104
|
+
order_id: order.id,
|
|
105
|
+
label: adj.props.label,
|
|
106
|
+
amount: adj.props.amount,
|
|
107
|
+
source_type: adj.props.sourceType,
|
|
108
|
+
source_id: adj.props.sourceId,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await this.core.hooks.doAction('commerce:order-placed', { orderId: order.id })
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
orderId: order.id,
|
|
116
|
+
status: order.status,
|
|
117
|
+
total: order.totalAmount,
|
|
118
|
+
adjustments: order.adjustments.map((a) => a.props.label),
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commerce Service Provider
|
|
3
|
+
*
|
|
4
|
+
* 註冊訂單管理系統的所有服務與事件監聽
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Container, PlanetCore } from '@gravito/core'
|
|
8
|
+
import { ServiceProvider } from '@gravito/core'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* CommerceServiceProvider
|
|
12
|
+
*
|
|
13
|
+
* 負責:
|
|
14
|
+
* 1. 訂單生命週期管理
|
|
15
|
+
* 2. 庫存扣減協調
|
|
16
|
+
* 3. 跨 Satellite 事件通訊
|
|
17
|
+
*/
|
|
18
|
+
export class CommerceServiceProvider extends ServiceProvider {
|
|
19
|
+
/**
|
|
20
|
+
* 註冊階段
|
|
21
|
+
*/
|
|
22
|
+
register(container: Container): void {
|
|
23
|
+
// TODO: 當 Repository 實現時解開註解
|
|
24
|
+
// container.singleton('order.repository', () => {
|
|
25
|
+
// return new AtlasOrderRepository()
|
|
26
|
+
// })
|
|
27
|
+
// TODO: 註冊 Use Cases
|
|
28
|
+
// container.bind('commerce.usecase.deductInventory', (c) => {
|
|
29
|
+
// return new DeductInventory(this.core!)
|
|
30
|
+
// })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 啟動階段
|
|
35
|
+
*/
|
|
36
|
+
override boot(): void {
|
|
37
|
+
const core = this.core
|
|
38
|
+
if (!core) {
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
core.logger.info('🛰️ Satellite Commerce is booting')
|
|
43
|
+
|
|
44
|
+
// 監聽支付成功事件(來自 Payment Satellite)
|
|
45
|
+
// core.hooks.addAction('payment:succeeded', async (orderId: string, amount: number) => {
|
|
46
|
+
// core.logger.info(`[Commerce] Payment succeeded for order ${orderId}`)
|
|
47
|
+
// // 執行庫存扣減
|
|
48
|
+
// // 更新訂單狀態
|
|
49
|
+
// })
|
|
50
|
+
|
|
51
|
+
// 監聽訂單建立事件(來自 Flash-Sale Satellite)
|
|
52
|
+
// core.events.listen(OrderCreated, async (event: OrderCreated) => {
|
|
53
|
+
// core.logger.info(`[Commerce] Order created: ${event.order.id}`)
|
|
54
|
+
// // 記錄訂單事件
|
|
55
|
+
// })
|
|
56
|
+
|
|
57
|
+
core.logger.info('✅ Satellite Commerce booted successfully')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export interface CommerceProps {
|
|
4
|
+
name: string
|
|
5
|
+
createdAt: Date
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class Commerce extends Entity<string> {
|
|
9
|
+
constructor(
|
|
10
|
+
id: string,
|
|
11
|
+
private props: CommerceProps
|
|
12
|
+
) {
|
|
13
|
+
super(id)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static create(id: string, name: string): Commerce {
|
|
17
|
+
return new Commerce(id, {
|
|
18
|
+
name,
|
|
19
|
+
createdAt: new Date(),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
get name() {
|
|
24
|
+
return this.props.name
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { AggregateRoot, Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export interface AdjustmentProps {
|
|
4
|
+
label: string
|
|
5
|
+
amount: number
|
|
6
|
+
sourceType: string | null
|
|
7
|
+
sourceId: string | null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class Adjustment extends Entity<string> {
|
|
11
|
+
constructor(
|
|
12
|
+
id: string,
|
|
13
|
+
public readonly props: AdjustmentProps
|
|
14
|
+
) {
|
|
15
|
+
super(id)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface LineItemProps {
|
|
20
|
+
variantId: string
|
|
21
|
+
sku: string
|
|
22
|
+
name: string
|
|
23
|
+
unitPrice: number
|
|
24
|
+
quantity: number
|
|
25
|
+
totalPrice: number
|
|
26
|
+
options?: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class LineItem extends Entity<string> {
|
|
30
|
+
constructor(
|
|
31
|
+
id: string,
|
|
32
|
+
public readonly props: LineItemProps
|
|
33
|
+
) {
|
|
34
|
+
super(id)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface OrderProps {
|
|
39
|
+
memberId: string | null
|
|
40
|
+
idempotencyKey?: string
|
|
41
|
+
status:
|
|
42
|
+
| 'pending'
|
|
43
|
+
| 'paid'
|
|
44
|
+
| 'processing'
|
|
45
|
+
| 'shipped'
|
|
46
|
+
| 'completed'
|
|
47
|
+
| 'cancelled'
|
|
48
|
+
| 'requested_refund'
|
|
49
|
+
| 'refunded'
|
|
50
|
+
subtotalAmount: number
|
|
51
|
+
adjustmentAmount: number
|
|
52
|
+
totalAmount: number
|
|
53
|
+
currency: string
|
|
54
|
+
items: LineItem[]
|
|
55
|
+
adjustments: Adjustment[]
|
|
56
|
+
createdAt: Date
|
|
57
|
+
updatedAt?: Date
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export class Order extends AggregateRoot<string> {
|
|
61
|
+
private constructor(
|
|
62
|
+
id: string,
|
|
63
|
+
private readonly props: OrderProps
|
|
64
|
+
) {
|
|
65
|
+
super(id)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
static create(id: string, memberId: string | null = null, currency = 'TWD'): Order {
|
|
69
|
+
return new Order(id, {
|
|
70
|
+
memberId,
|
|
71
|
+
status: 'pending',
|
|
72
|
+
subtotalAmount: 0,
|
|
73
|
+
adjustmentAmount: 0,
|
|
74
|
+
totalAmount: 0,
|
|
75
|
+
currency,
|
|
76
|
+
items: [],
|
|
77
|
+
adjustments: [],
|
|
78
|
+
createdAt: new Date(),
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Public Getters for Persistence & Logic
|
|
83
|
+
get memberId() {
|
|
84
|
+
return this.props.memberId
|
|
85
|
+
}
|
|
86
|
+
get status() {
|
|
87
|
+
return this.props.status
|
|
88
|
+
}
|
|
89
|
+
get subtotalAmount() {
|
|
90
|
+
return this.props.subtotalAmount
|
|
91
|
+
}
|
|
92
|
+
get adjustmentAmount() {
|
|
93
|
+
return this.props.adjustmentAmount
|
|
94
|
+
}
|
|
95
|
+
get totalAmount() {
|
|
96
|
+
return this.props.totalAmount
|
|
97
|
+
}
|
|
98
|
+
get createdAt() {
|
|
99
|
+
return this.props.createdAt
|
|
100
|
+
}
|
|
101
|
+
get items() {
|
|
102
|
+
return [...this.props.items]
|
|
103
|
+
}
|
|
104
|
+
get adjustments() {
|
|
105
|
+
return [...this.props.adjustments]
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public addItem(item: LineItem): void {
|
|
109
|
+
if (this.props.status !== 'pending') {
|
|
110
|
+
throw new Error('Order is not in pending state')
|
|
111
|
+
}
|
|
112
|
+
this.props.items.push(item)
|
|
113
|
+
this.recalculate()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public addAdjustment(adj: Adjustment): void {
|
|
117
|
+
if (this.props.status !== 'pending') {
|
|
118
|
+
throw new Error('Order is not in pending state')
|
|
119
|
+
}
|
|
120
|
+
this.props.adjustments.push(adj)
|
|
121
|
+
this.recalculate()
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private recalculate(): void {
|
|
125
|
+
;(this.props as any).subtotalAmount = this.props.items.reduce(
|
|
126
|
+
(sum, item) => sum + item.props.totalPrice,
|
|
127
|
+
0
|
|
128
|
+
)
|
|
129
|
+
;(this.props as any).adjustmentAmount = this.props.adjustments.reduce(
|
|
130
|
+
(sum, adj) => sum + adj.props.amount,
|
|
131
|
+
0
|
|
132
|
+
)
|
|
133
|
+
;(this.props as any).totalAmount = Math.max(
|
|
134
|
+
0,
|
|
135
|
+
this.props.subtotalAmount + this.props.adjustmentAmount
|
|
136
|
+
)
|
|
137
|
+
;(this.props as any).updatedAt = new Date()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
public markAsPaid(): void {
|
|
141
|
+
if (this.props.status !== 'pending') {
|
|
142
|
+
throw new Error('Invalid status transition')
|
|
143
|
+
}
|
|
144
|
+
;(this.props as any).status = 'paid'
|
|
145
|
+
;(this.props as any).updatedAt = new Date()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
public requestRefund(): void {
|
|
149
|
+
const allowedStatuses = ['paid', 'processing', 'completed']
|
|
150
|
+
if (!allowedStatuses.includes(this.props.status)) {
|
|
151
|
+
throw new Error(`Refund cannot be requested for order in ${this.props.status} state`)
|
|
152
|
+
}
|
|
153
|
+
;(this.props as any).status = 'requested_refund'
|
|
154
|
+
;(this.props as any).updatedAt = new Date()
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
public markAsRefunded(): void {
|
|
158
|
+
if (this.props.status !== 'requested_refund') {
|
|
159
|
+
throw new Error('Order must be in requested_refund state')
|
|
160
|
+
}
|
|
161
|
+
;(this.props as any).status = 'refunded'
|
|
162
|
+
;(this.props as any).updatedAt = new Date()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commerce Satellite 領域模型
|
|
3
|
+
*
|
|
4
|
+
* 專注於訂單的狀態管理與庫存扣減流程
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 庫存扣減請求
|
|
9
|
+
*
|
|
10
|
+
* 當支付成功時,Commerce 會發送此請求到 Flash-Sale Satellite
|
|
11
|
+
* 來扣減庫存
|
|
12
|
+
*/
|
|
13
|
+
export class DeductInventoryRequest {
|
|
14
|
+
constructor(
|
|
15
|
+
public orderId: string,
|
|
16
|
+
public productId: string,
|
|
17
|
+
public quantity: number
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
validate(): string[] {
|
|
21
|
+
const errors: string[] = []
|
|
22
|
+
|
|
23
|
+
if (!this.orderId?.trim()) {
|
|
24
|
+
errors.push('orderId is required')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!this.productId?.trim()) {
|
|
28
|
+
errors.push('productId is required')
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (this.quantity <= 0) {
|
|
32
|
+
errors.push('quantity must be greater than 0')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return errors
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 訂單狀態轉移事件
|
|
41
|
+
*/
|
|
42
|
+
export interface OrderStatusTransition {
|
|
43
|
+
orderId: string
|
|
44
|
+
fromStatus: string
|
|
45
|
+
toStatus: string
|
|
46
|
+
reason?: string
|
|
47
|
+
timestamp: Date
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 訂單確認請求
|
|
52
|
+
*
|
|
53
|
+
* 當庫存扣減成功時,Commerce 會建立訂單確認
|
|
54
|
+
*/
|
|
55
|
+
export class ConfirmOrderRequest {
|
|
56
|
+
constructor(
|
|
57
|
+
public orderId: string,
|
|
58
|
+
public inventoryLockId?: string
|
|
59
|
+
) {}
|
|
60
|
+
|
|
61
|
+
validate(): string[] {
|
|
62
|
+
const errors: string[] = []
|
|
63
|
+
|
|
64
|
+
if (!this.orderId?.trim()) {
|
|
65
|
+
errors.push('orderId is required')
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return errors
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 訂單退款請求
|
|
74
|
+
*/
|
|
75
|
+
export class RefundOrderRequest {
|
|
76
|
+
constructor(
|
|
77
|
+
public orderId: string,
|
|
78
|
+
public reason: string,
|
|
79
|
+
public restoreInventory = true
|
|
80
|
+
) {}
|
|
81
|
+
|
|
82
|
+
validate(): string[] {
|
|
83
|
+
const errors: string[] = []
|
|
84
|
+
|
|
85
|
+
if (!this.orderId?.trim()) {
|
|
86
|
+
errors.push('orderId is required')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!this.reason?.trim()) {
|
|
90
|
+
errors.push('reason is required')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return errors
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Blueprint, Schema } from '@gravito/atlas'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration to create Commerce tables and Patch inventory for Optimistic Locking.
|
|
5
|
+
*/
|
|
6
|
+
export default {
|
|
7
|
+
async up() {
|
|
8
|
+
// 1. Orders Table
|
|
9
|
+
await Schema.create('orders', (table: Blueprint) => {
|
|
10
|
+
table.string('id').primary()
|
|
11
|
+
table.string('member_id').nullable()
|
|
12
|
+
table.string('idempotency_key').unique().nullable()
|
|
13
|
+
table.string('status').default('pending') // pending, paid, processing, shipped, completed, cancelled
|
|
14
|
+
table.decimal('subtotal_amount', 15, 2)
|
|
15
|
+
table.decimal('adjustment_amount', 15, 2).default(0)
|
|
16
|
+
table.decimal('total_amount', 15, 2)
|
|
17
|
+
table.string('currency').default('TWD')
|
|
18
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
19
|
+
table.timestamp('updated_at').nullable()
|
|
20
|
+
table.text('metadata').nullable()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
// 2. Order Items (Snapshotting)
|
|
24
|
+
await Schema.create('order_items', (table: Blueprint) => {
|
|
25
|
+
table.string('id').primary()
|
|
26
|
+
table.string('order_id')
|
|
27
|
+
table.string('variant_id')
|
|
28
|
+
table.string('sku')
|
|
29
|
+
table.string('name')
|
|
30
|
+
table.decimal('unit_price', 15, 2)
|
|
31
|
+
table.integer('quantity')
|
|
32
|
+
table.decimal('total_price', 15, 2)
|
|
33
|
+
table.text('options').nullable()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// 3. Order Adjustments (Marketing/Tax/Shipping)
|
|
37
|
+
await Schema.create('order_adjustments', (table: Blueprint) => {
|
|
38
|
+
table.string('id').primary()
|
|
39
|
+
table.string('order_id')
|
|
40
|
+
table.string('label') // e.g., "Christmas Sale -10%", "Shipping Fee"
|
|
41
|
+
table.decimal('amount', 15, 2)
|
|
42
|
+
table.string('source_type').nullable() // e.g., "coupon", "tax"
|
|
43
|
+
table.string('source_id').nullable()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
// 4. Patch Inventory for Optimistic Locking
|
|
47
|
+
// We add 'version' to support atomic updates:
|
|
48
|
+
// UPDATE ... SET stock = stock - ?, version = version + 1 WHERE id = ? AND version = ?
|
|
49
|
+
try {
|
|
50
|
+
await Schema.table('product_variants', (table: Blueprint) => {
|
|
51
|
+
table.integer('version').default(1)
|
|
52
|
+
})
|
|
53
|
+
} catch (_e) {
|
|
54
|
+
// Column might already exist, ignore in review environment
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async down() {
|
|
59
|
+
await Schema.dropIfExists('order_adjustments')
|
|
60
|
+
await Schema.dropIfExists('order_items')
|
|
61
|
+
await Schema.dropIfExists('orders')
|
|
62
|
+
// We don't drop 'version' column in down to avoid losing catalog data
|
|
63
|
+
},
|
|
64
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import type { AdminListOrders } from '../../../Application/UseCases/AdminListOrders'
|
|
3
|
+
|
|
4
|
+
export class AdminOrderController {
|
|
5
|
+
constructor(private core: PlanetCore) {}
|
|
6
|
+
|
|
7
|
+
async index(ctx: any) {
|
|
8
|
+
try {
|
|
9
|
+
const useCase = this.core.container.make<AdminListOrders>('commerce.usecase.adminListOrders')
|
|
10
|
+
const orders = await useCase.execute()
|
|
11
|
+
return ctx.json(orders)
|
|
12
|
+
} catch (error: any) {
|
|
13
|
+
return ctx.json({ message: error.message }, 500)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async update(ctx: any) {
|
|
18
|
+
// 變更訂單狀態邏輯
|
|
19
|
+
return ctx.json({ success: true })
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { GravitoContext } from '@gravito/core'
|
|
2
|
+
import type { PlaceOrder } from '../../../Application/UseCases/PlaceOrder'
|
|
3
|
+
|
|
4
|
+
export class CheckoutController {
|
|
5
|
+
/**
|
|
6
|
+
* 結帳下單接口
|
|
7
|
+
* POST /api/commerce/checkout
|
|
8
|
+
*/
|
|
9
|
+
async store(c: GravitoContext) {
|
|
10
|
+
const core = c.get('core' as any) as any
|
|
11
|
+
const placeOrder = core.container.make('commerce.place-order') as PlaceOrder
|
|
12
|
+
|
|
13
|
+
// 獲取下單數據
|
|
14
|
+
const body = (await c.req.json()) as any
|
|
15
|
+
|
|
16
|
+
// 獲取冪等 Key
|
|
17
|
+
const idempotencyKey = c.req.header('X-Idempotency-Key') || body.idempotencyKey
|
|
18
|
+
|
|
19
|
+
// 獲取會員 ID (相容 Membership 插件)
|
|
20
|
+
const auth = c.get('auth' as any) as any
|
|
21
|
+
const memberId = auth?.user ? auth.user()?.id : null
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const result = await placeOrder.execute({
|
|
25
|
+
memberId,
|
|
26
|
+
idempotencyKey,
|
|
27
|
+
items: body.items,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return c.json(
|
|
31
|
+
{
|
|
32
|
+
success: true,
|
|
33
|
+
message: 'Order placed successfully',
|
|
34
|
+
data: result,
|
|
35
|
+
},
|
|
36
|
+
201
|
|
37
|
+
)
|
|
38
|
+
} catch (error: any) {
|
|
39
|
+
// 處理併發衝突或庫存不足
|
|
40
|
+
const status = error.message.includes('Concurrency') ? 409 : 400
|
|
41
|
+
return c.json(
|
|
42
|
+
{
|
|
43
|
+
success: false,
|
|
44
|
+
error: error.message,
|
|
45
|
+
},
|
|
46
|
+
status
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|