| name | wms-module |
| description | WMS NestJS module (倉儲管理 NestJS 模組). Use when working with warehouse management, inventory tracking (庫存追蹤), location management (儲位管理), stock transactions (庫存異動), batch management (批次管理), or warehouse maps (倉庫地圖). Covers LocationService, MaterialService, StockService, OrderService, WarehouseMapService. Keywords: WMS, warehouse, 倉儲, 庫存, 儲位, TypeORM, NestJS, inventory, batch, stock
|
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',
}),
WMSBaseModule.forRoot({
allowNegativeStock: false,
}),
],
})
export class AppModule {}
非同步設定
import { WMSBaseModule, WMSBaseModuleAsyncOptions } from '@rytass/wms-base-nestjs-module';
import { ConfigService } from '@nestjs/config';
@Module({
imports: [
WMSBaseModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
allowNegativeStock: config.get('WMS_ALLOW_NEGATIVE_STOCK', false),
}),
}),
],
})
export class AppModule {}
Core Entities
模組提供以下基礎 Entity,皆可透過繼承擴展:
LocationEntity (儲位)
import { LocationEntity } from '@rytass/wms-base-nestjs-module';
@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;
}
使用 TypeORM @Tree('materialized-path') 實作樹狀結構
MaterialEntity (物料)
import { MaterialEntity } from '@rytass/wms-base-nestjs-module';
@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;
@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';
@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';
@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';
@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 或自行定義。
import { WarehouseMapService } from '@rytass/wms-base-nestjs-module';
@Entity('warehouse_maps')
class WarehouseMapEntity {
@PrimaryColumn('varchar')
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;
}
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) {}
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],
});
}
async getTransactionLogs(options: StockFindAllDto) {
return this.stockService.findTransactions({
locationIds: ['WAREHOUSE-A'],
materialIds: ['SKU-001'],
batchIds: ['BATCH-001'],
offset: 0,
limit: 20,
sorter: StockSorter.CREATED_AT_DESC,
});
}
}
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'
>;
OrderService (訂單服務)
import {
OrderService,
OrderEntity,
OrderCreateDto,
BatchCreateDto,
} from '@rytass/wms-base-nestjs-module';
import { Entity, Column } from 'typeorm';
@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;
}
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';
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,
[
{
id: 'bg-1',
filename: 'warehouse-floor.png',
x: 0,
y: 0,
width: 1000,
height: 800,
},
],
[
{
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);
}
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 {
stockEntity?: new () => StockEntity;
locationEntity?: new () => LocationEntity;
materialEntity?: new () => MaterialEntity;
batchEntity?: new () => BatchEntity;
orderEntity?: new () => OrderEntity;
warehouseMapEntity?: new () => WarehouseMapEntity;
allowNegativeStock?: boolean;
}
interface WMSBaseModuleAsyncOptions {
imports?: ModuleMetadata['imports'];
inject?: InjectionToken[];
useFactory?: (...args: any[]) => Promise<WMSBaseModuleOptions> | WMSBaseModuleOptions;
useClass?: Type<WMSBaseModuleOptionsFactory>;
useExisting?: Type<WMSBaseModuleOptionsFactory>;
}
interface WMSBaseModuleOptionsFactory {
createWMSBaseModuleOptions(): Promise<WMSBaseModuleOptions> | WMSBaseModuleOptions;
}
Symbol Tokens
可用於依賴注入的 Symbol Tokens:
import {
RESOLVED_TREE_LOCATION_REPO,
RESOLVED_MATERIAL_REPO,
RESOLVED_BATCH_REPO,
RESOLVED_ORDER_REPO,
RESOLVED_STOCK_REPO,
RESOLVED_WAREHOUSE_MAP_REPO,
PROVIDE_LOCATION_ENTITY,
PROVIDE_MATERIAL_ENTITY,
PROVIDE_BATCH_ENTITY,
PROVIDE_ORDER_ENTITY,
PROVIDE_STOCK_ENTITY,
PROVIDE_WAREHOUSE_MAP_ENTITY,
WMS_MODULE_OPTIONS,
ALLOW_NEGATIVE_STOCK,
} 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。