wms-module
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.0typeorm^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。