@gravito/satellite-catalog 0.1.1
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 +19 -0
- package/ARCHITECTURE.md +14 -0
- package/CHANGELOG.md +12 -0
- package/Dockerfile +25 -0
- package/README.md +48 -0
- package/WHITEPAPER.md +20 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +321 -0
- package/package.json +32 -0
- package/src/Application/DTOs/CategoryDTO.ts +49 -0
- package/src/Application/DTOs/ProductDTO.ts +74 -0
- package/src/Application/UseCases/AdminListProducts.ts +13 -0
- package/src/Application/UseCases/CreateProduct.ts +70 -0
- package/src/Application/UseCases/RecoverStock.ts +32 -0
- package/src/Application/UseCases/UpdateCategory.ts +74 -0
- package/src/Domain/Contracts/ICatalogRepository.ts +19 -0
- package/src/Domain/Entities/Category.ts +83 -0
- package/src/Domain/Entities/Product.ts +124 -0
- package/src/Infrastructure/Persistence/AtlasCategoryRepository.ts +77 -0
- package/src/Infrastructure/Persistence/AtlasProductRepository.ts +136 -0
- package/src/Infrastructure/Persistence/Migrations/20250101_create_catalog_tables.ts +64 -0
- package/src/Interface/Http/Controllers/AdminProductController.ts +38 -0
- package/src/Interface/Http/Controllers/CategoryController.ts +20 -0
- package/src/Interface/Http/Controllers/ProductController.ts +56 -0
- package/src/index.ts +62 -0
- package/src/manifest.json +15 -0
- package/tests/entities.test.ts +71 -0
- package/tests/grand-review.ts +129 -0
- package/tests/unit.test.ts +7 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import { UseCase } from '@gravito/enterprise'
|
|
3
|
+
import type { IProductRepository } from '../../Domain/Contracts/ICatalogRepository'
|
|
4
|
+
import { Product, Variant } from '../../Domain/Entities/Product'
|
|
5
|
+
import { type ProductDTO, ProductMapper } from '../DTOs/ProductDTO'
|
|
6
|
+
|
|
7
|
+
export interface CreateProductInput {
|
|
8
|
+
name: Record<string, string>
|
|
9
|
+
slug: string
|
|
10
|
+
brand?: string
|
|
11
|
+
categoryIds?: string[]
|
|
12
|
+
variants: {
|
|
13
|
+
sku: string
|
|
14
|
+
name?: string
|
|
15
|
+
price: number
|
|
16
|
+
stock: number
|
|
17
|
+
options: Record<string, string>
|
|
18
|
+
}[]
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class CreateProduct extends UseCase<CreateProductInput, ProductDTO> {
|
|
22
|
+
constructor(
|
|
23
|
+
private repository: IProductRepository,
|
|
24
|
+
private core: PlanetCore
|
|
25
|
+
) {
|
|
26
|
+
super()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async execute(input: CreateProductInput): Promise<ProductDTO> {
|
|
30
|
+
// 1. 建立 Product 主體
|
|
31
|
+
const product = Product.create(crypto.randomUUID(), input.name, input.slug)
|
|
32
|
+
|
|
33
|
+
// @ts-expect-error
|
|
34
|
+
product.props.brand = input.brand
|
|
35
|
+
|
|
36
|
+
// 2. 加入分類
|
|
37
|
+
if (input.categoryIds) {
|
|
38
|
+
input.categoryIds.forEach((cid) => {
|
|
39
|
+
product.assignToCategory(cid)
|
|
40
|
+
})
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 3. 建立變體 (SKUs)
|
|
44
|
+
input.variants.forEach((v) => {
|
|
45
|
+
const variant = new Variant(crypto.randomUUID(), {
|
|
46
|
+
productId: product.id,
|
|
47
|
+
sku: v.sku,
|
|
48
|
+
name: v.name || null,
|
|
49
|
+
price: v.price,
|
|
50
|
+
compareAtPrice: null,
|
|
51
|
+
stock: v.stock,
|
|
52
|
+
options: v.options,
|
|
53
|
+
createdAt: new Date(),
|
|
54
|
+
updatedAt: new Date(),
|
|
55
|
+
})
|
|
56
|
+
product.addVariant(variant)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// 4. 存入持久化層
|
|
60
|
+
await this.repository.save(product)
|
|
61
|
+
|
|
62
|
+
// 5. 觸發 Hook,讓外部系統同步 (如:Search Indexer)
|
|
63
|
+
await this.core.hooks.doAction('catalog:product-created', {
|
|
64
|
+
productId: product.id,
|
|
65
|
+
skuCount: product.variants.length,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
return ProductMapper.toDTO(product)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DB } from '@gravito/atlas'
|
|
2
|
+
import { UseCase } from '@gravito/enterprise'
|
|
3
|
+
|
|
4
|
+
export interface RecoverStockInput {
|
|
5
|
+
variantId: string
|
|
6
|
+
quantity: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RecoverStock - 安全回滾庫存 (支持 OCC)
|
|
11
|
+
* 用於訂單退貨、取消或超時未支付場景
|
|
12
|
+
*/
|
|
13
|
+
export class RecoverStock extends UseCase<RecoverStockInput, void> {
|
|
14
|
+
async execute(input: RecoverStockInput): Promise<void> {
|
|
15
|
+
const { variantId, quantity } = input
|
|
16
|
+
|
|
17
|
+
// 使用 SQL 原子操作增加庫存,並讓 version 自增,確保 OCC 一致性
|
|
18
|
+
const affected = await DB.table('product_variants')
|
|
19
|
+
.where('id', variantId)
|
|
20
|
+
.update({
|
|
21
|
+
stock: DB.raw('stock + ?', [quantity]),
|
|
22
|
+
version: DB.raw('version + 1'),
|
|
23
|
+
updated_at: new Date(),
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
if (affected === 0) {
|
|
27
|
+
throw new Error(`Variant [${variantId}] not found during stock recovery`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(`[Catalog] Stock recovered for variant ${variantId}: +${quantity}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { PlanetCore } from '@gravito/core'
|
|
2
|
+
import { UseCase } from '@gravito/enterprise'
|
|
3
|
+
import type { ICategoryRepository } from '../../Domain/Contracts/ICatalogRepository'
|
|
4
|
+
import { type CategoryDTO, CategoryMapper } from '../DTOs/CategoryDTO'
|
|
5
|
+
|
|
6
|
+
export interface UpdateCategoryInput {
|
|
7
|
+
id: string
|
|
8
|
+
name?: Record<string, string>
|
|
9
|
+
parentId?: string | null
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export class UpdateCategory extends UseCase<UpdateCategoryInput, CategoryDTO> {
|
|
13
|
+
constructor(
|
|
14
|
+
private repository: ICategoryRepository,
|
|
15
|
+
private core: PlanetCore
|
|
16
|
+
) {
|
|
17
|
+
super()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async execute(input: UpdateCategoryInput): Promise<CategoryDTO> {
|
|
21
|
+
const category = await this.repository.findById(input.id)
|
|
22
|
+
if (!category) {
|
|
23
|
+
throw new Error('Category not found')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const oldPath = category.path
|
|
27
|
+
const isMoving = input.parentId !== undefined && input.parentId !== category.parentId
|
|
28
|
+
|
|
29
|
+
// 1. 更新基本屬性
|
|
30
|
+
if (input.name) {
|
|
31
|
+
// @ts-expect-error
|
|
32
|
+
category.props.name = input.name
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// 2. 處理移動邏輯 (關鍵!)
|
|
36
|
+
if (isMoving) {
|
|
37
|
+
category.moveTo(input.parentId!)
|
|
38
|
+
|
|
39
|
+
// 獲取新父節點的路徑
|
|
40
|
+
const newParent = input.parentId ? await this.repository.findById(input.parentId) : null
|
|
41
|
+
const newPath = newParent ? `${newParent.path}/${category.slug}` : category.slug
|
|
42
|
+
|
|
43
|
+
// 3. 同步更新所有子孫路徑 (必須在更新自己之前,使用舊路徑查找)
|
|
44
|
+
if (oldPath) {
|
|
45
|
+
const descendants = await this.repository.findByPathPrefix(oldPath)
|
|
46
|
+
console.log(`[Debug] 找到子孫數量: ${descendants.length} (使用前綴: ${oldPath})`)
|
|
47
|
+
for (const desc of descendants) {
|
|
48
|
+
if (desc.id === category.id) {
|
|
49
|
+
continue
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 確保只替換開頭的路徑部分
|
|
53
|
+
const subPath = desc.path?.substring(oldPath.length)
|
|
54
|
+
// @ts-expect-error
|
|
55
|
+
desc.props.path = newPath + subPath
|
|
56
|
+
await this.repository.save(desc)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 更新自己
|
|
61
|
+
// @ts-expect-error
|
|
62
|
+
category.props.path = newPath
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await this.repository.save(category)
|
|
66
|
+
|
|
67
|
+
await this.core.hooks.doAction('catalog:category-updated', {
|
|
68
|
+
categoryId: category.id,
|
|
69
|
+
moved: isMoving,
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return CategoryMapper.toDTO(category)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Category } from '../Entities/Category'
|
|
2
|
+
import type { Product } from '../Entities/Product'
|
|
3
|
+
|
|
4
|
+
export interface ICategoryRepository {
|
|
5
|
+
save(category: Category): Promise<void>
|
|
6
|
+
findById(id: string): Promise<Category | null>
|
|
7
|
+
findAll(): Promise<Category[]>
|
|
8
|
+
findByParentId(parentId: string | null): Promise<Category[]>
|
|
9
|
+
/** 獲取某路徑下的所有子分類 (用於同步更新) */
|
|
10
|
+
findByPathPrefix(path: string): Promise<Category[]>
|
|
11
|
+
delete(id: string): Promise<void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface IProductRepository {
|
|
15
|
+
save(product: Product): Promise<void>
|
|
16
|
+
findById(id: string): Promise<Product | null>
|
|
17
|
+
findAll(filters?: any): Promise<Product[]>
|
|
18
|
+
delete(id: string): Promise<void>
|
|
19
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export interface CategoryProps {
|
|
4
|
+
parentId: string | null
|
|
5
|
+
path: string | null
|
|
6
|
+
name: Record<string, string> // i18n
|
|
7
|
+
slug: string
|
|
8
|
+
description?: string
|
|
9
|
+
sortOrder: number
|
|
10
|
+
createdAt: Date
|
|
11
|
+
updatedAt: Date
|
|
12
|
+
metadata?: Record<string, any>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class Category extends Entity<string> {
|
|
16
|
+
private constructor(
|
|
17
|
+
id: string,
|
|
18
|
+
private props: CategoryProps
|
|
19
|
+
) {
|
|
20
|
+
super(id)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
static create(
|
|
24
|
+
id: string,
|
|
25
|
+
name: Record<string, string>,
|
|
26
|
+
slug: string,
|
|
27
|
+
parentId: string | null = null
|
|
28
|
+
): Category {
|
|
29
|
+
return new Category(id, {
|
|
30
|
+
parentId,
|
|
31
|
+
path: null, // Will be computed by repository/service
|
|
32
|
+
name,
|
|
33
|
+
slug,
|
|
34
|
+
sortOrder: 0,
|
|
35
|
+
createdAt: new Date(),
|
|
36
|
+
updatedAt: new Date(),
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
static reconstitute(id: string, props: CategoryProps): Category {
|
|
41
|
+
return new Category(id, props)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Getters
|
|
45
|
+
get parentId() {
|
|
46
|
+
return this.props.parentId
|
|
47
|
+
}
|
|
48
|
+
get path() {
|
|
49
|
+
return this.props.path
|
|
50
|
+
}
|
|
51
|
+
get name() {
|
|
52
|
+
return this.props.name
|
|
53
|
+
}
|
|
54
|
+
get slug() {
|
|
55
|
+
return this.props.slug
|
|
56
|
+
}
|
|
57
|
+
get sortOrder() {
|
|
58
|
+
return this.props.sortOrder
|
|
59
|
+
}
|
|
60
|
+
get metadata() {
|
|
61
|
+
return this.props.metadata || {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Update the path based on parent's path
|
|
66
|
+
*/
|
|
67
|
+
public updatePath(parentPath: string | null): void {
|
|
68
|
+
if (parentPath) {
|
|
69
|
+
this.props.path = `${parentPath}/${this.props.slug}`
|
|
70
|
+
} else {
|
|
71
|
+
this.props.path = this.props.slug
|
|
72
|
+
}
|
|
73
|
+
this.props.updatedAt = new Date()
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Change the parent category
|
|
78
|
+
*/
|
|
79
|
+
public moveTo(newParentId: string | null): void {
|
|
80
|
+
this.props.parentId = newParentId
|
|
81
|
+
this.props.updatedAt = new Date()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Entity } from '@gravito/enterprise'
|
|
2
|
+
|
|
3
|
+
export interface VariantProps {
|
|
4
|
+
productId: string
|
|
5
|
+
sku: string
|
|
6
|
+
name: string | null
|
|
7
|
+
price: number
|
|
8
|
+
compareAtPrice: number | null
|
|
9
|
+
stock: number
|
|
10
|
+
options: Record<string, string> // e.g., {"color": "Red"}
|
|
11
|
+
createdAt: Date
|
|
12
|
+
updatedAt: Date
|
|
13
|
+
metadata?: Record<string, any>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class Variant extends Entity<string> {
|
|
17
|
+
constructor(
|
|
18
|
+
id: string,
|
|
19
|
+
private props: VariantProps
|
|
20
|
+
) {
|
|
21
|
+
super(id)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Getters
|
|
25
|
+
get sku() {
|
|
26
|
+
return this.props.sku
|
|
27
|
+
}
|
|
28
|
+
get price() {
|
|
29
|
+
return this.props.price
|
|
30
|
+
}
|
|
31
|
+
get stock() {
|
|
32
|
+
return this.props.stock
|
|
33
|
+
}
|
|
34
|
+
get options() {
|
|
35
|
+
return this.props.options
|
|
36
|
+
}
|
|
37
|
+
get metadata() {
|
|
38
|
+
return this.props.metadata || {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
public reduceStock(quantity: number): void {
|
|
42
|
+
if (this.props.stock < quantity) {
|
|
43
|
+
throw new Error('Insufficient stock')
|
|
44
|
+
}
|
|
45
|
+
this.props.stock -= quantity
|
|
46
|
+
this.props.updatedAt = new Date()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ProductProps {
|
|
51
|
+
name: Record<string, string> // i18n
|
|
52
|
+
slug: string
|
|
53
|
+
description?: string
|
|
54
|
+
brand?: string
|
|
55
|
+
status: 'active' | 'draft' | 'archived'
|
|
56
|
+
thumbnail?: string // Storage key
|
|
57
|
+
variants: Variant[]
|
|
58
|
+
categoryIds: string[]
|
|
59
|
+
createdAt: Date
|
|
60
|
+
updatedAt: Date
|
|
61
|
+
metadata?: Record<string, any>
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export class Product extends Entity<string> {
|
|
65
|
+
private constructor(
|
|
66
|
+
id: string,
|
|
67
|
+
private props: ProductProps
|
|
68
|
+
) {
|
|
69
|
+
super(id)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
static create(id: string, name: Record<string, string>, slug: string): Product {
|
|
73
|
+
return new Product(id, {
|
|
74
|
+
name,
|
|
75
|
+
slug,
|
|
76
|
+
status: 'active',
|
|
77
|
+
variants: [],
|
|
78
|
+
categoryIds: [],
|
|
79
|
+
createdAt: new Date(),
|
|
80
|
+
updatedAt: new Date(),
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static reconstitute(id: string, props: ProductProps): Product {
|
|
85
|
+
return new Product(id, props)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Getters
|
|
89
|
+
get name() {
|
|
90
|
+
return this.props.name
|
|
91
|
+
}
|
|
92
|
+
get slug() {
|
|
93
|
+
return this.props.slug
|
|
94
|
+
}
|
|
95
|
+
get thumbnail() {
|
|
96
|
+
return this.props.thumbnail
|
|
97
|
+
}
|
|
98
|
+
get variants() {
|
|
99
|
+
return this.props.variants
|
|
100
|
+
}
|
|
101
|
+
get categoryIds() {
|
|
102
|
+
return this.props.categoryIds
|
|
103
|
+
}
|
|
104
|
+
get metadata() {
|
|
105
|
+
return this.props.metadata || {}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public setThumbnail(key: string): void {
|
|
109
|
+
this.props.thumbnail = key
|
|
110
|
+
this.props.updatedAt = new Date()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
public addVariant(variant: Variant): void {
|
|
114
|
+
this.props.variants.push(variant)
|
|
115
|
+
this.props.updatedAt = new Date()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
public assignToCategory(categoryId: string): void {
|
|
119
|
+
if (!this.props.categoryIds.includes(categoryId)) {
|
|
120
|
+
this.props.categoryIds.push(categoryId)
|
|
121
|
+
this.props.updatedAt = new Date()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { DB } from '@gravito/atlas'
|
|
2
|
+
import type { ICategoryRepository } from '../../Domain/Contracts/ICatalogRepository'
|
|
3
|
+
import { Category } from '../../Domain/Entities/Category'
|
|
4
|
+
|
|
5
|
+
export class AtlasCategoryRepository implements ICategoryRepository {
|
|
6
|
+
private table = 'categories'
|
|
7
|
+
|
|
8
|
+
async save(category: Category): Promise<void> {
|
|
9
|
+
const data = {
|
|
10
|
+
id: category.id,
|
|
11
|
+
parent_id: category.parentId,
|
|
12
|
+
path: category.path,
|
|
13
|
+
name: JSON.stringify(category.name),
|
|
14
|
+
slug: category.slug,
|
|
15
|
+
sort_order: category.sortOrder,
|
|
16
|
+
// @ts-expect-error
|
|
17
|
+
description: category.props.description || null,
|
|
18
|
+
// @ts-expect-error
|
|
19
|
+
created_at: category.props.createdAt,
|
|
20
|
+
// @ts-expect-error
|
|
21
|
+
updated_at: category.props.updatedAt,
|
|
22
|
+
metadata: JSON.stringify(category.metadata),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const exists = await DB.table(this.table).where('id', category.id).first()
|
|
26
|
+
if (exists) {
|
|
27
|
+
await DB.table(this.table).where('id', category.id).update(data)
|
|
28
|
+
} else {
|
|
29
|
+
await DB.table(this.table).insert(data)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async findById(id: string): Promise<Category | null> {
|
|
34
|
+
const row = await DB.table(this.table).where('id', id).first()
|
|
35
|
+
return row ? this.mapToDomain(row) : null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async findAll(): Promise<Category[]> {
|
|
39
|
+
const rows = await DB.table(this.table).orderBy('sort_order', 'asc').get()
|
|
40
|
+
return rows.map((row: any) => this.mapToDomain(row))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async findByParentId(parentId: string | null): Promise<Category[]> {
|
|
44
|
+
const query = DB.table(this.table)
|
|
45
|
+
if (parentId === null) {
|
|
46
|
+
query.whereNull('parent_id')
|
|
47
|
+
} else {
|
|
48
|
+
query.where('parent_id', parentId)
|
|
49
|
+
}
|
|
50
|
+
const rows = await query.orderBy('sort_order', 'asc').get()
|
|
51
|
+
return rows.map((row: any) => this.mapToDomain(row))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async findByPathPrefix(path: string): Promise<Category[]> {
|
|
55
|
+
// 獲取該路徑下的所有後代
|
|
56
|
+
const rows = await DB.table(this.table).where('path', 'like', `${path}/%`).get()
|
|
57
|
+
return rows.map((row: any) => this.mapToDomain(row))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async delete(id: string): Promise<void> {
|
|
61
|
+
await DB.table(this.table).where('id', id).delete()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private mapToDomain(row: any): Category {
|
|
65
|
+
return Category.reconstitute(row.id, {
|
|
66
|
+
parentId: row.parent_id,
|
|
67
|
+
path: row.path,
|
|
68
|
+
name: JSON.parse(row.name),
|
|
69
|
+
slug: row.slug,
|
|
70
|
+
description: row.description,
|
|
71
|
+
sortOrder: row.sort_order,
|
|
72
|
+
createdAt: new Date(row.created_at),
|
|
73
|
+
updatedAt: new Date(row.updated_at || row.created_at),
|
|
74
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ConnectionContract } from '@gravito/atlas'
|
|
2
|
+
import { DB } from '@gravito/atlas'
|
|
3
|
+
import type { IProductRepository } from '../../Domain/Contracts/ICatalogRepository'
|
|
4
|
+
import { Product, Variant } from '../../Domain/Entities/Product'
|
|
5
|
+
|
|
6
|
+
export class AtlasProductRepository implements IProductRepository {
|
|
7
|
+
private productsTable = 'products'
|
|
8
|
+
private variantsTable = 'product_variants'
|
|
9
|
+
private pivotTable = 'category_product'
|
|
10
|
+
|
|
11
|
+
async save(product: Product): Promise<void> {
|
|
12
|
+
await DB.transaction(async (db: ConnectionContract) => {
|
|
13
|
+
// 1. 保存商品主體
|
|
14
|
+
const productData = {
|
|
15
|
+
id: product.id,
|
|
16
|
+
name: JSON.stringify(product.name),
|
|
17
|
+
slug: product.slug,
|
|
18
|
+
// @ts-expect-error
|
|
19
|
+
description: product.props.description || null,
|
|
20
|
+
// @ts-expect-error
|
|
21
|
+
brand: product.props.brand || null,
|
|
22
|
+
// @ts-expect-error
|
|
23
|
+
status: product.props.status,
|
|
24
|
+
thumbnail: product.thumbnail || null,
|
|
25
|
+
// @ts-expect-error
|
|
26
|
+
created_at: product.props.createdAt,
|
|
27
|
+
// @ts-expect-error
|
|
28
|
+
updated_at: product.props.updatedAt,
|
|
29
|
+
metadata: JSON.stringify(product.metadata),
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const exists = await db.table(this.productsTable).where('id', product.id).first()
|
|
33
|
+
if (exists) {
|
|
34
|
+
await db.table(this.productsTable).where('id', product.id).update(productData)
|
|
35
|
+
} else {
|
|
36
|
+
await db.table(this.productsTable).insert(productData)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 2. 同步變體 (簡單起見,先刪除舊的再插入新的,或實作 upsert)
|
|
40
|
+
await db.table(this.variantsTable).where('product_id', product.id).delete()
|
|
41
|
+
for (const variant of product.variants) {
|
|
42
|
+
await db.table(this.variantsTable).insert({
|
|
43
|
+
id: variant.id,
|
|
44
|
+
product_id: product.id,
|
|
45
|
+
sku: variant.sku,
|
|
46
|
+
// @ts-expect-error
|
|
47
|
+
name: variant.props.name,
|
|
48
|
+
price: variant.price,
|
|
49
|
+
// @ts-expect-error
|
|
50
|
+
compare_at_price: variant.props.compareAtPrice,
|
|
51
|
+
stock: variant.stock,
|
|
52
|
+
options: JSON.stringify(variant.options),
|
|
53
|
+
// @ts-expect-error
|
|
54
|
+
created_at: variant.props.createdAt,
|
|
55
|
+
// @ts-expect-error
|
|
56
|
+
updated_at: variant.props.updatedAt,
|
|
57
|
+
metadata: JSON.stringify(variant.metadata),
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 3. 同步分類關聯
|
|
62
|
+
await db.table(this.pivotTable).where('product_id', product.id).delete()
|
|
63
|
+
for (const categoryId of product.categoryIds) {
|
|
64
|
+
await db.table(this.pivotTable).insert({
|
|
65
|
+
product_id: product.id,
|
|
66
|
+
category_id: categoryId,
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async findById(id: string): Promise<Product | null> {
|
|
73
|
+
const row = await DB.table(this.productsTable).where('id', id).first()
|
|
74
|
+
if (!row) {
|
|
75
|
+
return null
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const variantRows = await DB.table(this.variantsTable).where('product_id', id).get()
|
|
79
|
+
const categoryRows = await DB.table(this.pivotTable).where('product_id', id).get()
|
|
80
|
+
|
|
81
|
+
return this.mapToDomain(row, variantRows, categoryRows)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async findAll(_filters?: any): Promise<Product[]> {
|
|
85
|
+
const rows = await DB.table(this.productsTable).get()
|
|
86
|
+
const products: Product[] = []
|
|
87
|
+
|
|
88
|
+
for (const row of rows) {
|
|
89
|
+
const variantRows = await DB.table(this.variantsTable).where('product_id', row.id).get()
|
|
90
|
+
const categoryRows = await DB.table(this.pivotTable).where('product_id', row.id).get()
|
|
91
|
+
products.push(this.mapToDomain(row, variantRows, categoryRows))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return products
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async delete(id: string): Promise<void> {
|
|
98
|
+
await DB.transaction(async (db: ConnectionContract) => {
|
|
99
|
+
await db.table(this.pivotTable).where('product_id', id).delete()
|
|
100
|
+
await db.table(this.variantsTable).where('product_id', id).delete()
|
|
101
|
+
await db.table(this.productsTable).where('id', id).delete()
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private mapToDomain(row: any, variantRows: any[], categoryRows: any[]): Product {
|
|
106
|
+
const variants = variantRows.map(
|
|
107
|
+
(v) =>
|
|
108
|
+
new Variant(v.id, {
|
|
109
|
+
productId: v.product_id,
|
|
110
|
+
sku: v.sku,
|
|
111
|
+
name: v.name,
|
|
112
|
+
price: v.price,
|
|
113
|
+
compareAtPrice: v.compare_at_price,
|
|
114
|
+
stock: v.stock,
|
|
115
|
+
options: JSON.parse(v.options),
|
|
116
|
+
createdAt: new Date(v.created_at),
|
|
117
|
+
updatedAt: new Date(v.updated_at || v.created_at),
|
|
118
|
+
metadata: v.metadata ? JSON.parse(v.metadata) : {},
|
|
119
|
+
})
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return Product.reconstitute(row.id, {
|
|
123
|
+
name: JSON.parse(row.name),
|
|
124
|
+
slug: row.slug,
|
|
125
|
+
description: row.description,
|
|
126
|
+
brand: row.brand,
|
|
127
|
+
status: row.status,
|
|
128
|
+
thumbnail: row.thumbnail,
|
|
129
|
+
variants,
|
|
130
|
+
categoryIds: categoryRows.map((c) => c.category_id),
|
|
131
|
+
createdAt: new Date(row.created_at),
|
|
132
|
+
updatedAt: new Date(row.updated_at || row.created_at),
|
|
133
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type Blueprint, Schema } from '@gravito/atlas'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration to create Catalog tables: categories, products, and variants.
|
|
5
|
+
*/
|
|
6
|
+
export default {
|
|
7
|
+
async up() {
|
|
8
|
+
// 1. Categories Table (Nested Set / Path Pattern)
|
|
9
|
+
await Schema.create('categories', (table: Blueprint) => {
|
|
10
|
+
table.string('id').primary()
|
|
11
|
+
table.string('parent_id').nullable()
|
|
12
|
+
table.string('path').nullable() // e.g., "electronics/computers/laptops"
|
|
13
|
+
table.string('name') // Unified i18n JSON
|
|
14
|
+
table.string('slug').unique()
|
|
15
|
+
table.text('description').nullable()
|
|
16
|
+
table.integer('sort_order').default(0)
|
|
17
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
18
|
+
table.timestamp('updated_at').nullable()
|
|
19
|
+
table.text('metadata').nullable()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// 2. Products Table
|
|
23
|
+
await Schema.create('products', (table: Blueprint) => {
|
|
24
|
+
table.string('id').primary()
|
|
25
|
+
table.string('name')
|
|
26
|
+
table.string('slug').unique()
|
|
27
|
+
table.text('description').nullable()
|
|
28
|
+
table.string('brand').nullable()
|
|
29
|
+
table.string('status').default('active') // active, draft, archived
|
|
30
|
+
table.string('thumbnail').nullable()
|
|
31
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
32
|
+
table.timestamp('updated_at').nullable()
|
|
33
|
+
table.text('metadata').nullable()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
// 3. Product Variants (SKUs)
|
|
37
|
+
await Schema.create('product_variants', (table: Blueprint) => {
|
|
38
|
+
table.string('id').primary()
|
|
39
|
+
table.string('product_id')
|
|
40
|
+
table.string('sku').unique()
|
|
41
|
+
table.string('name').nullable() // Optional variant specific name
|
|
42
|
+
table.decimal('price', 15, 2)
|
|
43
|
+
table.decimal('compare_at_price', 15, 2).nullable() // Original price for sales
|
|
44
|
+
table.integer('stock').default(0)
|
|
45
|
+
table.text('options').nullable() // e.g., {"color": "Blue", "size": "XL"}
|
|
46
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
47
|
+
table.timestamp('updated_at').nullable()
|
|
48
|
+
table.text('metadata').nullable()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
// 4. Pivot Table: Product belongs to many Categories
|
|
52
|
+
await Schema.create('category_product', (table: Blueprint) => {
|
|
53
|
+
table.string('category_id').primary()
|
|
54
|
+
table.string('product_id').primary()
|
|
55
|
+
})
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async down() {
|
|
59
|
+
await Schema.dropIfExists('category_product')
|
|
60
|
+
await Schema.dropIfExists('product_variants')
|
|
61
|
+
await Schema.dropIfExists('products')
|
|
62
|
+
await Schema.dropIfExists('categories')
|
|
63
|
+
},
|
|
64
|
+
}
|