@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 +8 -0
- package/.env.example +19 -0
- package/ARCHITECTURE.md +14 -0
- package/CHANGELOG.md +11 -0
- package/Dockerfile +25 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +227 -0
- package/package.json +31 -0
- package/src/Application/UseCases/ArrangeShipment.ts +67 -0
- package/src/Domain/Contracts/IShipmentRepository.ts +7 -0
- package/src/Domain/Entities/Shipment.ts +55 -0
- package/src/Infrastructure/LogisticsManager.ts +55 -0
- package/src/Infrastructure/Persistence/AtlasShipmentRepository.ts +66 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +88 -0
- package/src/manifest.json +15 -0
- package/tests/unit.test.ts +71 -0
- package/tsconfig.json +26 -0
package/.dockerignore
ADDED
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
|
package/ARCHITECTURE.md
ADDED
|
@@ -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
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" ]
|
package/dist/index.d.ts
ADDED
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
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,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
|
+
}
|