WMS Base NestJS Module (倉儲管理模組)
Overview
@rytass/wms-base-nestjs-module 提供 NestJS 倉儲管理系統的基礎模組,支援儲位樹狀結構、物料管理、庫存異動追蹤、訂單管理及倉庫地圖功能。
Quick Start
安裝
npm install @rytass/wms-base-nestjs-module
Peer Dependencies:
-
@nestjs/common ^9.0.0 || ^10.0.0
-
@nestjs/typeorm ^9.0.0 || ^10.0.0
-
typeorm ^0.3.0
基本設定
import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WMSBaseModule } from '@rytass/wms-base-nestjs-module';
@Module({ imports: [ TypeOrmModule.forRoot({ type: 'postgres', // ... database config }), WMSBaseModule.forRoot({ allowNegativeStock: false, // 預設: false, 禁止負庫存 }), ], }) export class AppModule {}
非同步設定
import { WMSBaseModule, WMSBaseModuleAsyncOptions } from '@rytass/wms-base-nestjs-module'; import { ConfigService } from '@nestjs/config';
@Module({ imports: [ // useFactory 方式 WMSBaseModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ allowNegativeStock: config.get('WMS_ALLOW_NEGATIVE_STOCK', false), }), }),
// 或 useClass 方式(使用自訂 Factory)
// WMSBaseModule.forRootAsync({
// useClass: WMSConfigFactory,
// }),
// 或 useExisting 方式(重用現有 Factory)
// WMSBaseModule.forRootAsync({
// useExisting: ExistingWMSConfigFactory,
// }),
], }) export class AppModule {}
Core Entities
模組提供以下基礎 Entity,皆可透過繼承擴展:
LocationEntity (儲位)
import { LocationEntity } from '@rytass/wms-base-nestjs-module';
// 基礎結構 - Table: 'locations' @Entity('locations') @TableInheritance({ column: { type: 'varchar', name: 'entityName' } }) @Tree('materialized-path') class LocationEntity { @PrimaryColumn({ type: 'varchar' }) id: string;
@TreeChildren() children: LocationEntity[];
@TreeParent() parent: LocationEntity;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;
@DeleteDateColumn() deletedAt: Date | null; // Soft delete }
使用 TypeORM @Tree('materialized-path') 實作樹狀結構
MaterialEntity (物料)
import { MaterialEntity } from '@rytass/wms-base-nestjs-module';
// Table: 'materials' @Entity('materials') @TableInheritance({ column: { type: 'varchar', name: 'entityName' } }) class MaterialEntity { @PrimaryColumn({ type: 'varchar' }) id: string;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;
@DeleteDateColumn() deletedAt: Date | null; // Soft delete
@OneToMany(() => BatchEntity, batch => batch.material) batches: Relation<BatchEntity[]>;
@OneToMany(() => StockEntity, stock => stock.material) stocks: Relation<StockEntity[]>; }
BatchEntity (批次)
import { BatchEntity } from '@rytass/wms-base-nestjs-module';
// Table: 'batches' // 複合主鍵: id + materialId @Entity('batches') class BatchEntity { @PrimaryColumn('varchar') id: string;
@PrimaryColumn('varchar') materialId: string;
@ManyToOne(() => MaterialEntity, material => material.batches) @JoinColumn({ name: 'materialId', referencedColumnName: 'id' }) material: Relation<MaterialEntity>;
@OneToMany(() => StockEntity, stock => stock.batch) stocks: Relation<StockEntity[]>; }
StockEntity (庫存異動)
import { StockEntity } from '@rytass/wms-base-nestjs-module';
// Table: 'stocks' @Entity('stocks') @TableInheritance({ column: { type: 'varchar', name: 'entityName' } }) class StockEntity { @PrimaryGeneratedColumn('uuid') id: string;
@Column({ type: 'varchar' }) materialId: string;
@Column({ type: 'varchar' }) batchId: string;
@Column({ type: 'varchar' }) locationId: string;
@Column({ type: 'varchar' }) orderId: string;
@Column({ type: 'numeric' }) quantity: number; // 正數為入庫,負數為出庫
@ManyToOne(() => MaterialEntity, material => material.stocks) @JoinColumn({ name: 'materialId', referencedColumnName: 'id' }) material: Relation<MaterialEntity>;
@ManyToOne(() => BatchEntity, batch => batch.stocks) @JoinColumn([ { name: 'materialId', referencedColumnName: 'materialId' }, { name: 'batchId', referencedColumnName: 'id' }, ]) batch: Relation<BatchEntity>;
@ManyToOne(() => OrderEntity, order => order.stocks) @JoinColumn({ name: 'orderId', referencedColumnName: 'id' }) order: Relation<OrderEntity>;
@CreateDateColumn() createdAt: Date; }
OrderEntity (訂單)
import { OrderEntity } from '@rytass/wms-base-nestjs-module';
// Table: 'orders' @Entity('orders') @TableInheritance({ column: { type: 'varchar', name: 'entityName' } }) class OrderEntity { @PrimaryGeneratedColumn('uuid') id: string;
@OneToMany(() => StockEntity, stock => stock.order) stocks: Relation<StockEntity>[]; }
WarehouseMapEntity (倉庫地圖)
注意: WarehouseMapService 有從 index.ts 導出,可直接 import。但 WarehouseMapEntity 、MapRangeType 、MapRangeColor 、MapData 等類型目前未從 index.ts 導出,需直接從原始碼路徑 import 或自行定義。
// WarehouseMapService 可直接 import import { WarehouseMapService } from '@rytass/wms-base-nestjs-module';
// WarehouseMapEntity 需從原始碼路徑 import(若必要)
// Table: 'warehouse_maps' @Entity('warehouse_maps') class WarehouseMapEntity { @PrimaryColumn('varchar') // 對應 locationId id: string;
@Column({ type: 'jsonb' }) mapData: MapData;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date; }
Services
LocationService (儲位服務)
import { LocationService, LocationEntity } from '@rytass/wms-base-nestjs-module';
@Injectable() export class WarehouseService { constructor(private readonly locationService: LocationService) {}
// 建立儲位 async createLocation() { // 建立根儲位 const warehouse = await this.locationService.create({ id: 'WAREHOUSE-A', });
// 建立子儲位
const zone = await this.locationService.create({
id: 'ZONE-A1',
parentId: 'WAREHOUSE-A',
});
return zone;
}
// 封存儲位 (Soft Delete) // 注意: 只有庫存為 0 的儲位才能封存 async archiveLocation(id: string) { await this.locationService.archive(id); // 會連同所有子儲位一起封存 }
// 解除封存 async unArchiveLocation(id: string) { const location = await this.locationService.unArchive(id); return location; } }
MaterialService (物料服務)
import { MaterialService, MaterialEntity } from '@rytass/wms-base-nestjs-module';
@Injectable() export class ProductService { constructor(private readonly materialService: MaterialService) {}
// 建立物料 async createMaterial() { const material = await this.materialService.create({ id: 'SKU-001', });
return material;
} }
StockService (庫存服務)
import { StockService, StockFindDto, StockFindAllDto, StockSorter, } from '@rytass/wms-base-nestjs-module';
@Injectable() export class InventoryService { constructor(private readonly stockService: StockService) {}
// 查詢庫存數量 (回傳加總數量) // find() 可選傳入 manager 參數供交易使用 async getStockQuantity(locationId: string, materialId: string): Promise<number> { return this.stockService.find({ locationIds: [locationId], materialIds: [materialId], exactLocationMatch: true, // 只查詢該儲位,不包含子儲位 }); }
// 查詢儲位樹下的總庫存 (包含所有子儲位) async getTotalStock(locationId: string): Promise<number> { return this.stockService.find({ locationIds: [locationId], // exactLocationMatch: false (預設) - 包含所有子儲位 }); }
// 查詢庫存異動記錄 async getTransactionLogs(options: StockFindAllDto) { return this.stockService.findTransactions({ locationIds: ['WAREHOUSE-A'], materialIds: ['SKU-001'], batchIds: ['BATCH-001'], offset: 0, limit: 20, // 最大 100 sorter: StockSorter.CREATED_AT_DESC, }); // 回傳: StockCollectionDto { transactionLogs, total, offset, limit } } }
StockFindDto 參數:
參數 類型 說明
locationIds
string[]
儲位 ID 列表
materialIds
string[]
物料 ID 列表
batchIds
string[]
批次 ID 列表
exactLocationMatch
boolean
true : 只查詢指定儲位;false (預設): 包含子儲位
StockFindAllDto 額外參數:
參數 類型 預設值 說明
offset
number
0
分頁偏移
limit
number
20
每頁筆數 (最大 100)
sorter
StockSorter
CREATED_AT_DESC
排序方式
StockCollectionDto 回傳結構:
interface StockCollectionDto { transactionLogs: StockTransactionLogDto[]; total: number; offset: number; limit: number; }
// 泛型版本,排除關聯欄位 type StockTransactionLogDto<Stock extends StockEntity = StockEntity> = Omit< Stock, 'material' | 'batch' | 'location' | 'order'
;
// 實際包含欄位(以預設 StockEntity 為例): // { id, materialId, batchId, locationId, orderId, quantity, createdAt } // 注意: 原始碼 Omit 了 'location',但 StockEntity 實際上沒有 location relation // (只有 locationId 欄位),這是防禦性設計
OrderService (訂單服務)
import { OrderService, OrderEntity, OrderCreateDto, BatchCreateDto, } from '@rytass/wms-base-nestjs-module'; import { Entity, Column } from 'typeorm';
// 擴展訂單 Entity @Entity('custom_orders') export class CustomOrderEntity extends OrderEntity { @Column() orderNumber: string;
@Column() type: 'INBOUND' | 'OUTBOUND'; }
@Injectable() export class OrderManagementService { constructor(private readonly orderService: OrderService) {}
// 建立入庫訂單 async createInboundOrder() { const order = await this.orderService.createOrder(CustomOrderEntity, { order: { orderNumber: 'IN-2024-001', type: 'INBOUND', }, batches: [ { id: 'BATCH-001', materialId: 'SKU-001', locationId: 'ZONE-A1', quantity: 100, // 正數: 入庫 }, ], });
return order;
}
// 建立出庫訂單 async createOutboundOrder() { const order = await this.orderService.createOrder(CustomOrderEntity, { order: { orderNumber: 'OUT-2024-001', type: 'OUTBOUND', }, batches: [ { id: 'BATCH-001', materialId: 'SKU-001', locationId: 'ZONE-A1', quantity: -50, // 負數: 出庫 }, ], });
return order;
// 若 allowNegativeStock: false 且庫存不足,會拋出 StockQuantityNotEnoughError
}
// 檢查是否可建立庫存異動 async canCreateStock(batch: BatchCreateDto): Promise<boolean> { return this.orderService.canCreateStock(batch); } }
BatchCreateDto:
interface BatchCreateDto { id: string; materialId: string; locationId: string; quantity: number; }
OrderCreateDto:
type OrderDto<O extends OrderEntity = OrderEntity> = DeepPartial<Omit<O, 'stocks'>>;
type OrderCreateDto<O extends OrderEntity = OrderEntity> = { order: OrderDto<O>; batches: BatchCreateDto[]; };
WarehouseMapService (倉庫地圖服務)
注意: WarehouseMapService 有從 index.ts 導出,可直接 import。但 MapRangeType 和 MapRangeColor 目前未從 index.ts 導出,需自行定義或直接使用字串值。
import { WarehouseMapService } from '@rytass/wms-base-nestjs-module'; // MapRangeType/MapRangeColor 可直接使用字串值或自行定義 enum
// 可自行定義 enum 或使用字串常數 enum MapRangeType { RECTANGLE = 'RECTANGLE', POLYGON = 'POLYGON', }
enum MapRangeColor { RED = 'RED', YELLOW = 'YELLOW', GREEN = 'GREEN', BLUE = 'BLUE', BLACK = 'BLACK', }
@Injectable() export class MapService { constructor(private readonly warehouseMapService: WarehouseMapService) {}
// 更新倉庫地圖 async updateMap(locationId: string) { const map = await this.warehouseMapService.updateMap( locationId, // backgrounds: 背景圖片 [ { id: 'bg-1', filename: 'warehouse-floor.png', x: 0, y: 0, width: 1000, height: 800, }, ], // ranges: 區域標記 [ // 矩形區域 { id: 'zone-a1', type: MapRangeType.RECTANGLE, color: MapRangeColor.GREEN, x: 100, y: 100, width: 200, height: 150, }, // 多邊形區域 { id: 'zone-special', type: MapRangeType.POLYGON, color: MapRangeColor.YELLOW, points: [ { x: 400, y: 100 }, { x: 500, y: 100 }, { x: 550, y: 200 }, { x: 400, y: 200 }, ], }, ], );
return map;
}
// 取得地圖資料 async getMap(locationId: string) { return this.warehouseMapService.getMapById(locationId); // 若不存在回傳: { id, backgrounds: [], ranges: [] } }
// 刪除地圖 async deleteMap(locationId: string) { await this.warehouseMapService.deleteMapById(locationId); } }
Custom Entities (擴展 Entity)
透過 forRoot 或 forRootAsync 傳入自訂 Entity:
import { WMSBaseModule, LocationEntity, MaterialEntity, StockEntity, BatchEntity, OrderEntity, } from '@rytass/wms-base-nestjs-module'; import { Entity, Column } from 'typeorm';
// 擴展儲位 @Entity('custom_locations') export class CustomLocationEntity extends LocationEntity { @Column() name: string;
@Column({ nullable: true }) description: string; }
// 擴展物料 @Entity('custom_materials') export class CustomMaterialEntity extends MaterialEntity { @Column() name: string;
@Column() sku: string; }
// 模組設定 @Module({ imports: [ WMSBaseModule.forRoot({ locationEntity: CustomLocationEntity, materialEntity: CustomMaterialEntity, stockEntity: StockEntity, // 使用預設 batchEntity: BatchEntity, // 使用預設 orderEntity: OrderEntity, // 使用預設 allowNegativeStock: false, }), ], }) export class AppModule {}
Data Types
MapData
interface MapData { id: string; backgrounds: MapBackground[]; ranges: (MapRectangleRange | MapPolygonRange)[]; }
interface MapBackground { id: string; filename: string; x: number; y: number; height: number; width: number; }
interface MapRange { id: string; type: MapRangeType; color: string; }
interface MapRectangleRange extends MapRange { x: number; y: number; width: number; height: number; }
interface MapPolygonRange extends MapRange { points: MapPolygonRangePoint[]; }
interface MapPolygonRangePoint { x: number; y: number; }
Enums
enum MapRangeType { RECTANGLE = 'RECTANGLE', POLYGON = 'POLYGON', }
enum MapRangeColor { RED = 'RED', YELLOW = 'YELLOW', GREEN = 'GREEN', BLUE = 'BLUE', BLACK = 'BLACK', }
enum StockSorter { CREATED_AT_DESC = 'CREATED_AT_DESC', CREATED_AT_ASC = 'CREATED_AT_ASC', }
Error Handling
import { LocationNotFoundError, LocationCannotArchiveError, LocationAlreadyExistedError, StockQuantityNotEnoughError, } from '@rytass/wms-base-nestjs-module';
錯誤代碼表:
Code Error Description
100 LocationNotFoundError 儲位不存在
101 LocationCannotArchiveError 儲位無法封存 (庫存不為 0)
102 LocationAlreadyExistedError 儲位已存在
200 StockQuantityNotEnoughError 庫存數量不足
@Injectable() export class SafeLocationService { constructor(private readonly locationService: LocationService) {}
async safeArchive(id: string) { try { await this.locationService.archive(id); } catch (error) { if (error instanceof LocationCannotArchiveError) { throw new Error('無法封存儲位,請先清空庫存'); } if (error instanceof LocationNotFoundError) { throw new Error('儲位不存在'); } throw error; } } }
Configuration Options
interface WMSBaseModuleOptions { // 自訂 Entity (皆為選填) stockEntity?: new () => StockEntity; locationEntity?: new () => LocationEntity; materialEntity?: new () => MaterialEntity; batchEntity?: new () => BatchEntity; orderEntity?: new () => OrderEntity; warehouseMapEntity?: new () => WarehouseMapEntity;
// 選項 allowNegativeStock?: boolean; // 預設: false }
// Async Options interface WMSBaseModuleAsyncOptions { imports?: ModuleMetadata['imports']; inject?: InjectionToken[]; useFactory?: (...args: any[]) => Promise<WMSBaseModuleOptions> | WMSBaseModuleOptions; useClass?: Type<WMSBaseModuleOptionsFactory>; useExisting?: Type<WMSBaseModuleOptionsFactory>; }
// Options Factory Interface interface WMSBaseModuleOptionsFactory { createWMSBaseModuleOptions(): Promise<WMSBaseModuleOptions> | WMSBaseModuleOptions; }
Symbol Tokens
可用於依賴注入的 Symbol Tokens:
import { // Repository Tokens RESOLVED_TREE_LOCATION_REPO, // TreeRepository<LocationEntity> RESOLVED_MATERIAL_REPO, // Repository<MaterialEntity> RESOLVED_BATCH_REPO, // Repository<BatchEntity> RESOLVED_ORDER_REPO, // Repository<OrderEntity> RESOLVED_STOCK_REPO, // Repository<StockEntity> RESOLVED_WAREHOUSE_MAP_REPO, // Repository<WarehouseMapEntity>
// Entity Provider Tokens PROVIDE_LOCATION_ENTITY, PROVIDE_MATERIAL_ENTITY, PROVIDE_BATCH_ENTITY, PROVIDE_ORDER_ENTITY, PROVIDE_STOCK_ENTITY, PROVIDE_WAREHOUSE_MAP_ENTITY,
// Options Tokens WMS_MODULE_OPTIONS, // WMSBaseModuleOptions ALLOW_NEGATIVE_STOCK, // boolean } from '@rytass/wms-base-nestjs-module';
// 使用範例 @Injectable() export class CustomService { constructor( @Inject(RESOLVED_TREE_LOCATION_REPO) private readonly locationRepo: TreeRepository<LocationEntity>,
@Inject(RESOLVED_STOCK_REPO)
private readonly stockRepo: Repository<StockEntity>,
@Inject(ALLOW_NEGATIVE_STOCK)
private readonly allowNegativeStock: boolean,
) {} }
Complete Example
import { Module, Injectable } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { WMSBaseModule, LocationService, MaterialService, StockService, OrderService, OrderEntity, StockSorter, } from '@rytass/wms-base-nestjs-module'; import { Entity, Column } from 'typeorm';
// 自訂訂單 @Entity('warehouse_orders') export class WarehouseOrderEntity extends OrderEntity { @Column() orderNumber: string;
@Column() type: 'INBOUND' | 'OUTBOUND' | 'TRANSFER';
@Column({ nullable: true }) note: string; }
// 倉儲服務 @Injectable() export class WarehouseManagementService { constructor( private readonly locationService: LocationService, private readonly materialService: MaterialService, private readonly stockService: StockService, private readonly orderService: OrderService, ) {}
// 初始化倉庫結構 async initializeWarehouse() { const warehouse = await this.locationService.create({ id: 'WH-001' }); const zoneA = await this.locationService.create({ id: 'WH-001-A', parentId: 'WH-001' }); const zoneB = await this.locationService.create({ id: 'WH-001-B', parentId: 'WH-001' });
return { warehouse, zoneA, zoneB };
}
// 入庫作業 async inbound(materialId: string, locationId: string, quantity: number, batchId: string) { // 確保物料存在 await this.materialService.create({ id: materialId });
// 建立入庫訂單
const order = await this.orderService.createOrder(WarehouseOrderEntity, {
order: {
orderNumber: `IN-${Date.now()}`,
type: 'INBOUND',
},
batches: [{
id: batchId,
materialId,
locationId,
quantity, // 正數
}],
});
return order;
}
// 出庫作業 async outbound(materialId: string, locationId: string, quantity: number, batchId: string) { // 檢查庫存 const stock = await this.stockService.find({ locationIds: [locationId], materialIds: [materialId], batchIds: [batchId], exactLocationMatch: true, });
if (stock < quantity) {
throw new Error(`庫存不足: 現有 ${stock}, 需要 ${quantity}`);
}
// 建立出庫訂單
const order = await this.orderService.createOrder(WarehouseOrderEntity, {
order: {
orderNumber: `OUT-${Date.now()}`,
type: 'OUTBOUND',
},
batches: [{
id: batchId,
materialId,
locationId,
quantity: -quantity, // 負數
}],
});
return order;
}
// 查詢庫存 async getInventory(locationId: string) { const total = await this.stockService.find({ locationIds: [locationId] }); const logs = await this.stockService.findTransactions({ locationIds: [locationId], limit: 10, sorter: StockSorter.CREATED_AT_DESC, });
return { total, recentLogs: logs.transactionLogs };
} }
// 模組 @Module({ imports: [ TypeOrmModule.forRoot({ type: 'postgres', host: 'localhost', database: 'wms', entities: [WarehouseOrderEntity], synchronize: true, }), WMSBaseModule.forRoot({ orderEntity: WarehouseOrderEntity, allowNegativeStock: false, }), ], providers: [WarehouseManagementService], }) export class WarehouseModule {}
Troubleshooting
負庫存錯誤
如果 allowNegativeStock: false ,出庫數量超過庫存會拋出 StockQuantityNotEnoughError 。 先查詢庫存再執行出庫操作。
交易失敗
createOrder 使用資料庫交易,任何批次失敗都會回滾整個訂單。 確保所有批次資料正確再提交。
倉位無法封存
當嘗試封存一個仍有庫存的倉位時,會拋出 LocationCannotArchiveError 。 需先將庫存移出(出庫或調撥)後才能封存倉位。
倉位已存在錯誤
建立儲位時,若 ID 已存在(含已封存的),會拋出 LocationAlreadyExistedError 。 可以先解除封存 (unArchive ) 或使用不同的 ID。