mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 23:40:56 +00:00
feat(warehouse): add warehouse management module (batch 1)
Backend (edr-freight-api): - Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory entities - CRUD for warehouses/yards/zones with scoped code uniqueness - Inventory receive with location-hierarchy + capacity validation and transactional capacity-counter updates - inspect / ready-for-loading status transitions - inventory inquiry (booking/customer/container/cargo-type joins) - migration creating freight.warehouse* tables Frontend (freight backoffice): - types, service, react-query hooks, URL constants - reusable components: badges, filters, table/card views, inventory and inquiry tables, create/receive modals - pages: Warehouse list, detail (overview/yards/zones/inventory tabs), inventory, inventory inquiry - Warehouse Information card + Receive At Warehouse on booking detail - routes + sidebar nav; app-wide ErrorBoundary Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_YARD_TYPES, WarehouseYardType } from '../entities/warehouse-yard.entity';
|
||||
|
||||
export class CreateWarehouseYardDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ enum: WAREHOUSE_YARD_TYPES })
|
||||
@IsEnum(WAREHOUSE_YARD_TYPES)
|
||||
type!: WarehouseYardType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_ZONE_TYPES, WarehouseZoneType } from '../entities/warehouse-zone.entity';
|
||||
|
||||
export class CreateWarehouseZoneDto {
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Optional — taken from the route param when omitted' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ enum: WAREHOUSE_ZONE_TYPES })
|
||||
@IsEnum(WAREHOUSE_ZONE_TYPES)
|
||||
type!: WarehouseZoneType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_TYPES, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
export class CreateWarehouseDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(160)
|
||||
name!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(40)
|
||||
code!: string;
|
||||
|
||||
@ApiProperty({ enum: WAREHOUSE_TYPES })
|
||||
@IsEnum(WAREHOUSE_TYPES)
|
||||
type!: WarehouseType;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
stationId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
locationName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
WAREHOUSE_INVENTORY_STATUSES,
|
||||
WarehouseInventoryStatus,
|
||||
} from '../entities/warehouse-inventory.entity';
|
||||
|
||||
export class FilterWarehouseInventoryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
goodsId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
|
||||
status?: WarehouseInventoryStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_STATUSES, WAREHOUSE_TYPES, WarehouseStatus, WarehouseType } from '../entities/warehouse.entity';
|
||||
|
||||
export class FilterWarehouseDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_TYPES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_TYPES)
|
||||
type?: WarehouseType;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
stationId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_STATUSES)
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import {
|
||||
WAREHOUSE_INVENTORY_STATUSES,
|
||||
WarehouseInventoryStatus,
|
||||
} from '../entities/warehouse-inventory.entity';
|
||||
|
||||
export class InquiryWarehouseInventoryDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cargoType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
goodsName?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
warehouseId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
yardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
zoneId?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_INVENTORY_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_INVENTORY_STATUSES)
|
||||
status?: WarehouseInventoryStatus;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
|
||||
export class ReceiveWarehouseInventoryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
containerId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
goodsId?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
quantity!: number;
|
||||
|
||||
@ApiProperty()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
weight!: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
volume?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_YARD_STATUSES, WarehouseYardStatus } from '../entities/warehouse-yard.entity';
|
||||
import { CreateWarehouseYardDto } from './create-warehouse-yard.dto';
|
||||
|
||||
export class UpdateWarehouseYardDto extends PartialType(CreateWarehouseYardDto) {
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_YARD_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_YARD_STATUSES)
|
||||
status?: WarehouseYardStatus;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_ZONE_STATUSES, WarehouseZoneStatus } from '../entities/warehouse-zone.entity';
|
||||
import { CreateWarehouseZoneDto } from './create-warehouse-zone.dto';
|
||||
|
||||
export class UpdateWarehouseZoneDto extends PartialType(CreateWarehouseZoneDto) {
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_ZONE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_ZONE_STATUSES)
|
||||
status?: WarehouseZoneStatus;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ApiPropertyOptional, PartialType } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional } from 'class-validator';
|
||||
|
||||
import { WAREHOUSE_STATUSES, WarehouseStatus } from '../entities/warehouse.entity';
|
||||
import { CreateWarehouseDto } from './create-warehouse.dto';
|
||||
|
||||
export class UpdateWarehouseDto extends PartialType(CreateWarehouseDto) {
|
||||
@ApiPropertyOptional({ enum: WAREHOUSE_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(WAREHOUSE_STATUSES)
|
||||
status?: WarehouseStatus;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
|
||||
export const WAREHOUSE_INVENTORY_STATUSES = [
|
||||
'ARRIVED_AT_WAREHOUSE',
|
||||
'UNDER_INSPECTION',
|
||||
'READY_FOR_LOADING',
|
||||
] as const;
|
||||
export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['yardId'])
|
||||
@Index(['zoneId'])
|
||||
@Index(['bookingId'])
|
||||
@Index(['cargoId'])
|
||||
@Index(['containerId'])
|
||||
@Index(['goodsId'])
|
||||
@Index(['status'])
|
||||
export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||
warehouseId!: string;
|
||||
|
||||
@ManyToOne(() => Warehouse)
|
||||
@JoinColumn({ name: 'warehouse_id' })
|
||||
warehouse?: Warehouse;
|
||||
|
||||
@Column({ name: 'yard_id', type: 'uuid' })
|
||||
yardId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseYard)
|
||||
@JoinColumn({ name: 'yard_id' })
|
||||
yard?: WarehouseYard;
|
||||
|
||||
@Column({ name: 'zone_id', type: 'uuid' })
|
||||
zoneId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseZone)
|
||||
@JoinColumn({ name: 'zone_id' })
|
||||
zone?: WarehouseZone;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
|
||||
cargoId?: string | null;
|
||||
|
||||
@Column({ name: 'container_id', type: 'uuid', nullable: true })
|
||||
containerId?: string | null;
|
||||
|
||||
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
||||
goodsId?: string | null;
|
||||
|
||||
@Column({ name: 'quantity', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
quantity!: number;
|
||||
|
||||
@Column({ name: 'weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
weight!: number;
|
||||
|
||||
@Column({ name: 'volume', type: 'numeric', precision: 12, scale: 3, nullable: true })
|
||||
volume?: number | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'ARRIVED_AT_WAREHOUSE' })
|
||||
status!: WarehouseInventoryStatus;
|
||||
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'inspected_at', type: 'timestamptz', nullable: true })
|
||||
inspectedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true })
|
||||
readyForLoadingAt?: Date | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
|
||||
export const WAREHOUSE_YARD_TYPES = [
|
||||
'CONTAINER_YARD',
|
||||
'BULK_YARD',
|
||||
'GENERAL_CARGO_YARD',
|
||||
'HAZARDOUS_YARD',
|
||||
'COLD_STORAGE_YARD',
|
||||
] as const;
|
||||
export type WarehouseYardType = (typeof WAREHOUSE_YARD_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_YARD_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseYardStatus = (typeof WAREHOUSE_YARD_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_yards' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['type'])
|
||||
@Index(['status'])
|
||||
export class WarehouseYard extends BaseEntity {
|
||||
@Column({ name: 'warehouse_id', type: 'uuid' })
|
||||
warehouseId!: string;
|
||||
|
||||
@ManyToOne(() => Warehouse, (warehouse) => warehouse.yards, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'warehouse_id' })
|
||||
warehouse?: Warehouse;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'code', type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||
type!: WarehouseYardType;
|
||||
|
||||
@Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
capacityWeight?: number | null;
|
||||
|
||||
@Column({ name: 'capacity_containers', type: 'int', nullable: true })
|
||||
capacityContainers?: number | null;
|
||||
|
||||
@Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentWeight!: number;
|
||||
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseYardStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => WarehouseZone, (zone) => zone.yard)
|
||||
zones?: WarehouseZone[];
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
|
||||
export const WAREHOUSE_ZONE_TYPES = [
|
||||
'CONTAINER_ZONE',
|
||||
'BULK_ZONE',
|
||||
'GENERAL_CARGO_ZONE',
|
||||
'HAZARDOUS_ZONE',
|
||||
'COLD_STORAGE_ZONE',
|
||||
] as const;
|
||||
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_ZONE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseZoneStatus = (typeof WAREHOUSE_ZONE_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_zones' })
|
||||
@Index(['yardId'])
|
||||
@Index(['type'])
|
||||
@Index(['status'])
|
||||
export class WarehouseZone extends BaseEntity {
|
||||
@Column({ name: 'yard_id', type: 'uuid' })
|
||||
yardId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseYard, (yard) => yard.zones, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'yard_id' })
|
||||
yard?: WarehouseYard;
|
||||
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'code', type: 'varchar', length: 40 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||
type!: WarehouseZoneType;
|
||||
|
||||
@Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
capacityWeight?: number | null;
|
||||
|
||||
@Column({ name: 'capacity_containers', type: 'int', nullable: true })
|
||||
capacityContainers?: number | null;
|
||||
|
||||
@Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentWeight!: number;
|
||||
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseZoneStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, OneToMany } from 'typeorm';
|
||||
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
|
||||
export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const;
|
||||
export type WarehouseType = (typeof WAREHOUSE_TYPES)[number];
|
||||
|
||||
export const WAREHOUSE_STATUSES = ['ACTIVE', 'INACTIVE'] as const;
|
||||
export type WarehouseStatus = (typeof WAREHOUSE_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouses' })
|
||||
@Index(['code'], { unique: true })
|
||||
@Index(['type'])
|
||||
@Index(['status'])
|
||||
@Index(['stationId'])
|
||||
export class Warehouse extends BaseEntity {
|
||||
@Column({ name: 'name', type: 'varchar', length: 160 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'type', type: 'varchar', length: 32 })
|
||||
type!: WarehouseType;
|
||||
|
||||
@Column({ name: 'station_id', type: 'uuid', nullable: true })
|
||||
stationId?: string | null;
|
||||
|
||||
@Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true })
|
||||
locationName?: string | null;
|
||||
|
||||
@Column({ name: 'capacity_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
capacityWeight?: number | null;
|
||||
|
||||
@Column({ name: 'capacity_containers', type: 'int', nullable: true })
|
||||
capacityContainers?: number | null;
|
||||
|
||||
@Column({ name: 'current_weight', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentWeight!: number;
|
||||
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseStatus;
|
||||
|
||||
@Column({ name: 'is_active', type: 'boolean', default: true })
|
||||
isActive!: boolean;
|
||||
|
||||
@OneToMany(() => WarehouseYard, (yard) => yard.warehouse)
|
||||
yards?: WarehouseYard[];
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-inventory')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-inventory')
|
||||
export class WarehouseInventoryController {
|
||||
constructor(private readonly inventoryService: WarehouseInventoryService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List warehouse inventory' })
|
||||
findAll(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get('ready-for-loading')
|
||||
@ApiOperation({ summary: 'List inventory ready for loading' })
|
||||
findReadyForLoading(@Query() filter: FilterWarehouseInventoryDto) {
|
||||
return this.inventoryService.findReadyForLoading(filter);
|
||||
}
|
||||
|
||||
@Get('inquiry')
|
||||
@ApiOperation({ summary: 'Locate any item inside the warehouse' })
|
||||
inquiry(@Query() filter: InquiryWarehouseInventoryDto) {
|
||||
return this.inventoryService.inquiry(filter);
|
||||
}
|
||||
|
||||
@Post('receive')
|
||||
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
|
||||
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
|
||||
return this.inventoryService.receive(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/inspect')
|
||||
@ApiOperation({ summary: 'Move inventory to UNDER_INSPECTION' })
|
||||
inspect(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.inspect(id);
|
||||
}
|
||||
|
||||
@Patch(':id/ready-for-loading')
|
||||
@ApiOperation({ summary: 'Move inventory to READY_FOR_LOADING' })
|
||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.readyForLoading(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryRepository extends BaseRepository<WarehouseInventory> {
|
||||
constructor(@InjectRepository(WarehouseInventory) repository: Repository<WarehouseInventory>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
|
||||
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
bookingNumber: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
cargoDescription: string | null;
|
||||
goodsId: string | null;
|
||||
warehouse: { id: string; name: string; code: string } | null;
|
||||
yard: { id: string; name: string; code: string } | null;
|
||||
zone: { id: string; name: string; code: string } | null;
|
||||
status: string;
|
||||
quantity: number;
|
||||
weight: number;
|
||||
arrivedAt: Date | null;
|
||||
readyForLoadingAt: Date | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||
) {}
|
||||
|
||||
// ── Listing ────────────────────────────────────────────────────────────
|
||||
|
||||
findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||
const base = {
|
||||
...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}),
|
||||
...(filter.yardId ? { yardId: filter.yardId } : {}),
|
||||
...(filter.zoneId ? { zoneId: filter.zoneId } : {}),
|
||||
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
|
||||
...(filter.cargoId ? { cargoId: filter.cargoId } : {}),
|
||||
...(filter.containerId ? { containerId: filter.containerId } : {}),
|
||||
...(filter.goodsId ? { goodsId: filter.goodsId } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
};
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const where: FindManyOptions<WarehouseInventory>['where'] = search
|
||||
? { ...base, notes: ILike(`%${search}%`) }
|
||||
: base;
|
||||
|
||||
return this.inventoryRepository.findAll({
|
||||
where,
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
|
||||
return this.findAll({ ...filter, status: 'READY_FOR_LOADING' });
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.inventoryRepository.findById(id, {
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
});
|
||||
|
||||
if (!item) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
// ── Receive (with location + capacity validation) ──────────────────────
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
|
||||
await this.assertBookingExists(manager, dto.bookingId);
|
||||
|
||||
this.assertCapacity('Warehouse', warehouse, weight, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, containerCount);
|
||||
this.assertCapacity('Zone', zone, weight, containerCount);
|
||||
|
||||
const now = new Date();
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId: dto.bookingId,
|
||||
cargoId: dto.cargoId ?? null,
|
||||
containerId: dto.containerId ?? null,
|
||||
goodsId: dto.goodsId ?? null,
|
||||
quantity: Number(dto.quantity) || 0,
|
||||
weight,
|
||||
volume: dto.volume ?? null,
|
||||
status: 'ARRIVED_AT_WAREHOUSE',
|
||||
arrivedAt: now,
|
||||
notes: dto.notes?.trim() ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applyCapacityDelta(manager, dto, weight, containerCount);
|
||||
|
||||
return saved.id;
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Status transitions ─────────────────────────────────────────────────
|
||||
|
||||
async inspect(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
|
||||
if (item.status !== 'ARRIVED_AT_WAREHOUSE') {
|
||||
throw new BadRequestException(
|
||||
`Only items in ARRIVED_AT_WAREHOUSE can be inspected (current: ${item.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.inventoryRepository.update(id, {
|
||||
status: 'UNDER_INSPECTION',
|
||||
inspectedAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async readyForLoading(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
|
||||
if (item.status !== 'UNDER_INSPECTION') {
|
||||
throw new BadRequestException(
|
||||
`Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.inventoryRepository.update(id, {
|
||||
status: 'READY_FOR_LOADING',
|
||||
readyForLoadingAt: new Date(),
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Inquiry ────────────────────────────────────────────────────────────
|
||||
|
||||
async inquiry(filter: InquiryWarehouseInventoryDto): Promise<InventoryInquiryResult[]> {
|
||||
const qb = this.dataSource
|
||||
.getRepository(WarehouseInventory)
|
||||
.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||
.leftJoinAndSelect('inv.yard', 'yard')
|
||||
.leftJoinAndSelect('inv.zone', 'zone')
|
||||
.leftJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
||||
.leftJoin('freight.companies', 'company', 'company.id = booking.company_id')
|
||||
.leftJoin('freight.containers', 'container', 'container.id = inv.container_id')
|
||||
.leftJoin('freight.cargoes', 'cargo', 'cargo.id = inv.cargo_id')
|
||||
.leftJoin('freight.cargo_types', 'cargo_type', 'cargo_type.id = cargo.cargo_type_id')
|
||||
.addSelect('booking.reference', 'b_reference')
|
||||
.addSelect('company.name', 'c_name')
|
||||
.addSelect('container.container_number', 'ct_number')
|
||||
.addSelect('cargo.description', 'cg_description')
|
||||
.addSelect('cargo_type.cargo_type_name', 'cgt_name')
|
||||
.orderBy('inv.created_at', 'DESC');
|
||||
|
||||
if (filter.bookingNumber?.trim()) {
|
||||
qb.andWhere('booking.reference ILIKE :bn', { bn: `%${filter.bookingNumber.trim()}%` });
|
||||
}
|
||||
if (filter.containerNumber?.trim()) {
|
||||
qb.andWhere('container.container_number ILIKE :cn', { cn: `%${filter.containerNumber.trim()}%` });
|
||||
}
|
||||
if (filter.cargoType?.trim()) {
|
||||
qb.andWhere('cargo_type.cargo_type_name ILIKE :ctype', { ctype: `%${filter.cargoType.trim()}%` });
|
||||
}
|
||||
if (filter.goodsName?.trim()) {
|
||||
// No dedicated goods entity in Batch 1 — best-effort match against notes / cargo description.
|
||||
qb.andWhere('(inv.notes ILIKE :gn OR cargo.description ILIKE :gn)', { gn: `%${filter.goodsName.trim()}%` });
|
||||
}
|
||||
if (filter.warehouseId) {
|
||||
qb.andWhere('inv.warehouse_id = :wid', { wid: filter.warehouseId });
|
||||
}
|
||||
if (filter.yardId) {
|
||||
qb.andWhere('inv.yard_id = :yid', { yid: filter.yardId });
|
||||
}
|
||||
if (filter.zoneId) {
|
||||
qb.andWhere('inv.zone_id = :zid', { zid: filter.zoneId });
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere('inv.status = :status', { status: filter.status });
|
||||
}
|
||||
|
||||
const { entities, raw } = await qb.getRawAndEntities();
|
||||
|
||||
return entities.map((inv, index) => {
|
||||
const row = raw[index] ?? {};
|
||||
return {
|
||||
id: inv.id,
|
||||
bookingId: inv.bookingId,
|
||||
bookingNumber: row.b_reference ?? null,
|
||||
customerName: row.c_name ?? null,
|
||||
containerNumber: row.ct_number ?? null,
|
||||
cargoType: row.cgt_name ?? null,
|
||||
cargoDescription: row.cg_description ?? null,
|
||||
goodsId: inv.goodsId ?? null,
|
||||
warehouse: inv.warehouse
|
||||
? { id: inv.warehouse.id, name: inv.warehouse.name, code: inv.warehouse.code }
|
||||
: null,
|
||||
yard: inv.yard ? { id: inv.yard.id, name: inv.yard.name, code: inv.yard.code } : null,
|
||||
zone: inv.zone ? { id: inv.zone.id, name: inv.zone.name, code: inv.zone.code } : null,
|
||||
status: inv.status,
|
||||
quantity: Number(inv.quantity),
|
||||
weight: Number(inv.weight),
|
||||
arrivedAt: inv.arrivedAt ?? null,
|
||||
readyForLoadingAt: inv.readyForLoadingAt ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async validateLocation(
|
||||
manager: EntityManager,
|
||||
dto: ReceiveWarehouseInventoryDto,
|
||||
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
|
||||
const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } });
|
||||
if (!warehouse) {
|
||||
throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`);
|
||||
}
|
||||
if (warehouse.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('Warehouse is not ACTIVE');
|
||||
}
|
||||
|
||||
const yard = await manager.getRepository(WarehouseYard).findOne({ where: { id: dto.yardId } });
|
||||
if (!yard) {
|
||||
throw new NotFoundException(`Yard ${dto.yardId} not found`);
|
||||
}
|
||||
if (yard.warehouseId !== warehouse.id) {
|
||||
throw new BadRequestException('Yard does not belong to the selected warehouse');
|
||||
}
|
||||
if (yard.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('Yard is not ACTIVE');
|
||||
}
|
||||
|
||||
const zone = await manager.getRepository(WarehouseZone).findOne({ where: { id: dto.zoneId } });
|
||||
if (!zone) {
|
||||
throw new NotFoundException(`Zone ${dto.zoneId} not found`);
|
||||
}
|
||||
if (zone.yardId !== yard.id) {
|
||||
throw new BadRequestException('Zone does not belong to the selected yard');
|
||||
}
|
||||
if (zone.status !== 'ACTIVE') {
|
||||
throw new BadRequestException('Zone is not ACTIVE');
|
||||
}
|
||||
|
||||
return { warehouse, yard, zone };
|
||||
}
|
||||
|
||||
private async assertBookingExists(manager: EntityManager, bookingId: string): Promise<void> {
|
||||
const rows = await manager.query(
|
||||
'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||
[bookingId],
|
||||
);
|
||||
if (!rows || rows.length === 0) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private assertCapacity(
|
||||
label: string,
|
||||
node: { capacityWeight?: number | null; capacityContainers?: number | null; currentWeight: number; currentContainers: number },
|
||||
weightAdd: number,
|
||||
containerAdd: number,
|
||||
): void {
|
||||
if (node.capacityWeight != null) {
|
||||
const projected = Number(node.currentWeight) + weightAdd;
|
||||
if (projected > Number(node.capacityWeight)) {
|
||||
throw new BadRequestException(
|
||||
`${label} weight capacity exceeded (${projected} / ${node.capacityWeight})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (node.capacityContainers != null && containerAdd > 0) {
|
||||
const projected = Number(node.currentContainers) + containerAdd;
|
||||
if (projected > Number(node.capacityContainers)) {
|
||||
throw new BadRequestException(
|
||||
`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyCapacityDelta(
|
||||
manager: EntityManager,
|
||||
dto: ReceiveWarehouseInventoryDto,
|
||||
weightAdd: number,
|
||||
containerAdd: number,
|
||||
): Promise<void> {
|
||||
await manager.increment(Warehouse, { id: dto.warehouseId }, 'currentWeight', weightAdd);
|
||||
await manager.increment(WarehouseYard, { id: dto.yardId }, 'currentWeight', weightAdd);
|
||||
await manager.increment(WarehouseZone, { id: dto.zoneId }, 'currentWeight', weightAdd);
|
||||
|
||||
if (containerAdd > 0) {
|
||||
await manager.increment(Warehouse, { id: dto.warehouseId }, 'currentContainers', containerAdd);
|
||||
await manager.increment(WarehouseYard, { id: dto.yardId }, 'currentContainers', containerAdd);
|
||||
await manager.increment(WarehouseZone, { id: dto.zoneId }, 'currentContainers', containerAdd);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-yards')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-yards')
|
||||
export class WarehouseYardsController {
|
||||
constructor(
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
private readonly zonesService: WarehouseZonesService,
|
||||
) {}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse yard by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.yardsService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update warehouse yard' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseYardDto) {
|
||||
return this.yardsService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':yardId/zones')
|
||||
@ApiOperation({ summary: 'List zones within a yard' })
|
||||
listZones(@Param('yardId', ParseUUIDPipe) yardId: string) {
|
||||
return this.zonesService.findByYard(yardId);
|
||||
}
|
||||
|
||||
@Post(':yardId/zones')
|
||||
@ApiOperation({ summary: 'Create a zone within a yard' })
|
||||
createZone(
|
||||
@Param('yardId', ParseUUIDPipe) yardId: string,
|
||||
@Body() dto: CreateWarehouseZoneDto,
|
||||
) {
|
||||
return this.zonesService.create(yardId, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseYardsRepository extends BaseRepository<WarehouseYard> {
|
||||
constructor(@InjectRepository(WarehouseYard) repository: Repository<WarehouseYard>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { UpdateWarehouseYardDto } from './dto/update-warehouse-yard.dto';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseYardsService {
|
||||
constructor(
|
||||
private readonly yardsRepository: WarehouseYardsRepository,
|
||||
private readonly warehousesService: WarehousesService,
|
||||
) {}
|
||||
|
||||
findByWarehouse(warehouseId: string): Promise<WarehouseYard[]> {
|
||||
return this.yardsRepository.findAll({
|
||||
where: { warehouseId },
|
||||
relations: { zones: true },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseYard> {
|
||||
const yard = await this.yardsRepository.findById(id, {
|
||||
relations: { warehouse: true, zones: true },
|
||||
});
|
||||
|
||||
if (!yard) {
|
||||
throw new NotFoundException(`Warehouse yard ${id} not found`);
|
||||
}
|
||||
|
||||
return yard;
|
||||
}
|
||||
|
||||
async create(warehouseId: string, dto: CreateWarehouseYardDto): Promise<WarehouseYard> {
|
||||
// Ensure the parent warehouse exists.
|
||||
await this.warehousesService.findById(warehouseId);
|
||||
await this.assertCodeUnique(warehouseId, dto.code.trim());
|
||||
|
||||
return this.yardsRepository.create({
|
||||
warehouseId,
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseYardDto): Promise<WarehouseYard> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.code && dto.code.trim() !== existing.code) {
|
||||
await this.assertCodeUnique(existing.warehouseId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.yardsRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Warehouse yard ${id} not found`);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async assertCodeUnique(warehouseId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.yardsRepository.findAll({ where: { warehouseId, code } });
|
||||
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(`Yard code ${code} already exists in this warehouse`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
|
||||
@ApiTags('warehouse-zones')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouse-zones')
|
||||
export class WarehouseZonesController {
|
||||
constructor(private readonly zonesService: WarehouseZonesService) {}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse zone by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.zonesService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update warehouse zone' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseZoneDto) {
|
||||
return this.zonesService.update(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZonesRepository extends BaseRepository<WarehouseZone> {
|
||||
constructor(@InjectRepository(WarehouseZone) repository: Repository<WarehouseZone>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { CreateWarehouseZoneDto } from './dto/create-warehouse-zone.dto';
|
||||
import { UpdateWarehouseZoneDto } from './dto/update-warehouse-zone.dto';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseZonesService {
|
||||
constructor(
|
||||
private readonly zonesRepository: WarehouseZonesRepository,
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
) {}
|
||||
|
||||
findByYard(yardId: string): Promise<WarehouseZone[]> {
|
||||
return this.zonesRepository.findAll({
|
||||
where: { yardId },
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<WarehouseZone> {
|
||||
const zone = await this.zonesRepository.findById(id, {
|
||||
relations: { yard: { warehouse: true } },
|
||||
});
|
||||
|
||||
if (!zone) {
|
||||
throw new NotFoundException(`Warehouse zone ${id} not found`);
|
||||
}
|
||||
|
||||
return zone;
|
||||
}
|
||||
|
||||
async create(yardId: string, dto: CreateWarehouseZoneDto): Promise<WarehouseZone> {
|
||||
// Ensure the parent yard exists.
|
||||
await this.yardsService.findById(yardId);
|
||||
await this.assertCodeUnique(yardId, dto.code.trim());
|
||||
|
||||
return this.zonesRepository.create({
|
||||
yardId,
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseZoneDto): Promise<WarehouseZone> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.code && dto.code.trim() !== existing.code) {
|
||||
await this.assertCodeUnique(existing.yardId, dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.zonesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Warehouse zone ${id} not found`);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async assertCodeUnique(yardId: string, code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.zonesRepository.findAll({ where: { yardId, code } });
|
||||
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(`Zone code ${code} already exists in this yard`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { CreateWarehouseYardDto } from './dto/create-warehouse-yard.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
import { UpdateWarehouseDto } from './dto/update-warehouse.dto';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@ApiTags('warehouses')
|
||||
@ApiBearerAuth()
|
||||
@Controller('warehouses')
|
||||
export class WarehousesController {
|
||||
constructor(
|
||||
private readonly warehousesService: WarehousesService,
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List warehouses' })
|
||||
findAll(@Query() filter: FilterWarehouseDto) {
|
||||
return this.warehousesService.findAll(filter);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create warehouse' })
|
||||
create(@Body() dto: CreateWarehouseDto) {
|
||||
return this.warehousesService.create(dto);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get warehouse by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.warehousesService.findById(id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
@ApiOperation({ summary: 'Update warehouse' })
|
||||
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWarehouseDto) {
|
||||
return this.warehousesService.update(id, dto);
|
||||
}
|
||||
|
||||
@Get(':warehouseId/yards')
|
||||
@ApiOperation({ summary: 'List yards within a warehouse' })
|
||||
listYards(@Param('warehouseId', ParseUUIDPipe) warehouseId: string) {
|
||||
return this.yardsService.findByWarehouse(warehouseId);
|
||||
}
|
||||
|
||||
@Post(':warehouseId/yards')
|
||||
@ApiOperation({ summary: 'Create a yard within a warehouse' })
|
||||
createYard(
|
||||
@Param('warehouseId', ParseUUIDPipe) warehouseId: string,
|
||||
@Body() dto: CreateWarehouseYardDto,
|
||||
) {
|
||||
return this.yardsService.create(warehouseId, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehouseInventoryController } from './warehouse-inventory.controller';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehouseZonesController } from './warehouse-zones.controller';
|
||||
import { WarehouseZonesRepository } from './warehouse-zones.repository';
|
||||
import { WarehouseZonesService } from './warehouse-zones.service';
|
||||
import { WarehousesController } from './warehouses.controller';
|
||||
import { WarehousesRepository } from './warehouses.repository';
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory])],
|
||||
controllers: [
|
||||
WarehousesController,
|
||||
WarehouseYardsController,
|
||||
WarehouseZonesController,
|
||||
WarehouseInventoryController,
|
||||
],
|
||||
providers: [
|
||||
WarehousesRepository,
|
||||
WarehouseYardsRepository,
|
||||
WarehouseZonesRepository,
|
||||
WarehouseInventoryRepository,
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseInventoryService,
|
||||
],
|
||||
exports: [WarehousesService, WarehouseYardsService, WarehouseZonesService, WarehouseInventoryService],
|
||||
})
|
||||
export class WarehousesModule {}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehousesRepository extends BaseRepository<Warehouse> {
|
||||
constructor(@InjectRepository(Warehouse) repository: Repository<Warehouse>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindManyOptions, ILike } from 'typeorm';
|
||||
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
import { UpdateWarehouseDto } from './dto/update-warehouse.dto';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehousesRepository } from './warehouses.repository';
|
||||
|
||||
@Injectable()
|
||||
export class WarehousesService {
|
||||
constructor(private readonly warehousesRepository: WarehousesRepository) {}
|
||||
|
||||
async findAll(filter: FilterWarehouseDto): Promise<Warehouse[]> {
|
||||
const where: FindManyOptions<Warehouse>['where'] = {
|
||||
...(filter.type ? { type: filter.type } : {}),
|
||||
...(filter.stationId ? { stationId: filter.stationId } : {}),
|
||||
...(filter.status ? { status: filter.status } : {}),
|
||||
};
|
||||
|
||||
const search = filter.search?.trim();
|
||||
const whereClauses = search
|
||||
? [
|
||||
{ ...where, name: ILike(`%${search}%`) },
|
||||
{ ...where, code: ILike(`%${search}%`) },
|
||||
{ ...where, locationName: ILike(`%${search}%`) },
|
||||
]
|
||||
: where;
|
||||
|
||||
return this.warehousesRepository.findAll({
|
||||
where: whereClauses,
|
||||
order: { code: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Warehouse> {
|
||||
const warehouse = await this.warehousesRepository.findById(id, {
|
||||
relations: { yards: { zones: true } },
|
||||
});
|
||||
|
||||
if (!warehouse) {
|
||||
throw new NotFoundException(`Warehouse ${id} not found`);
|
||||
}
|
||||
|
||||
return warehouse;
|
||||
}
|
||||
|
||||
async create(dto: CreateWarehouseDto): Promise<Warehouse> {
|
||||
await this.assertCodeUnique(dto.code.trim());
|
||||
|
||||
return this.warehousesRepository.create({
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
stationId: dto.stationId ?? null,
|
||||
locationName: dto.locationName?.trim() ?? null,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseDto): Promise<Warehouse> {
|
||||
const existing = await this.findById(id);
|
||||
|
||||
if (dto.code && dto.code.trim() !== existing.code) {
|
||||
await this.assertCodeUnique(dto.code.trim(), id);
|
||||
}
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.warehousesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
stationId: dto.stationId ?? existing.stationId,
|
||||
locationName: dto.locationName?.trim() ?? existing.locationName,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Warehouse ${id} not found`);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private async assertCodeUnique(code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.warehousesRepository.findAll({ where: { code } });
|
||||
|
||||
if (existing && existing.id !== ignoreId) {
|
||||
throw new ConflictException(`Warehouse code ${code} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user