mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 22:18:12 +00:00
Recieved invontory detals
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class WarehouseBatch21790000000001 implements MigrationInterface {
|
||||
name = 'WarehouseBatch21790000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── Capacity columns (weight + volume) on warehouse / yard / zone ──────
|
||||
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.${table}
|
||||
ADD COLUMN IF NOT EXISTS max_weight NUMERIC(14,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS max_volume NUMERIC(14,3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS current_volume NUMERIC(14,3) NOT NULL DEFAULT 0;
|
||||
`);
|
||||
// Backfill max_weight from the Batch 1 capacity_weight column.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.${table} SET max_weight = capacity_weight WHERE max_weight IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
// ── Inventory lifecycle: migrate Batch 1 statuses to Batch 2 set ───────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ALTER COLUMN status SET DEFAULT 'RECEIVED';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.warehouse_inventory SET status = 'RECEIVED' WHERE status = 'ARRIVED_AT_WAREHOUSE';
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.warehouse_inventory SET status = 'STORED' WHERE status = 'UNDER_INSPECTION';
|
||||
`);
|
||||
|
||||
// ── New lifecycle timestamps ──────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
ADD COLUMN IF NOT EXISTS stored_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS reserved_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS loaded_at TIMESTAMPTZ NULL,
|
||||
ADD COLUMN IF NOT EXISTS dispatched_at TIMESTAMPTZ NULL;
|
||||
`);
|
||||
|
||||
// booking_id becomes nullable (inventory can exist before booking linkage).
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory ALTER COLUMN booking_id DROP NOT NULL;
|
||||
`);
|
||||
|
||||
// ── Movement history ──────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_inventory_movement (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_id UUID NOT NULL REFERENCES freight.warehouse_inventory(id) ON DELETE CASCADE,
|
||||
from_warehouse_id UUID NOT NULL,
|
||||
from_yard_id UUID NOT NULL,
|
||||
from_zone_id UUID NOT NULL,
|
||||
to_warehouse_id UUID NOT NULL,
|
||||
to_yard_id UUID NOT NULL,
|
||||
to_zone_id UUID NOT NULL,
|
||||
remarks TEXT NULL,
|
||||
moved_by VARCHAR(120) NULL,
|
||||
moved_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_inventory_movement_inventory_id
|
||||
ON freight.warehouse_inventory_movement(inventory_id);
|
||||
`);
|
||||
|
||||
// ── Activity log ──────────────────────────────────────────────────────
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.warehouse_activity_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
inventory_id UUID NULL,
|
||||
warehouse_id UUID NULL,
|
||||
activity_type VARCHAR(40) NOT NULL,
|
||||
description TEXT NULL,
|
||||
performed_by VARCHAR(120) NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
deleted_at TIMESTAMPTZ NULL
|
||||
);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_inventory_id
|
||||
ON freight.warehouse_activity_log(inventory_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_warehouse_id
|
||||
ON freight.warehouse_activity_log(warehouse_id);
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_warehouse_activity_log_activity_type
|
||||
ON freight.warehouse_activity_log(activity_type);
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_activity_log;`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.warehouse_inventory_movement;`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory
|
||||
DROP COLUMN IF EXISTS stored_at,
|
||||
DROP COLUMN IF EXISTS reserved_at,
|
||||
DROP COLUMN IF EXISTS loaded_at,
|
||||
DROP COLUMN IF EXISTS dispatched_at;
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.warehouse_inventory ALTER COLUMN status SET DEFAULT 'RECEIVED';
|
||||
`);
|
||||
|
||||
for (const table of ['warehouses', 'warehouse_yards', 'warehouse_zones']) {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.${table}
|
||||
DROP COLUMN IF EXISTS max_weight,
|
||||
DROP COLUMN IF EXISTS max_volume,
|
||||
DROP COLUMN IF EXISTS current_volume;
|
||||
`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,4 +34,16 @@ export class CreateWarehouseYardDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxWeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
}
|
||||
|
||||
@@ -34,4 +34,16 @@ export class CreateWarehouseZoneDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxWeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
}
|
||||
|
||||
@@ -40,4 +40,16 @@ export class CreateWarehouseDto {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
capacityContainers?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max weight capacity (kg). Defaults to capacityWeight.' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxWeight?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Max volume capacity (m³).' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
maxVolume?: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class MoveInventoryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remarks?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
movedBy?: string;
|
||||
}
|
||||
@@ -14,9 +14,10 @@ export class ReceiveWarehouseInventoryDto {
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@@ -53,4 +54,9 @@ export class ReceiveWarehouseInventoryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class ReserveInventoryDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
inventoryId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAREHOUSE_ACTIVITY_TYPES = [
|
||||
'INVENTORY_RECEIVED',
|
||||
'INVENTORY_STORED',
|
||||
'INVENTORY_MOVED',
|
||||
'INVENTORY_RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'INVENTORY_LOADED',
|
||||
'INVENTORY_DISPATCHED',
|
||||
] as const;
|
||||
export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_activity_log' })
|
||||
@Index(['inventoryId'])
|
||||
@Index(['warehouseId'])
|
||||
@Index(['activityType'])
|
||||
export class WarehouseActivityLog extends BaseEntity {
|
||||
@Column({ name: 'inventory_id', type: 'uuid', nullable: true })
|
||||
inventoryId?: string | null;
|
||||
|
||||
@Column({ name: 'warehouse_id', type: 'uuid', nullable: true })
|
||||
warehouseId?: string | null;
|
||||
|
||||
@Column({ name: 'activity_type', type: 'varchar', length: 40 })
|
||||
activityType!: WarehouseActivityType;
|
||||
|
||||
@Column({ name: 'description', type: 'text', nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { WarehouseInventory } from './warehouse-inventory.entity';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory_movement' })
|
||||
@Index(['inventoryId'])
|
||||
export class WarehouseInventoryMovement extends BaseEntity {
|
||||
@Column({ name: 'inventory_id', type: 'uuid' })
|
||||
inventoryId!: string;
|
||||
|
||||
@ManyToOne(() => WarehouseInventory, { onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'inventory_id' })
|
||||
inventory?: WarehouseInventory;
|
||||
|
||||
@Column({ name: 'from_warehouse_id', type: 'uuid' })
|
||||
fromWarehouseId!: string;
|
||||
|
||||
@Column({ name: 'from_yard_id', type: 'uuid' })
|
||||
fromYardId!: string;
|
||||
|
||||
@Column({ name: 'from_zone_id', type: 'uuid' })
|
||||
fromZoneId!: string;
|
||||
|
||||
@Column({ name: 'to_warehouse_id', type: 'uuid' })
|
||||
toWarehouseId!: string;
|
||||
|
||||
@Column({ name: 'to_yard_id', type: 'uuid' })
|
||||
toYardId!: string;
|
||||
|
||||
@Column({ name: 'to_zone_id', type: 'uuid' })
|
||||
toZoneId!: string;
|
||||
|
||||
@Column({ name: 'remarks', type: 'text', nullable: true })
|
||||
remarks?: string | null;
|
||||
|
||||
@Column({ name: 'moved_by', type: 'varchar', length: 120, nullable: true })
|
||||
movedBy?: string | null;
|
||||
|
||||
@Column({ name: 'moved_at', type: 'timestamptz' })
|
||||
movedAt!: Date;
|
||||
}
|
||||
@@ -1,17 +1,35 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { Cargo } from '../../cargoes/entities/cargoes.entity';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
|
||||
// Batch 2 lifecycle. Supersedes the Batch 1 set
|
||||
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
|
||||
export const WAREHOUSE_INVENTORY_STATUSES = [
|
||||
'ARRIVED_AT_WAREHOUSE',
|
||||
'UNDER_INSPECTION',
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
] as const;
|
||||
export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number];
|
||||
|
||||
/** Allowed forward transitions for the inventory lifecycle. */
|
||||
export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, WarehouseInventoryStatus[]> = {
|
||||
RECEIVED: ['STORED'],
|
||||
STORED: ['RESERVED'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
DISPATCHED: [],
|
||||
};
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
|
||||
@Index(['warehouseId'])
|
||||
@Index(['yardId'])
|
||||
@@ -43,15 +61,27 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@JoinColumn({ name: 'zone_id' })
|
||||
zone?: WarehouseZone;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking | null;
|
||||
|
||||
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
|
||||
cargoId?: string | null;
|
||||
|
||||
@ManyToOne(() => Cargo, { nullable: true })
|
||||
@JoinColumn({ name: 'cargo_id' })
|
||||
cargo?: Cargo | null;
|
||||
|
||||
@Column({ name: 'container_id', type: 'uuid', nullable: true })
|
||||
containerId?: string | null;
|
||||
|
||||
@ManyToOne(() => Container, { nullable: true })
|
||||
@JoinColumn({ name: 'container_id' })
|
||||
container?: Container | null;
|
||||
|
||||
@Column({ name: 'goods_id', type: 'uuid', nullable: true })
|
||||
goodsId?: string | null;
|
||||
|
||||
@@ -64,18 +94,30 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@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' })
|
||||
@Column({ name: 'status', type: 'varchar', length: 32, default: 'RECEIVED' })
|
||||
status!: WarehouseInventoryStatus;
|
||||
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
|
||||
storedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'reserved_at', type: 'timestamptz', nullable: true })
|
||||
reservedAt?: 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: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'dispatched_at', type: 'timestamptz', nullable: true })
|
||||
dispatchedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,15 @@ export class WarehouseYard extends BaseEntity {
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxWeight?: number | null;
|
||||
|
||||
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxVolume?: number | null;
|
||||
|
||||
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentVolume!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseYardStatus;
|
||||
|
||||
|
||||
@@ -48,6 +48,15 @@ export class WarehouseZone extends BaseEntity {
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxWeight?: number | null;
|
||||
|
||||
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxVolume?: number | null;
|
||||
|
||||
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentVolume!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseZoneStatus;
|
||||
|
||||
|
||||
@@ -42,6 +42,16 @@ export class Warehouse extends BaseEntity {
|
||||
@Column({ name: 'current_containers', type: 'int', default: 0 })
|
||||
currentContainers!: number;
|
||||
|
||||
// Batch 2 capacity (weight + volume). maxWeight backfilled from capacityWeight.
|
||||
@Column({ name: 'max_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxWeight?: number | null;
|
||||
|
||||
@Column({ name: 'max_volume', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
maxVolume?: number | null;
|
||||
|
||||
@Column({ name: 'current_volume', type: 'numeric', precision: 14, scale: 3, default: 0 })
|
||||
currentVolume!: number;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 16, default: 'ACTIVE' })
|
||||
status!: WarehouseStatus;
|
||||
|
||||
|
||||
@@ -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 { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseActivityLogRepository extends BaseRepository<WarehouseActivityLog> {
|
||||
constructor(@InjectRepository(WarehouseActivityLog) repository: Repository<WarehouseActivityLog>) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EntityManager } from 'typeorm';
|
||||
|
||||
import {
|
||||
WarehouseActivityLog,
|
||||
WarehouseActivityType,
|
||||
} from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||
|
||||
interface LogInput {
|
||||
activityType: WarehouseActivityType;
|
||||
description?: string;
|
||||
inventoryId?: string | null;
|
||||
warehouseId?: string | null;
|
||||
performedBy?: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseActivityLogService {
|
||||
constructor(private readonly logRepository: WarehouseActivityLogRepository) {}
|
||||
|
||||
/** Persist an activity record. Pass a transaction manager to enrol in the caller's transaction. */
|
||||
async record(input: LogInput, manager?: EntityManager): Promise<void> {
|
||||
const data = {
|
||||
activityType: input.activityType,
|
||||
description: input.description ?? null,
|
||||
inventoryId: input.inventoryId ?? null,
|
||||
warehouseId: input.warehouseId ?? null,
|
||||
performedBy: input.performedBy ?? 'system',
|
||||
};
|
||||
|
||||
if (manager) {
|
||||
await manager.getRepository(WarehouseActivityLog).save(
|
||||
manager.getRepository(WarehouseActivityLog).create(data),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.logRepository.create(data);
|
||||
}
|
||||
|
||||
findByInventory(inventoryId: string): Promise<WarehouseActivityLog[]> {
|
||||
return this.logRepository.findAll({
|
||||
where: { inventoryId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
|
||||
export interface WarehouseDashboard {
|
||||
totalWarehouses: number;
|
||||
totalInventory: number;
|
||||
receivedToday: number;
|
||||
stored: number;
|
||||
reserved: number;
|
||||
readyForLoading: number;
|
||||
loaded: number;
|
||||
dispatched: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseDashboardService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async getDashboard(): Promise<WarehouseDashboard> {
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] =
|
||||
await Promise.all([
|
||||
warehouseRepo.count(),
|
||||
inventoryRepo.count(),
|
||||
inventoryRepo.count({ where: { status: 'STORED' } }),
|
||||
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
||||
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
||||
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
||||
inventoryRepo
|
||||
.createQueryBuilder('inv')
|
||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalWarehouses,
|
||||
totalInventory,
|
||||
receivedToday,
|
||||
stored,
|
||||
reserved,
|
||||
readyForLoading,
|
||||
loaded,
|
||||
dispatched,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryMovementRepository extends BaseRepository<WarehouseInventoryMovement> {
|
||||
constructor(
|
||||
@InjectRepository(WarehouseInventoryMovement) repository: Repository<WarehouseInventoryMovement>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, 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 { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
|
||||
@ApiTags('warehouse-inventory')
|
||||
@@ -36,15 +38,51 @@ export class WarehouseInventoryController {
|
||||
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);
|
||||
@Post('reserve')
|
||||
@ApiOperation({ summary: 'Reserve stored inventory for a PAID booking' })
|
||||
reserve(@Body() dto: ReserveInventoryDto) {
|
||||
return this.inventoryService.reserve(dto);
|
||||
}
|
||||
|
||||
@Patch(':id/ready-for-loading')
|
||||
@ApiOperation({ summary: 'Move inventory to READY_FOR_LOADING' })
|
||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.readyForLoading(id);
|
||||
@Get(':id/movements')
|
||||
@ApiOperation({ summary: 'Inventory movement history' })
|
||||
movements(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findMovements(id);
|
||||
}
|
||||
|
||||
@Get(':id/activity')
|
||||
@ApiOperation({ summary: 'Inventory activity log' })
|
||||
activity(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.inventoryService.findActivity(id);
|
||||
}
|
||||
|
||||
@Post(':id/move')
|
||||
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
|
||||
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
|
||||
return this.inventoryService.move(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/store')
|
||||
@ApiOperation({ summary: 'Mark received inventory as STORED' })
|
||||
store(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.store(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-loading')
|
||||
@ApiOperation({ summary: 'Mark reserved inventory READY_FOR_LOADING' })
|
||||
readyForLoading(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForLoading(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/load')
|
||||
@ApiOperation({ summary: 'Mark inventory LOADED' })
|
||||
load(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.load(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/dispatch')
|
||||
@ApiOperation({ summary: 'Mark inventory DISPATCHED' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.dispatch(id, performedBy);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,16 +3,25 @@ import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
|
||||
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
import {
|
||||
WAREHOUSE_INVENTORY_TRANSITIONS,
|
||||
WarehouseInventory,
|
||||
WarehouseInventoryStatus,
|
||||
} 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 { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
bookingId: string | null;
|
||||
bookingNumber: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
@@ -29,11 +38,22 @@ export interface InventoryInquiryResult {
|
||||
readyForLoadingAt: Date | null;
|
||||
}
|
||||
|
||||
interface LocationNode {
|
||||
maxWeight?: number | null;
|
||||
capacityWeight?: number | null;
|
||||
maxVolume?: number | null;
|
||||
capacityContainers?: number | null;
|
||||
currentWeight: number;
|
||||
currentVolume: number;
|
||||
currentContainers: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||
private readonly activityLog: WarehouseActivityLogService,
|
||||
) {}
|
||||
|
||||
// ── Listing ────────────────────────────────────────────────────────────
|
||||
@@ -78,20 +98,23 @@ export class WarehouseInventoryService {
|
||||
return item;
|
||||
}
|
||||
|
||||
// ── Receive (with location + capacity validation) ──────────────────────
|
||||
// ── Receive ──────────────────────────────────────────────────────────────
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 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);
|
||||
if (dto.bookingId) {
|
||||
await this.assertBookingExists(manager, dto.bookingId);
|
||||
}
|
||||
|
||||
this.assertCapacity('Warehouse', warehouse, weight, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, containerCount);
|
||||
this.assertCapacity('Zone', zone, weight, containerCount);
|
||||
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||||
this.assertCapacity('Zone', zone, weight, volume, containerCount);
|
||||
|
||||
const now = new Date();
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
@@ -99,20 +122,31 @@ export class WarehouseInventoryService {
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId: dto.bookingId,
|
||||
bookingId: dto.bookingId ?? null,
|
||||
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',
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
notes: dto.notes?.trim() ?? null,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.applyCapacityDelta(manager, dto, weight, containerCount);
|
||||
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `Received ${weight}kg at warehouse location`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
return saved.id;
|
||||
});
|
||||
@@ -120,43 +154,181 @@ export class WarehouseInventoryService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Status transitions ─────────────────────────────────────────────────
|
||||
// ── Lifecycle transitions ────────────────────────────────────────────────
|
||||
|
||||
async inspect(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
store(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
return this.transition(id, 'STORED', {
|
||||
timestampField: 'storedAt',
|
||||
activityType: 'INVENTORY_STORED',
|
||||
description: 'Inventory stored',
|
||||
performedBy,
|
||||
});
|
||||
}
|
||||
|
||||
if (item.status !== 'ARRIVED_AT_WAREHOUSE') {
|
||||
throw new BadRequestException(
|
||||
`Only items in ARRIVED_AT_WAREHOUSE can be inspected (current: ${item.status})`,
|
||||
);
|
||||
async reserve(dto: ReserveInventoryDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(dto.inventoryId);
|
||||
|
||||
if (item.status !== 'STORED') {
|
||||
throw new BadRequestException(`Inventory must be STORED to reserve (current: ${item.status})`);
|
||||
}
|
||||
|
||||
await this.inventoryRepository.update(id, {
|
||||
status: 'UNDER_INSPECTION',
|
||||
inspectedAt: new Date(),
|
||||
const status = await this.getBookingStatus(dto.bookingId);
|
||||
if (!status) {
|
||||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||||
}
|
||||
if (status !== 'PAID') {
|
||||
throw new BadRequestException(`Booking must be PAID to reserve inventory (current: ${status})`);
|
||||
}
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(dto.inventoryId, {
|
||||
status: 'RESERVED',
|
||||
bookingId: dto.bookingId,
|
||||
reservedAt: new Date(),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RESERVED',
|
||||
inventoryId: dto.inventoryId,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Reserved for booking ${dto.bookingId}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(dto.inventoryId);
|
||||
}
|
||||
|
||||
async readyForLoading(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||||
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||||
}
|
||||
return this.transition(id, 'READY_FOR_LOADING', {
|
||||
timestampField: 'readyForLoadingAt',
|
||||
activityType: 'READY_FOR_LOADING',
|
||||
description: 'Inventory ready for loading',
|
||||
performedBy,
|
||||
preloaded: item,
|
||||
});
|
||||
}
|
||||
|
||||
load(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
return this.transition(id, 'LOADED', {
|
||||
timestampField: 'loadedAt',
|
||||
activityType: 'INVENTORY_LOADED',
|
||||
description: 'Inventory loaded',
|
||||
performedBy,
|
||||
});
|
||||
}
|
||||
|
||||
async dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'DISPATCHED');
|
||||
|
||||
const weight = Number(item.weight) || 0;
|
||||
const volume = Number(item.volume) || 0;
|
||||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'DISPATCHED',
|
||||
dispatchedAt: new Date(),
|
||||
});
|
||||
// Item physically leaves the warehouse — free up capacity.
|
||||
await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1);
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_DISPATCHED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: 'Inventory dispatched',
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async readyForLoading(id: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
// ── Movement ──────────────────────────────────────────────────────────────
|
||||
|
||||
if (item.status !== 'UNDER_INSPECTION') {
|
||||
throw new BadRequestException(
|
||||
`Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`,
|
||||
);
|
||||
async move(id: string, dto: MoveInventoryDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
if (item.status === 'DISPATCHED') {
|
||||
throw new BadRequestException('Dispatched inventory cannot be moved');
|
||||
}
|
||||
|
||||
await this.inventoryRepository.update(id, {
|
||||
status: 'READY_FOR_LOADING',
|
||||
readyForLoadingAt: new Date(),
|
||||
const weight = Number(item.weight) || 0;
|
||||
const volume = Number(item.volume) || 0;
|
||||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||
|
||||
const from = { warehouseId: item.warehouseId, yardId: item.yardId, zoneId: item.zoneId };
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse } = await this.validateLocation(manager, dto);
|
||||
|
||||
// Capacity check at the destination (item is added there).
|
||||
const dest = await this.loadLocation(manager, dto);
|
||||
this.assertCapacity('Warehouse', dest.warehouse, weight, volume, containerCount);
|
||||
this.assertCapacity('Yard', dest.yard, weight, volume, containerCount);
|
||||
this.assertCapacity('Zone', dest.zone, weight, volume, containerCount);
|
||||
|
||||
// Free the old location, occupy the new one.
|
||||
await this.applyCapacityDelta(manager, from.warehouseId, from.yardId, from.zoneId, weight, volume, containerCount, -1);
|
||||
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
|
||||
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
});
|
||||
|
||||
await manager.getRepository(WarehouseInventoryMovement).save(
|
||||
manager.getRepository(WarehouseInventoryMovement).create({
|
||||
inventoryId: id,
|
||||
fromWarehouseId: from.warehouseId,
|
||||
fromYardId: from.yardId,
|
||||
fromZoneId: from.zoneId,
|
||||
toWarehouseId: dto.warehouseId,
|
||||
toYardId: dto.yardId,
|
||||
toZoneId: dto.zoneId,
|
||||
remarks: dto.remarks?.trim() ?? null,
|
||||
movedBy: dto.movedBy ?? 'system',
|
||||
movedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_MOVED',
|
||||
inventoryId: id,
|
||||
warehouseId: warehouse.id,
|
||||
description: dto.remarks?.trim() || 'Inventory moved',
|
||||
performedBy: dto.movedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
// ── Inquiry ────────────────────────────────────────────────────────────
|
||||
findMovements(id: string): Promise<WarehouseInventoryMovement[]> {
|
||||
return this.dataSource.getRepository(WarehouseInventoryMovement).find({
|
||||
where: { inventoryId: id },
|
||||
order: { movedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
findActivity(id: string): Promise<WarehouseActivityLog[]> {
|
||||
return this.activityLog.findByInventory(id);
|
||||
}
|
||||
|
||||
// ── Inquiry (Batch 1) ──────────────────────────────────────────────────
|
||||
|
||||
async inquiry(filter: InquiryWarehouseInventoryDto): Promise<InventoryInquiryResult[]> {
|
||||
const qb = this.dataSource
|
||||
@@ -187,21 +359,12 @@ export class WarehouseInventoryService {
|
||||
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 });
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -209,7 +372,7 @@ export class WarehouseInventoryService {
|
||||
const row = raw[index] ?? {};
|
||||
return {
|
||||
id: inv.id,
|
||||
bookingId: inv.bookingId,
|
||||
bookingId: inv.bookingId ?? null,
|
||||
bookingNumber: row.b_reference ?? null,
|
||||
customerName: row.c_name ?? null,
|
||||
containerNumber: row.ct_number ?? null,
|
||||
@@ -232,40 +395,71 @@ export class WarehouseInventoryService {
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private async transition(
|
||||
id: string,
|
||||
to: WarehouseInventoryStatus,
|
||||
opts: {
|
||||
timestampField: keyof WarehouseInventory;
|
||||
activityType: Parameters<WarehouseActivityLogService['record']>[0]['activityType'];
|
||||
description: string;
|
||||
performedBy?: string;
|
||||
preloaded?: WarehouseInventory;
|
||||
},
|
||||
): Promise<WarehouseInventory> {
|
||||
const item = opts.preloaded ?? (await this.findById(id));
|
||||
this.assertTransition(item.status, to);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: to,
|
||||
[opts.timestampField]: new Date(),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: opts.activityType,
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: opts.description,
|
||||
performedBy: opts.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
private assertTransition(from: WarehouseInventoryStatus, to: WarehouseInventoryStatus): void {
|
||||
if (!WAREHOUSE_INVENTORY_TRANSITIONS[from]?.includes(to)) {
|
||||
throw new BadRequestException(`Invalid transition ${from} → ${to}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async validateLocation(
|
||||
manager: EntityManager,
|
||||
dto: ReceiveWarehouseInventoryDto,
|
||||
dto: { warehouseId: string; yardId: string; zoneId: string },
|
||||
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
|
||||
const { warehouse, yard, zone } = await this.loadLocation(manager, dto);
|
||||
|
||||
if (warehouse.status !== 'ACTIVE') throw new BadRequestException('Warehouse is not ACTIVE');
|
||||
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');
|
||||
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 loadLocation(
|
||||
manager: EntityManager,
|
||||
dto: { warehouseId: string; yardId: string; zoneId: string },
|
||||
): 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');
|
||||
}
|
||||
|
||||
if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`);
|
||||
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');
|
||||
}
|
||||
|
||||
if (!yard) throw new NotFoundException(`Yard ${dto.yardId} not found`);
|
||||
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');
|
||||
}
|
||||
|
||||
if (!zone) throw new NotFoundException(`Zone ${dto.zoneId} not found`);
|
||||
return { warehouse, yard, zone };
|
||||
}
|
||||
|
||||
@@ -279,45 +473,63 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private async getBookingStatus(bookingId: string): Promise<string | null> {
|
||||
const rows = await this.dataSource.query(
|
||||
'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||
[bookingId],
|
||||
);
|
||||
return rows?.[0]?.status ?? null;
|
||||
}
|
||||
|
||||
private assertCapacity(
|
||||
label: string,
|
||||
node: { capacityWeight?: number | null; capacityContainers?: number | null; currentWeight: number; currentContainers: number },
|
||||
node: LocationNode,
|
||||
weightAdd: number,
|
||||
volumeAdd: number,
|
||||
containerAdd: number,
|
||||
): void {
|
||||
if (node.capacityWeight != null) {
|
||||
const maxWeight = node.maxWeight ?? node.capacityWeight;
|
||||
if (maxWeight != null) {
|
||||
const projected = Number(node.currentWeight) + weightAdd;
|
||||
if (projected > Number(node.capacityWeight)) {
|
||||
throw new BadRequestException(
|
||||
`${label} weight capacity exceeded (${projected} / ${node.capacityWeight})`,
|
||||
);
|
||||
if (projected > Number(maxWeight)) {
|
||||
throw new BadRequestException(`${label} weight capacity exceeded (${projected} / ${maxWeight})`);
|
||||
}
|
||||
}
|
||||
if (node.maxVolume != null && volumeAdd > 0) {
|
||||
const projected = Number(node.currentVolume) + volumeAdd;
|
||||
if (projected > Number(node.maxVolume)) {
|
||||
throw new BadRequestException(`${label} volume capacity exceeded (${projected} / ${node.maxVolume})`);
|
||||
}
|
||||
}
|
||||
|
||||
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})`,
|
||||
);
|
||||
throw new BadRequestException(`${label} container capacity exceeded (${projected} / ${node.capacityContainers})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async applyCapacityDelta(
|
||||
manager: EntityManager,
|
||||
dto: ReceiveWarehouseInventoryDto,
|
||||
weightAdd: number,
|
||||
containerAdd: number,
|
||||
warehouseId: string,
|
||||
yardId: string,
|
||||
zoneId: string,
|
||||
weight: number,
|
||||
volume: number,
|
||||
containers: number,
|
||||
sign: 1 | -1,
|
||||
): 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);
|
||||
const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager);
|
||||
const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [
|
||||
[Warehouse, warehouseId],
|
||||
[WarehouseYard, yardId],
|
||||
[WarehouseZone, zoneId],
|
||||
];
|
||||
|
||||
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);
|
||||
for (const [entity, id] of targets) {
|
||||
if (weight) await apply(entity, { id }, 'currentWeight', weight);
|
||||
if (volume) await apply(entity, { id }, 'currentVolume', volume);
|
||||
if (containers) await apply(entity, { id }, 'currentContainers', containers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
|
||||
/**
|
||||
* READ-ONLY bridge that exposes warehouse inventory to the Train Scheduling
|
||||
* domain. It only reads warehouse data — it never assigns wagons, creates or
|
||||
* mutates schedules, and is intentionally NOT imported by the scheduling module.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseSchedulingAdapterService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
private get repo() {
|
||||
return this.dataSource.getRepository(WarehouseInventory);
|
||||
}
|
||||
|
||||
getReadyForLoadingInventory(): Promise<WarehouseInventory[]> {
|
||||
return this.repo.find({
|
||||
where: { status: 'READY_FOR_LOADING' },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { readyForLoadingAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
getReservedInventory(): Promise<WarehouseInventory[]> {
|
||||
return this.repo.find({
|
||||
where: { status: 'RESERVED' },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { reservedAt: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
getInventoryByBooking(bookingId: string): Promise<WarehouseInventory[]> {
|
||||
return this.repo.find({
|
||||
where: { bookingId },
|
||||
relations: { warehouse: true, yard: true, zone: true },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Inventory whose origin booking runs on the given route. Best-effort, read-only:
|
||||
* matches the route's origin/destination yards against the booking's yards.
|
||||
*/
|
||||
async getInventoryByRoute(routeId: string): Promise<WarehouseInventory[]> {
|
||||
return this.repo
|
||||
.createQueryBuilder('inv')
|
||||
.leftJoinAndSelect('inv.warehouse', 'warehouse')
|
||||
.leftJoinAndSelect('inv.yard', 'yard')
|
||||
.leftJoinAndSelect('inv.zone', 'zone')
|
||||
.innerJoin('freight.bookings', 'booking', 'booking.id = inv.booking_id')
|
||||
.innerJoin(
|
||||
'freight.routes',
|
||||
'route',
|
||||
'route.id = :routeId AND (route.origin_yard_id = booking.origin_yard_id OR route.destination_yard_id = booking.destination_yard_id)',
|
||||
{ routeId },
|
||||
)
|
||||
.orderBy('inv.created_at', 'DESC')
|
||||
.getMany();
|
||||
}
|
||||
}
|
||||
@@ -45,8 +45,11 @@ export class WarehouseYardsService {
|
||||
type: dto.type,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
maxVolume: dto.maxVolume ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -67,6 +70,8 @@ export class WarehouseYardsService {
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
@@ -44,8 +44,11 @@ export class WarehouseZonesService {
|
||||
type: dto.type,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
maxVolume: dto.maxVolume ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -66,6 +69,8 @@ export class WarehouseZonesService {
|
||||
type: dto.type ?? existing.type,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { WarehouseDashboardService } from './warehouse-dashboard.service';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@@ -15,6 +16,7 @@ export class WarehousesController {
|
||||
constructor(
|
||||
private readonly warehousesService: WarehousesService,
|
||||
private readonly yardsService: WarehouseYardsService,
|
||||
private readonly dashboardService: WarehouseDashboardService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@@ -23,6 +25,12 @@ export class WarehousesController {
|
||||
return this.warehousesService.findAll(filter);
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
@ApiOperation({ summary: 'Warehouse dashboard metrics' })
|
||||
dashboard() {
|
||||
return this.dashboardService.getDashboard();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create warehouse' })
|
||||
create(@Body() dto: CreateWarehouseDto) {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
import { WarehouseYard } from './entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from './entities/warehouse-zone.entity';
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
|
||||
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
|
||||
import { WarehouseDashboardService } from './warehouse-dashboard.service';
|
||||
import { WarehouseInventoryController } from './warehouse-inventory.controller';
|
||||
import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository';
|
||||
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
|
||||
import { WarehouseInventoryService } from './warehouse-inventory.service';
|
||||
import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service';
|
||||
import { WarehouseYardsController } from './warehouse-yards.controller';
|
||||
import { WarehouseYardsRepository } from './warehouse-yards.repository';
|
||||
import { WarehouseYardsService } from './warehouse-yards.service';
|
||||
@@ -19,7 +26,16 @@ import { WarehousesRepository } from './warehouses.repository';
|
||||
import { WarehousesService } from './warehouses.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory])],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Warehouse,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
WarehouseInventory,
|
||||
WarehouseInventoryMovement,
|
||||
WarehouseActivityLog,
|
||||
]),
|
||||
],
|
||||
controllers: [
|
||||
WarehousesController,
|
||||
WarehouseYardsController,
|
||||
@@ -31,11 +47,22 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseYardsRepository,
|
||||
WarehouseZonesRepository,
|
||||
WarehouseInventoryRepository,
|
||||
WarehouseInventoryMovementRepository,
|
||||
WarehouseActivityLogRepository,
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseInventoryService,
|
||||
WarehouseActivityLogService,
|
||||
WarehouseDashboardService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
],
|
||||
exports: [
|
||||
WarehousesService,
|
||||
WarehouseYardsService,
|
||||
WarehouseZonesService,
|
||||
WarehouseInventoryService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
],
|
||||
exports: [WarehousesService, WarehouseYardsService, WarehouseZonesService, WarehouseInventoryService],
|
||||
})
|
||||
export class WarehousesModule {}
|
||||
|
||||
@@ -56,8 +56,11 @@ export class WarehousesService {
|
||||
locationName: dto.locationName?.trim() ?? null,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
maxVolume: dto.maxVolume ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
@@ -80,6 +83,8 @@ export class WarehousesService {
|
||||
locationName: dto.locationName?.trim() ?? existing.locationName,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user