Dashboard enhancement

This commit is contained in:
Hagernesh
2026-06-19 10:55:03 +00:00
parent f32d3d76e3
commit e40318c847
20 changed files with 563 additions and 28 deletions

View File

@@ -0,0 +1,37 @@
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
/**
* Import pickup branch on warehouse_inventory:
* - release_order_reference: DO / release order number sent to the customer
* - delivered_at: when the goods were handed over (proof of delivery)
*
* Idempotent: the shared dev DB may already carry some of these columns
* (added by another checkout), so only add what is missing.
*/
export class AddImportPickupDeliveryColumns1791000000002 implements MigrationInterface {
private readonly table = 'freight.warehouse_inventory';
public async up(queryRunner: QueryRunner): Promise<void> {
if (!(await queryRunner.hasColumn(this.table, 'release_order_reference'))) {
await queryRunner.addColumn(
this.table,
new TableColumn({ name: 'release_order_reference', type: 'varchar', length: '100', isNullable: true }),
);
}
if (!(await queryRunner.hasColumn(this.table, 'delivered_at'))) {
await queryRunner.addColumn(
this.table,
new TableColumn({ name: 'delivered_at', type: 'timestamptz', isNullable: true }),
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
if (await queryRunner.hasColumn(this.table, 'release_order_reference')) {
await queryRunner.dropColumn(this.table, 'release_order_reference');
}
if (await queryRunner.hasColumn(this.table, 'delivered_at')) {
await queryRunner.dropColumn(this.table, 'delivered_at');
}
}
}

View File

@@ -0,0 +1,24 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString } from 'class-validator';
/** Proof of delivery captured when import goods are handed over to the customer. */
export class DeliverInventoryDto {
@ApiProperty({ description: 'Name of the person who received the goods' })
@IsString()
receiverName!: string;
@ApiPropertyOptional({ description: 'When the goods were delivered (defaults to now)' })
@IsOptional()
@IsDateString()
deliveredAt?: string;
@ApiPropertyOptional({ description: 'Delivery remarks / notes' })
@IsOptional()
@IsString()
remarks?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -0,0 +1,20 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsString } from 'class-validator';
/** Records a DO / release order being sent to the customer for import pickup. */
export class ReleaseOrderDto {
@ApiPropertyOptional({ description: 'DO / release order reference number' })
@IsOptional()
@IsString()
reference?: string;
@ApiPropertyOptional({ description: 'Release date (defaults to now)' })
@IsOptional()
@IsDateString()
releaseDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -9,6 +9,9 @@ export const WAREHOUSE_ACTIVITY_TYPES = [
'READY_FOR_LOADING',
'INVENTORY_LOADED',
'INVENTORY_DISPATCHED',
'READY_FOR_PICKUP',
'INVENTORY_RELEASED',
'INVENTORY_DELIVERED',
] as const;
export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number];

View File

@@ -8,8 +8,11 @@ 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
// Lifecycle. Supersedes the Batch 1 set
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
// After RECEIVED + inspection (PASSED), the flow branches by booking trade direction:
// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
export const WAREHOUSE_INVENTORY_STATUSES = [
'RECEIVED',
'STORED',
@@ -17,17 +20,21 @@ export const WAREHOUSE_INVENTORY_STATUSES = [
'READY_FOR_LOADING',
'LOADED',
'DISPATCHED',
'READY_FOR_PICKUP',
'DELIVERED',
] 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'],
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
STORED: ['RESERVED'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],
DISPATCHED: [],
READY_FOR_PICKUP: ['DELIVERED'],
DELIVERED: [],
};
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
@@ -135,6 +142,14 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'release_date', type: 'timestamptz', nullable: true })
releaseDate?: Date | null;
// Import branch: reference of the DO / release order sent to the customer.
@Column({ name: 'release_order_reference', type: 'varchar', length: 100, nullable: true })
releaseOrderReference?: string | null;
// Import branch: when the goods were handed over to the customer (proof of delivery).
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
@Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true })
gateClearedAt?: Date | null;

View File

@@ -1,5 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Facility } from '../../facilities/entities/facility.entity';
import { WarehouseYard } from './warehouse-yard.entity';
@@ -63,6 +63,7 @@ export class Warehouse extends BaseEntity {
facilityId?: string | null;
@ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true })
@JoinColumn({ name: 'facility_id' })
facility?: Facility | null;
@OneToMany(() => WarehouseYard, (yard) => yard.warehouse)

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { DataSource, IsNull } from 'typeorm';
import { Warehouse } from './entities/warehouse.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
@@ -8,11 +8,18 @@ export interface WarehouseDashboard {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
// Inspection gate
awaitingInspection: number;
inspected: number;
// Export branch
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
// Import branch
readyForPickup: number;
delivered: number;
}
@Injectable()
@@ -26,30 +33,50 @@ export class WarehouseDashboardService {
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 {
const [
totalWarehouses,
totalInventory,
receivedToday,
awaitingInspection,
inspected,
stored,
reserved,
readyForLoading,
loaded,
dispatched,
readyForPickup,
delivered,
receivedToday,
] = await Promise.all([
warehouseRepo.count(),
inventoryRepo.count(),
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
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.count({ where: { status: 'READY_FOR_PICKUP' } }),
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
inventoryRepo
.createQueryBuilder('inv')
.where('inv.arrived_at >= :start', { start: startOfToday })
.getCount(),
]);
return {
totalWarehouses,
totalInventory,
receivedToday,
awaitingInspection,
inspected,
stored,
reserved,
readyForLoading,
loaded,
dispatched,
readyForPickup,
delivered,
};
}
}

View File

@@ -1,11 +1,13 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { SchedulingReadFacade } from './scheduling-read.facade';
@@ -137,6 +139,24 @@ export class WarehouseInventoryController {
return this.inventoryService.load(id, dto);
}
@Post(':id/ready-for-pickup')
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.readyForPickup(id, performedBy);
}
@Post(':id/release')
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
return this.inventoryService.release(id, dto);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
return this.inventoryService.deliver(id, dto);
}
@Patch(':id/dispatch')
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {

View File

@@ -1,11 +1,14 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReleaseOrderDto } from './dto/release-order.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
import { WarehouseAllocationService } from './warehouse-allocation.service';
@@ -511,6 +514,9 @@ export class WarehouseInventoryService {
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
}
if (item.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
}
return this.transition(id, 'READY_FOR_LOADING', {
timestampField: 'readyForLoadingAt',
activityType: 'READY_FOR_LOADING',
@@ -520,6 +526,112 @@ export class WarehouseInventoryService {
});
}
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────
/** Mark inspected IMPORT inventory ready for customer pickup (RECEIVED → READY_FOR_PICKUP). */
async readyForPickup(id: string, performedBy?: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for pickup');
}
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction !== 'IMPORT') {
throw new BadRequestException('Only IMPORT inventory can be marked ready for pickup');
}
return this.transition(id, 'READY_FOR_PICKUP', {
timestampField: 'readyForPickupAt',
activityType: 'READY_FOR_PICKUP',
description: 'Inventory ready for customer pickup',
performedBy,
preloaded: item,
});
}
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.status !== 'READY_FOR_PICKUP') {
throw new BadRequestException(
`Inventory must be READY_FOR_PICKUP to issue a release order (current: ${item.status})`,
);
}
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
const reference = dto.reference?.trim() || null;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
releaseDate,
releaseOrderReference: reference,
});
await this.activityLog.record(
{
activityType: 'INVENTORY_RELEASED',
inventoryId: id,
warehouseId: item.warehouseId,
description: reference
? `Release order ${reference} sent to customer`
: 'Release order sent to customer',
performedBy: dto.performedBy,
},
manager,
);
});
return this.findById(id);
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
this.assertTransition(item.status, 'DELIVERED');
if (!item.releaseDate) {
throw new BadRequestException('A release order must be issued before the goods can be delivered');
}
const receiverName = dto.receiverName.trim();
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : 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;
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(id, {
status: 'DELIVERED',
deliveredAt,
});
// Goods physically leave the warehouse on pickup — free up capacity.
await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1);
// Proof of delivery is captured on the linked cargo.
if (item.cargoId) {
await manager.getRepository(Cargo).update(item.cargoId, {
receiverName,
deliveredAt,
deliveryRemarks: dto.remarks?.trim() ?? null,
});
}
await this.activityLog.record(
{
activityType: 'INVENTORY_DELIVERED',
inventoryId: id,
warehouseId: item.warehouseId,
description: `Delivered to ${receiverName}`,
performedBy: dto.performedBy,
},
manager,
);
});
return this.findById(id);
}
/**
* Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record.
* Reads wagon/schedule data read-only — never modifies scheduling.
@@ -886,6 +998,15 @@ export class WarehouseInventoryService {
return rows?.[0]?.status ?? null;
}
/** IMPORT | EXPORT | DOMESTIC for the booking, or null if the booking is missing. */
private async getBookingDirection(bookingId: string): Promise<string | null> {
const rows = await this.dataSource.query(
'SELECT trade_direction FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
[bookingId],
);
return rows?.[0]?.trade_direction ?? null;
}
private assertCapacity(
label: string,
node: LocationNode,