@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.
@@ -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
+ }