wms-module

📁 rytass/utils 📅 8 days ago
10
总安装量
2
周安装量
#29921
全站排名
安装命令
npx skills add https://github.com/rytass/utils --skill wms-module

Agent 安装分布

replit 2
amp 2
opencode 2
kimi-cli 2
github-copilot 2

Skill 文档

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。