diff --git a/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts b/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts new file mode 100644 index 000000000..3e272c859 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1791000000002-AddImportPickupDeliveryColumns.ts @@ -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 { + 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 { + 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'); + } + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts new file mode 100644 index 000000000..b2491472d --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/deliver-inventory.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts new file mode 100644 index 000000000..9d4e3eb4f --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/release-order.dto.ts @@ -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; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts index 4521bb808..9efa5c51a 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-activity-log.entity.ts @@ -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]; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 6c9270987..64dbda1ec 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -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 = { - 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; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts index 9d27ed64a..267fcc8af 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -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) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts index 1bb5b1289..fcc09f668 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-dashboard.service.ts @@ -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, }; } } diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index ce8a2c188..244239c37 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 652be600c..b26fc9c44 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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, diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx new file mode 100644 index 000000000..4b5485815 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/DeliverInventoryModal.tsx @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core'; +import { Info } from 'lucide-react'; + +import { useToast } from '@/hooks/use-toast'; +import { useDeliverInventory } from '@/hooks/useWarehouses'; +import type { WarehouseInventoryItem } from '@/types/warehouse'; +import { extractErrorMessage } from './options'; + +interface DeliverInventoryModalProps { + opened: boolean; + onClose: () => void; + item: WarehouseInventoryItem | null; +} + +export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) { + const { toast } = useToast(); + const deliverMutation = useDeliverInventory(); + const [receiverName, setReceiverName] = useState(''); + const [remarks, setRemarks] = useState(''); + + useEffect(() => { + if (opened) { + setReceiverName(''); + setRemarks(''); + } + }, [opened, item]); + + const handleSubmit = async () => { + if (!item) return; + if (!receiverName.trim()) { + toast({ variant: 'destructive', title: 'Receiver name is required' }); + return; + } + try { + await deliverMutation.mutateAsync({ + id: item.id, + payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined }, + }); + toast({ title: 'Delivered — proof of delivery captured' }); + onClose(); + } catch (error) { + toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) }); + } + }; + + return ( + + + } color="green" variant="light"> + + A release order must already be issued. Capturing the receiver marks the goods DELIVERED. + + + setReceiverName(e.currentTarget.value)} + /> +