@gravito/satellite-logistics 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 ADDED
@@ -0,0 +1,8 @@
1
+ node_modules
2
+ dist
3
+ .git
4
+ .env
5
+ *.log
6
+ .vscode
7
+ .idea
8
+ tests
package/.env.example ADDED
@@ -0,0 +1,19 @@
1
+ # Application
2
+ APP_NAME="logistics"
3
+ APP_ENV=development
4
+ APP_KEY=
5
+ APP_DEBUG=true
6
+ APP_URL=http://localhost:3000
7
+
8
+ # Server
9
+ PORT=3000
10
+
11
+ # Database
12
+ DB_CONNECTION=sqlite
13
+ DB_DATABASE=database/database.sqlite
14
+
15
+ # Cache
16
+ CACHE_DRIVER=memory
17
+
18
+ # Logging
19
+ LOG_LEVEL=debug
@@ -0,0 +1,14 @@
1
+ # logistics Satellite Architecture
2
+
3
+ This satellite follows the Gravito Satellite Specification v1.0.
4
+
5
+ ## Design
6
+ - **DDD**: Domain logic is separated from framework concerns.
7
+ - **Dogfooding**: Uses official Gravito modules (@gravito/atlas, @gravito/stasis).
8
+ - **Decoupled**: Inter-satellite communication happens via Contracts and Events.
9
+
10
+ ## Layers
11
+ - **Domain**: Pure business rules.
12
+ - **Application**: Orchestration of domain tasks.
13
+ - **Infrastructure**: Implementation of persistence and external services.
14
+ - **Interface**: HTTP and Event entry points.
package/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # @gravito/satellite-logistics
2
+
3
+ ## 0.1.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @gravito/atlas@1.0.1
9
+ - @gravito/core@1.0.0
10
+ - @gravito/enterprise@1.0.0
11
+ - @gravito/stasis@1.0.0
package/Dockerfile ADDED
@@ -0,0 +1,25 @@
1
+ FROM oven/bun:1.0 AS base
2
+ WORKDIR /usr/src/app
3
+
4
+ # Install dependencies
5
+ FROM base AS install
6
+ RUN mkdir -p /temp/dev
7
+ COPY package.json bun.lockb /temp/dev/
8
+ RUN cd /temp/dev && bun install --frozen-lockfile
9
+
10
+ # Build application
11
+ FROM base AS build
12
+ COPY --from=install /temp/dev/node_modules node_modules
13
+ COPY . .
14
+ ENV NODE_ENV=production
15
+ RUN bun run build
16
+
17
+ # Final production image
18
+ FROM base AS release
19
+ COPY --from=build /usr/src/app/dist/bootstrap.js index.js
20
+ COPY --from=build /usr/src/app/package.json .
21
+
22
+ # Create a non-root user for security
23
+ USER bun
24
+ EXPOSE 3000/tcp
25
+ ENTRYPOINT [ "bun", "run", "index.js" ]
@@ -0,0 +1,8 @@
1
+ import { ServiceProvider, Container } from '@gravito/core';
2
+
3
+ declare class LogisticsServiceProvider extends ServiceProvider {
4
+ register(container: Container): void;
5
+ boot(): void;
6
+ }
7
+
8
+ export { LogisticsServiceProvider };
package/dist/index.js ADDED
@@ -0,0 +1,227 @@
1
+ // src/index.ts
2
+ import { ServiceProvider } from "@gravito/core";
3
+
4
+ // src/Application/UseCases/ArrangeShipment.ts
5
+ import { UseCase } from "@gravito/enterprise";
6
+
7
+ // src/Domain/Entities/Shipment.ts
8
+ import { Entity } from "@gravito/enterprise";
9
+ var Shipment = class _Shipment extends Entity {
10
+ constructor(id, props) {
11
+ super(id);
12
+ this.props = props;
13
+ }
14
+ static create(id, props) {
15
+ return new _Shipment(id, {
16
+ ...props,
17
+ status: "pending" /* PENDING */,
18
+ metadata: {}
19
+ });
20
+ }
21
+ markAsShipped(trackingNumber) {
22
+ this.props.status = "shipped" /* SHIPPED */;
23
+ this.props.trackingNumber = trackingNumber;
24
+ }
25
+ get status() {
26
+ return this.props.status;
27
+ }
28
+ get orderId() {
29
+ return this.props.orderId;
30
+ }
31
+ get trackingNumber() {
32
+ return this.props.trackingNumber;
33
+ }
34
+ };
35
+
36
+ // src/Application/UseCases/ArrangeShipment.ts
37
+ var ArrangeShipment = class extends UseCase {
38
+ constructor(repository, manager) {
39
+ super();
40
+ this.repository = repository;
41
+ this.manager = manager;
42
+ }
43
+ async execute(input) {
44
+ const existing = await this.repository.findByOrderId(input.orderId);
45
+ if (existing) {
46
+ throw new Error(`Shipment for order ${input.orderId} already exists.`);
47
+ }
48
+ const provider = this.manager.provider(input.providerName);
49
+ const trackingNumber = await provider.ship(input.orderId, {
50
+ recipient: input.recipientName,
51
+ address: input.address
52
+ });
53
+ const shipmentId = crypto.randomUUID();
54
+ const shipment = Shipment.create(shipmentId, {
55
+ orderId: input.orderId,
56
+ recipientName: input.recipientName,
57
+ address: input.address,
58
+ carrier: provider.getName(),
59
+ cvsType: void 0,
60
+ // 暫時未處理超商
61
+ cvsStoreId: void 0
62
+ });
63
+ shipment.markAsShipped(trackingNumber);
64
+ await this.repository.save(shipment);
65
+ return {
66
+ shipmentId: shipment.id,
67
+ trackingNumber: shipment.trackingNumber,
68
+ status: shipment.status
69
+ };
70
+ }
71
+ };
72
+
73
+ // src/Infrastructure/LogisticsManager.ts
74
+ var LocalShippingProvider = class {
75
+ getName() {
76
+ return "local";
77
+ }
78
+ async calculateCost(weight, _destination) {
79
+ return 60 + weight * 10;
80
+ }
81
+ async ship(orderId, _details) {
82
+ return `LOC-${orderId}-${Date.now().toString().slice(-4)}`;
83
+ }
84
+ };
85
+ var LogisticsManager = class {
86
+ constructor(core) {
87
+ this.core = core;
88
+ this.extend("local", () => new LocalShippingProvider());
89
+ }
90
+ providers = /* @__PURE__ */ new Map();
91
+ extend(name, resolver) {
92
+ this.core.logger.info(`[LogisticsManager] Provider registered: ${name}`);
93
+ this.providers.set(name, resolver);
94
+ }
95
+ provider(name) {
96
+ const providerName = name || this.core.config.get("logistics.default", "local");
97
+ const resolver = this.providers.get(providerName);
98
+ if (!resolver) {
99
+ if (providerName === "local" && !this.providers.has("local")) {
100
+ const local = new LocalShippingProvider();
101
+ this.providers.set("local", () => local);
102
+ return local;
103
+ }
104
+ throw new Error(`Logistics provider [${providerName}] not found.`);
105
+ }
106
+ return resolver();
107
+ }
108
+ };
109
+
110
+ // src/Infrastructure/Persistence/AtlasShipmentRepository.ts
111
+ var AtlasShipmentRepository = class _AtlasShipmentRepository {
112
+ // 暫時使用靜態 Map 模擬資料庫,確保在沒有真實 DB 連接時也能運作 (In-Memory Persistence)
113
+ // 在真實生產環境中,這裡會替換為 Atlas DB 查詢
114
+ static storage = /* @__PURE__ */ new Map();
115
+ async save(entity) {
116
+ const data = {
117
+ id: entity.id,
118
+ orderId: entity.orderId,
119
+ status: entity.status,
120
+ // 這裡需要存取受保護的 props,但在 TypeScript 中通常透過 getter 或 public props 存取
121
+ // 為了演示,我們假設可以獲取 props
122
+ ...entity.props
123
+ };
124
+ console.log(
125
+ `[AtlasShipmentRepository] Persisting shipment ${entity.id} for order ${entity.orderId}`
126
+ );
127
+ _AtlasShipmentRepository.storage.set(entity.id, data);
128
+ }
129
+ async findById(id) {
130
+ const data = _AtlasShipmentRepository.storage.get(id);
131
+ if (!data) {
132
+ return null;
133
+ }
134
+ return new Shipment(data.id, data);
135
+ }
136
+ async findByOrderId(orderId) {
137
+ for (const data of _AtlasShipmentRepository.storage.values()) {
138
+ if (data.orderId === orderId) {
139
+ return new Shipment(data.id, data);
140
+ }
141
+ }
142
+ return null;
143
+ }
144
+ async findByTrackingNumber(trackingNumber) {
145
+ for (const data of _AtlasShipmentRepository.storage.values()) {
146
+ if (data.trackingNumber === trackingNumber) {
147
+ return new Shipment(data.id, data);
148
+ }
149
+ }
150
+ return null;
151
+ }
152
+ async findAll() {
153
+ return Array.from(_AtlasShipmentRepository.storage.values()).map(
154
+ (data) => new Shipment(data.id, data)
155
+ );
156
+ }
157
+ async delete(id) {
158
+ _AtlasShipmentRepository.storage.delete(id);
159
+ }
160
+ async exists(id) {
161
+ return _AtlasShipmentRepository.storage.has(id);
162
+ }
163
+ };
164
+
165
+ // src/index.ts
166
+ var LogisticsServiceProvider = class extends ServiceProvider {
167
+ register(container) {
168
+ container.singleton("logistics.manager", () => new LogisticsManager(this.core));
169
+ container.singleton("logistics.repository", () => new AtlasShipmentRepository());
170
+ container.bind(
171
+ "usecase.arrangeShipment",
172
+ () => new ArrangeShipment(
173
+ container.make("logistics.repository"),
174
+ container.make("logistics.manager")
175
+ )
176
+ );
177
+ }
178
+ boot() {
179
+ const core = this.core;
180
+ if (!core) {
181
+ return;
182
+ }
183
+ core.logger.info("\u{1F6F0}\uFE0F Satellite Logistics is operational");
184
+ core.hooks.addAction(
185
+ "payment:succeeded",
186
+ async (payload) => {
187
+ core.logger.info(
188
+ `[Logistics] Payment verified for order: ${payload.orderId}. Preparing shipment...`
189
+ );
190
+ try {
191
+ const useCase = core.container.make("usecase.arrangeShipment");
192
+ const recipientName = payload.orderData?.recipientName || "Guest User";
193
+ const address = payload.orderData?.address || "Default Address";
194
+ const result = await useCase.execute({
195
+ orderId: payload.orderId,
196
+ recipientName,
197
+ address
198
+ });
199
+ core.logger.info(`[Logistics] Shipment arranged: ${result.trackingNumber}`);
200
+ await core.hooks.doAction("logistics:shipment:prepared", {
201
+ orderId: payload.orderId,
202
+ shipmentId: result.shipmentId,
203
+ trackingNumber: result.trackingNumber,
204
+ status: result.status
205
+ });
206
+ } catch (error) {
207
+ core.logger.error(`[Logistics] Failed to arrange shipment: ${error.message}`);
208
+ }
209
+ }
210
+ );
211
+ core.hooks.addFilter("commerce:order:adjustments", async (adjustments, args) => {
212
+ const _payload = args;
213
+ const manager = core.container.make("logistics.manager");
214
+ const cost = await manager.provider().calculateCost(1, "TW");
215
+ adjustments.push({
216
+ label: "Shipping Fee (Standard)",
217
+ amount: cost,
218
+ sourceType: "shipping",
219
+ sourceId: "standard"
220
+ });
221
+ return adjustments;
222
+ });
223
+ }
224
+ };
225
+ export {
226
+ LogisticsServiceProvider
227
+ };
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@gravito/satellite-logistics",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsup src/index.ts --format esm --dts --clean --external @gravito/core --external @gravito/enterprise --external @gravito/atlas --external @gravito/stasis",
10
+ "test": "bun test",
11
+ "typecheck": "bun tsc -p tsconfig.json --noEmit --skipLibCheck"
12
+ },
13
+ "dependencies": {
14
+ "@gravito/core": "workspace:*",
15
+ "@gravito/enterprise": "workspace:*",
16
+ "@gravito/atlas": "workspace:*",
17
+ "@gravito/stasis": "workspace:*"
18
+ },
19
+ "devDependencies": {
20
+ "tsup": "^8.0.0",
21
+ "typescript": "^5.9.3"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/gravito-framework/gravito.git",
29
+ "directory": "satellites/logistics"
30
+ }
31
+ }
@@ -0,0 +1,67 @@
1
+ import { UseCase } from '@gravito/enterprise'
2
+ import type { IShipmentRepository } from '../../Domain/Contracts/IShipmentRepository'
3
+ import { Shipment } from '../../Domain/Entities/Shipment'
4
+ import type { LogisticsManager } from '../../Infrastructure/LogisticsManager'
5
+
6
+ export interface ArrangeShipmentInput {
7
+ orderId: string
8
+ recipientName: string
9
+ address: string
10
+ providerName?: string // 指定物流商,可選
11
+ items?: any[] // 用於計算重量等
12
+ }
13
+
14
+ export interface ArrangeShipmentOutput {
15
+ shipmentId: string
16
+ trackingNumber: string
17
+ status: string
18
+ }
19
+
20
+ export class ArrangeShipment extends UseCase<ArrangeShipmentInput, ArrangeShipmentOutput> {
21
+ constructor(
22
+ private repository: IShipmentRepository,
23
+ private manager: LogisticsManager
24
+ ) {
25
+ super()
26
+ }
27
+
28
+ async execute(input: ArrangeShipmentInput): Promise<ArrangeShipmentOutput> {
29
+ // 1. 檢查是否已存在運單
30
+ const existing = await this.repository.findByOrderId(input.orderId)
31
+ if (existing) {
32
+ throw new Error(`Shipment for order ${input.orderId} already exists.`)
33
+ }
34
+
35
+ // 2. 取得物流供應商
36
+ const provider = this.manager.provider(input.providerName)
37
+
38
+ // 3. 向供應商請求出貨 (取得追蹤碼)
39
+ const trackingNumber = await provider.ship(input.orderId, {
40
+ recipient: input.recipientName,
41
+ address: input.address,
42
+ })
43
+
44
+ // 4. 建立 Shipment 實體
45
+ const shipmentId = crypto.randomUUID()
46
+ const shipment = Shipment.create(shipmentId, {
47
+ orderId: input.orderId,
48
+ recipientName: input.recipientName,
49
+ address: input.address,
50
+ carrier: provider.getName(),
51
+ cvsType: undefined, // 暫時未處理超商
52
+ cvsStoreId: undefined,
53
+ })
54
+
55
+ // 5. 更新狀態為已出貨 (或根據供應商回應決定)
56
+ shipment.markAsShipped(trackingNumber)
57
+
58
+ // 6. 儲存
59
+ await this.repository.save(shipment)
60
+
61
+ return {
62
+ shipmentId: shipment.id,
63
+ trackingNumber: shipment.trackingNumber!,
64
+ status: shipment.status,
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,7 @@
1
+ import type { Repository } from '@gravito/enterprise'
2
+ import type { Shipment } from '../Entities/Shipment'
3
+
4
+ export interface IShipmentRepository extends Repository<Shipment, string> {
5
+ findByOrderId(orderId: string): Promise<Shipment | null>
6
+ findByTrackingNumber(trackingNumber: string): Promise<Shipment | null>
7
+ }
@@ -0,0 +1,55 @@
1
+ import { Entity } from '@gravito/enterprise'
2
+
3
+ export enum ShipmentStatus {
4
+ PENDING = 'pending',
5
+ PICKED = 'picked',
6
+ SHIPPED = 'shipped',
7
+ DELIVERED = 'delivered',
8
+ CVS_ARRIVED = 'cvs_arrived', // 台灣特色:貨到門市
9
+ RETURNED = 'returned',
10
+ }
11
+
12
+ export interface ShipmentProps {
13
+ orderId: string
14
+ trackingNumber?: string
15
+ carrier: string
16
+ status: ShipmentStatus
17
+ recipientName: string
18
+ address: string
19
+ // 台灣 CVS 擴充屬性
20
+ cvsStoreId?: string
21
+ cvsType?: '7-11' | 'fami'
22
+ metadata: Record<string, any>
23
+ }
24
+
25
+ export class Shipment extends Entity<string> {
26
+ constructor(
27
+ id: string,
28
+ private props: ShipmentProps
29
+ ) {
30
+ super(id)
31
+ }
32
+
33
+ static create(id: string, props: Omit<ShipmentProps, 'status' | 'metadata'>): Shipment {
34
+ return new Shipment(id, {
35
+ ...props,
36
+ status: ShipmentStatus.PENDING,
37
+ metadata: {},
38
+ })
39
+ }
40
+
41
+ markAsShipped(trackingNumber: string): void {
42
+ this.props.status = ShipmentStatus.SHIPPED
43
+ this.props.trackingNumber = trackingNumber
44
+ }
45
+
46
+ get status() {
47
+ return this.props.status
48
+ }
49
+ get orderId() {
50
+ return this.props.orderId
51
+ }
52
+ get trackingNumber() {
53
+ return this.props.trackingNumber
54
+ }
55
+ }
@@ -0,0 +1,55 @@
1
+ import type { PlanetCore } from '@gravito/core'
2
+
3
+ export interface IShippingProvider {
4
+ getName(): string
5
+ calculateCost(weight: number, destination: string): Promise<number>
6
+ ship(orderId: string, details: any): Promise<string> // 回傳 Tracking Number
7
+ }
8
+
9
+ class LocalShippingProvider implements IShippingProvider {
10
+ getName(): string {
11
+ return 'local'
12
+ }
13
+
14
+ async calculateCost(weight: number, _destination: string): Promise<number> {
15
+ // 簡單的計費邏輯:基礎費 60 + 每公斤 10 元
16
+ return 60 + weight * 10
17
+ }
18
+
19
+ async ship(orderId: string, _details: any): Promise<string> {
20
+ // 產生模擬的追蹤碼
21
+ return `LOC-${orderId}-${Date.now().toString().slice(-4)}`
22
+ }
23
+ }
24
+
25
+ export class LogisticsManager {
26
+ private providers = new Map<string, () => IShippingProvider>()
27
+
28
+ constructor(private core: PlanetCore) {
29
+ // 註冊預設的 Local Provider
30
+ this.extend('local', () => new LocalShippingProvider())
31
+ }
32
+
33
+ extend(name: string, resolver: () => IShippingProvider): void {
34
+ this.core.logger.info(`[LogisticsManager] Provider registered: ${name}`)
35
+ this.providers.set(name, resolver)
36
+ }
37
+
38
+ provider(name?: string): IShippingProvider {
39
+ // 優先使用傳入的名稱,其次讀取設定,最後 fallback 到 'local'
40
+ const providerName = name || this.core.config.get<string>('logistics.default', 'local')
41
+ const resolver = this.providers.get(providerName)
42
+
43
+ if (!resolver) {
44
+ // 若找不到,但請求的是 default,則嘗試使用 local
45
+ if (providerName === 'local' && !this.providers.has('local')) {
46
+ const local = new LocalShippingProvider()
47
+ this.providers.set('local', () => local)
48
+ return local
49
+ }
50
+ throw new Error(`Logistics provider [${providerName}] not found.`)
51
+ }
52
+
53
+ return resolver()
54
+ }
55
+ }
@@ -0,0 +1,66 @@
1
+ import type { IShipmentRepository } from '../../Domain/Contracts/IShipmentRepository'
2
+ import { Shipment } from '../../Domain/Entities/Shipment'
3
+ // import { DB } from '@gravito/atlas' // 假設 Atlas DB 尚未完全以此方式導出,我們先用記憶體模擬以確保測試通過,或寫出標準結構
4
+
5
+ export class AtlasShipmentRepository implements IShipmentRepository {
6
+ // 暫時使用靜態 Map 模擬資料庫,確保在沒有真實 DB 連接時也能運作 (In-Memory Persistence)
7
+ // 在真實生產環境中,這裡會替換為 Atlas DB 查詢
8
+ private static storage = new Map<string, any>()
9
+
10
+ async save(entity: Shipment): Promise<void> {
11
+ // 序列化實體
12
+ const data = {
13
+ id: entity.id,
14
+ orderId: entity.orderId,
15
+ status: entity.status,
16
+ // 這裡需要存取受保護的 props,但在 TypeScript 中通常透過 getter 或 public props 存取
17
+ // 為了演示,我們假設可以獲取 props
18
+ ...(entity as any).props,
19
+ }
20
+
21
+ console.log(
22
+ `[AtlasShipmentRepository] Persisting shipment ${entity.id} for order ${entity.orderId}`
23
+ )
24
+ AtlasShipmentRepository.storage.set(entity.id, data)
25
+ }
26
+
27
+ async findById(id: string): Promise<Shipment | null> {
28
+ const data = AtlasShipmentRepository.storage.get(id)
29
+ if (!data) {
30
+ return null
31
+ }
32
+ return new Shipment(data.id, data)
33
+ }
34
+
35
+ async findByOrderId(orderId: string): Promise<Shipment | null> {
36
+ for (const data of AtlasShipmentRepository.storage.values()) {
37
+ if (data.orderId === orderId) {
38
+ return new Shipment(data.id, data)
39
+ }
40
+ }
41
+ return null
42
+ }
43
+
44
+ async findByTrackingNumber(trackingNumber: string): Promise<Shipment | null> {
45
+ for (const data of AtlasShipmentRepository.storage.values()) {
46
+ if (data.trackingNumber === trackingNumber) {
47
+ return new Shipment(data.id, data)
48
+ }
49
+ }
50
+ return null
51
+ }
52
+
53
+ async findAll(): Promise<Shipment[]> {
54
+ return Array.from(AtlasShipmentRepository.storage.values()).map(
55
+ (data) => new Shipment(data.id, data)
56
+ )
57
+ }
58
+
59
+ async delete(id: string): Promise<void> {
60
+ AtlasShipmentRepository.storage.delete(id)
61
+ }
62
+
63
+ async exists(id: string): Promise<boolean> {
64
+ return AtlasShipmentRepository.storage.has(id)
65
+ }
66
+ }
package/src/env.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ interface ImportMeta {
2
+ readonly dir: string
3
+ readonly path: string
4
+ }
package/src/index.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { type Container, ServiceProvider } from '@gravito/core'
2
+ import { ArrangeShipment } from './Application/UseCases/ArrangeShipment'
3
+ import { LogisticsManager } from './Infrastructure/LogisticsManager'
4
+ import { AtlasShipmentRepository } from './Infrastructure/Persistence/AtlasShipmentRepository'
5
+
6
+ export class LogisticsServiceProvider extends ServiceProvider {
7
+ register(container: Container): void {
8
+ container.singleton('logistics.manager', () => new LogisticsManager(this.core!))
9
+ container.singleton('logistics.repository', () => new AtlasShipmentRepository())
10
+
11
+ container.bind(
12
+ 'usecase.arrangeShipment',
13
+ () =>
14
+ new ArrangeShipment(
15
+ container.make('logistics.repository'),
16
+ container.make('logistics.manager')
17
+ )
18
+ )
19
+ }
20
+
21
+ override boot(): void {
22
+ const core = this.core
23
+ if (!core) {
24
+ return
25
+ }
26
+
27
+ core.logger.info('🛰️ Satellite Logistics is operational')
28
+
29
+ /**
30
+ * GASS 聯動:監聽支付成功
31
+ */
32
+ core.hooks.addAction(
33
+ 'payment:succeeded',
34
+ async (payload: { orderId: string; orderData?: any }) => {
35
+ core.logger.info(
36
+ `[Logistics] Payment verified for order: ${payload.orderId}. Preparing shipment...`
37
+ )
38
+
39
+ try {
40
+ const useCase = core.container.make<ArrangeShipment>('usecase.arrangeShipment')
41
+
42
+ // 假設 payload 中包含必要的收件資訊,若無則使用預設值或查詢 Order 服務
43
+ // 這裡為了演示,使用 Payload 中的資料或 Mock 資料
44
+ const recipientName = payload.orderData?.recipientName || 'Guest User'
45
+ const address = payload.orderData?.address || 'Default Address'
46
+
47
+ const result = await useCase.execute({
48
+ orderId: payload.orderId,
49
+ recipientName,
50
+ address,
51
+ })
52
+
53
+ core.logger.info(`[Logistics] Shipment arranged: ${result.trackingNumber}`)
54
+
55
+ // 發射物流準備完成事件
56
+ await core.hooks.doAction('logistics:shipment:prepared', {
57
+ orderId: payload.orderId,
58
+ shipmentId: result.shipmentId,
59
+ trackingNumber: result.trackingNumber,
60
+ status: result.status,
61
+ })
62
+ } catch (error: any) {
63
+ core.logger.error(`[Logistics] Failed to arrange shipment: ${error.message}`)
64
+ }
65
+ }
66
+ )
67
+
68
+ /**
69
+ * GASS 聯動:監聽運費計算 Filter
70
+ */
71
+ core.hooks.addFilter('commerce:order:adjustments', async (adjustments: any[], args: any) => {
72
+ const _payload = args as { order: any }
73
+
74
+ // 預設運費邏輯 (可改為呼叫 Manager 計算)
75
+ const manager = core.container.make<LogisticsManager>('logistics.manager')
76
+ const cost = await manager.provider().calculateCost(1, 'TW') // 假設 1kg
77
+
78
+ adjustments.push({
79
+ label: 'Shipping Fee (Standard)',
80
+ amount: cost,
81
+ sourceType: 'shipping',
82
+ sourceId: 'standard',
83
+ })
84
+
85
+ return adjustments
86
+ })
87
+ }
88
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "logistics",
3
+ "id": "logistics",
4
+ "version": "0.1.0",
5
+ "description": "A Gravito Satellite",
6
+ "capabilities": [
7
+ "create-logistics"
8
+ ],
9
+ "requirements": [
10
+ "cache"
11
+ ],
12
+ "hooks": [
13
+ "logistics:created"
14
+ ]
15
+ }
@@ -0,0 +1,71 @@
1
+ import { beforeEach, describe, expect, it } from 'bun:test'
2
+ import { PlanetCore } from '@gravito/core'
3
+ import { ArrangeShipment } from '../src/Application/UseCases/ArrangeShipment'
4
+ import { LogisticsManager } from '../src/Infrastructure/LogisticsManager'
5
+ import { AtlasShipmentRepository } from '../src/Infrastructure/Persistence/AtlasShipmentRepository'
6
+
7
+ // Mock PlanetCore
8
+ class MockCore extends PlanetCore {
9
+ constructor() {
10
+ super({} as any) // Pass minimal config
11
+ this.logger = {
12
+ info: () => {},
13
+ error: () => {},
14
+ warn: () => {},
15
+ debug: () => {},
16
+ } as any
17
+ this.config = {
18
+ get: (_key: string, def: any) => def,
19
+ } as any
20
+ }
21
+ }
22
+
23
+ describe('Logistics Satellite', () => {
24
+ let repository: AtlasShipmentRepository
25
+ let manager: LogisticsManager
26
+ let useCase: ArrangeShipment
27
+
28
+ beforeEach(() => {
29
+ // Reset singleton if necessary, but here we just instantiate new ones
30
+ repository = new AtlasShipmentRepository()
31
+ manager = new LogisticsManager(new MockCore())
32
+ useCase = new ArrangeShipment(repository, manager)
33
+ })
34
+
35
+ it('should arrange a shipment successfully', async () => {
36
+ const input = {
37
+ orderId: 'ORD-123',
38
+ recipientName: 'Test User',
39
+ address: 'Taipei City',
40
+ }
41
+
42
+ const result = await useCase.execute(input)
43
+
44
+ expect(result.shipmentId).toBeDefined()
45
+ expect(result.trackingNumber).toStartWith('LOC-ORD-123')
46
+ expect(result.status).toBe('shipped')
47
+
48
+ // Verify persistence
49
+ const saved = await repository.findById(result.shipmentId)
50
+ expect(saved).toBeDefined()
51
+ expect(saved?.orderId).toBe('ORD-123')
52
+ })
53
+
54
+ it('should prevent duplicate shipments for the same order', async () => {
55
+ const input = {
56
+ orderId: 'ORD-DUP',
57
+ recipientName: 'Test User',
58
+ address: 'Taipei City',
59
+ }
60
+
61
+ await useCase.execute(input)
62
+
63
+ // Second attempt should fail
64
+ try {
65
+ await useCase.execute(input)
66
+ expect(true).toBe(false) // Should not reach here
67
+ } catch (e: any) {
68
+ expect(e.message).toContain('already exists')
69
+ }
70
+ })
71
+ })
package/tsconfig.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "baseUrl": ".",
6
+ "paths": {
7
+ "@gravito/core": [
8
+ "../../packages/core/src/index.ts"
9
+ ],
10
+ "@gravito/*": [
11
+ "../../packages/*/src/index.ts"
12
+ ]
13
+ },
14
+ "types": [
15
+ "bun-types"
16
+ ]
17
+ },
18
+ "include": [
19
+ "src/**/*"
20
+ ],
21
+ "exclude": [
22
+ "node_modules",
23
+ "dist",
24
+ "**/*.test.ts"
25
+ ]
26
+ }