Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority

This commit is contained in:
Marshal
2026-06-22 11:43:38 +00:00
151 changed files with 8535 additions and 9476 deletions

View File

@@ -0,0 +1,26 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsOptional, IsString, IsUUID } from 'class-validator';
/** Bulk-mark received inventory items as inspection PASSED. */
export class BulkInspectDto {
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('all', { each: true })
inventoryIds!: string[];
@ApiPropertyOptional({ description: 'Inspection type label (e.g. ORIGIN_INSPECTION).' })
@IsOptional()
@IsString()
inspectionType?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
remarks?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
inspectedBy?: string;
}

View File

@@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
export class BulkReceiveDto {
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
@IsIn(['IMPORT', 'EXPORT'])
direction!: 'IMPORT' | 'EXPORT';
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
zoneId!: string;
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('all', { each: true })
bookingIds!: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

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

@@ -3,12 +3,16 @@ import { Column, Entity, Index } from 'typeorm';
export const WAREHOUSE_ACTIVITY_TYPES = [
'INVENTORY_RECEIVED',
'INVENTORY_UNLOADED',
'INVENTORY_STORED',
'INVENTORY_MOVED',
'INVENTORY_RESERVED',
'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,26 +8,40 @@ 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 = [
'UNLOADED',
'RECEIVED',
'STORED',
'RESERVED',
'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'],
// UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected.
// Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection.
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
STORED: ['RESERVED'],
RESERVED: ['READY_FOR_LOADING'],
READY_FOR_LOADING: ['LOADED'],
LOADED: ['DISPATCHED'],
DISPATCHED: [],
// Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched
// out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects
// it / customs or inspection hold / operator chooses to store.
READY_FOR_PICKUP: ['DELIVERED', 'STORED', 'DISPATCHED'],
DELIVERED: [],
};
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
@@ -104,6 +118,10 @@ export class WarehouseInventory extends BaseEntity {
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
// Batch 8: when the goods were unloaded off the arrived train (before storage/inspection).
@Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true })
unloadedAt?: Date | null;
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
storedAt?: Date | null;
@@ -135,6 +153,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,6 +1,8 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
/**
* READ-ONLY view into the train-scheduling / wagons domain for the warehouse module.
*
@@ -9,6 +11,32 @@ import { DataSource } from 'typeorm';
* It is intentionally decoupled (raw SQL) so it does not import the scheduling
* services/entities and cannot accidentally write to them.
*/
export interface ImportTrainRow {
scheduleId: string;
trainNumber: string | null;
route: string | null;
origin: string | null;
destination: string | null;
arrivalTime: string | null;
totalBookings: number;
totalContainers: number;
totalCargoes: number;
status: string;
}
export interface ImportTrainItemRow {
bookingId: string;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
arrivalTime: string | null;
currentStatus: string | null;
lastMileRequested: boolean;
pickupOption: string;
}
export interface WagonView {
id: string;
wagonNumber: string;
@@ -115,4 +143,81 @@ export class SchedulingReadFacade {
departureStatus: schedule?.status ?? null,
};
}
/**
* ARRIVED train schedules whose route is IMPORT (origin country = Djibouti), with per-train
* booking/container/cargo counts. Direction is derived from the origin/destination station
* countries (route-based), so EXPORT/DOMESTIC trains never appear. Read-only.
*/
async importArriveQueue(): Promise<ImportTrainRow[]> {
const rows: Array<
ImportTrainRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT ts.id AS "scheduleId",
ts.train_number AS "trainNumber",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
ts.status,
(SELECT count(*) FROM freight.train_schedule_bookings tsb
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings",
(SELECT count(*) FROM freight.containers c
JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL
WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers",
(SELECT count(*) FROM freight.cargoes cg
JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status = 'ARRIVED'
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`,
);
return rows
.filter(
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
...rest,
totalBookings: Number(rest.totalBookings) || 0,
totalContainers: Number(rest.totalContainers) || 0,
totalCargoes: Number(rest.totalCargoes) || 0,
route: rest.origin || rest.destination ? `${rest.origin ?? '?'}${rest.destination ?? '?'}` : null,
}));
}
/** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */
async importTrainDetail(scheduleId: string): Promise<ImportTrainItemRow[]> {
const rows: ImportTrainItemRow[] = await this.dataSource.query(
`SELECT b.id AS "bookingId",
b.reference AS "bookingReference",
b.company_id AS "customerId",
company.name AS "customerName",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
b.cargo_total_weight_vgm AS "weight",
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
COALESCE(inv.status, b.status) AS "currentStatus",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption"
FROM freight.train_schedule_bookings tsb
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
ORDER BY b.reference ASC NULLS LAST`,
[scheduleId],
);
return rows;
}
}

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,15 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
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';
@@ -56,6 +60,49 @@ export class WarehouseInventoryController {
return this.inventoryService.autoLoadReady();
}
@Get('eligible-bookings')
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
eligibleBookings(@Query('direction') direction?: string) {
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
return this.inventoryService.eligibleBookings(dir);
}
@Post('receive-bulk')
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
receiveBulk(@Body() dto: BulkReceiveDto) {
return this.inventoryService.bulkReceive(dto);
}
@Post('load-passed-export')
@ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' })
loadPassedExport(@Body('performedBy') performedBy?: string) {
return this.inventoryService.loadPassedExport(performedBy);
}
@Get('ready-to-load-export')
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
readyToLoadExport() {
return this.inventoryService.readyToLoadExport();
}
@Get('loaded-export')
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
loadedExport() {
return this.inventoryService.loadedExport();
}
@Post('bulk-dispatch-export')
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
}
@Post('bulk-mark-inspected')
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
bulkMarkInspected(@Body() dto: BulkInspectDto) {
return this.inventoryService.bulkMarkInspected(dto);
}
@Post('bookings/:bookingId/unload')
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
unloadBooking(
@@ -71,6 +118,36 @@ export class WarehouseInventoryController {
return this.inventoryService.gateClearance(id, performedBy);
}
@Get('import/arrive-queue')
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
importArriveQueue() {
return this.scheduling.importArriveQueue();
}
@Get('import/trains/:scheduleId/items')
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
return this.scheduling.importTrainDetail(scheduleId);
}
@Post('import/auto-unload-arrived-bookings')
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) {
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
}
@Get('import/unloaded-queue')
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
importUnloadedQueue() {
return this.inventoryService.importUnloadedQueue();
}
@Get('import/pickup-ready-queue')
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
importPickupReadyQueue() {
return this.inventoryService.importPickupReadyQueue();
}
@Get('loadable-wagons')
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {
@@ -137,6 +214,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,14 +1,21 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { BulkInspectDto } from './dto/bulk-inspect.dto';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
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';
import { WarehouseInspectionService } from './warehouse-inspection.service';
import { WarehouseInvoiceService } from './warehouse-invoice.service';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
@@ -113,6 +120,85 @@ export interface AutoLoadResult {
results: { inventoryId: string; status: string; reason?: string }[];
}
// ── Receive (Import/Export bulk) shapes ──────────────────────────────────────
export interface EligibleBookingRow {
id: string;
reference: string;
customerId: string | null;
customer: string | null;
direction: string;
origin: string | null;
destination: string | null;
freightType: string | null;
cargo: string | null;
weight: string | null;
paymentStatus: string;
status: string;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
}
export interface LoadPassedExportResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface BulkInspectResult {
inspectedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface ReadyToLoadRow {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
origin: string | null;
destination: string | null;
inspectionStatus: string | null;
status: string;
}
export interface BulkDispatchResult {
dispatchedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
export interface AutoUnloadArrivedResult {
unloadedCount: number;
skippedCount: number;
failedCount: number;
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
}
export interface ImportUnloadedRow {
id: string;
bookingId: string | null;
bookingReference: string | null;
customerId: string | null;
customerName: string | null;
arrivalTime: string | null;
containerNumber: string | null;
cargoType: string | null;
weight: number | null;
trainSchedule: string | null;
inspectionStatus: string | null;
pickupOption: string;
lastMileRequested: boolean;
currentStatus: string;
}
@Injectable()
export class WarehouseInventoryService {
constructor(
@@ -123,6 +209,7 @@ export class WarehouseInventoryService {
private readonly scheduling: SchedulingReadFacade,
private readonly allocation: WarehouseAllocationService,
private readonly invoices: WarehouseInvoiceService,
private readonly inspectionService: WarehouseInspectionService,
) {}
/**
@@ -403,6 +490,542 @@ export class WarehouseInventoryService {
return result;
}
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
/**
* Eligible PAID bookings that have NOT been received yet, classified IMPORT/EXPORT by route
* (origin/destination yard countries). Pass a direction to filter to one; omit it to return
* all import + export bookings in a single call (DOMESTIC routes are excluded either way).
*/
async eligibleBookings(direction?: 'IMPORT' | 'EXPORT'): Promise<EligibleBookingRow[]> {
const rows: Array<
EligibleBookingRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT b.id,
b.reference AS "reference",
b.company_id AS "customerId",
company.name AS "customer",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
b.freight_type AS "freightType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
b.cargo_total_weight_vgm AS "weight",
b.payment_status AS "paymentStatus",
b.status AS "status"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID'
AND inv.id IS NULL
ORDER BY b.scheduled_date DESC NULLS LAST`,
);
// Direction is derived from the route (origin/destination yard countries), reusing deriveTradeDirection.
return rows
.map((r) => ({
...r,
direction: deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }),
}))
.filter((r) =>
direction ? r.direction === direction : r.direction === 'IMPORT' || r.direction === 'EXPORT',
);
}
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, {
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
});
for (const bookingId of dto.bookingIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ bookingId, status: 'SKIPPED', reason });
};
const [booking] = await manager.query(
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.bookings b
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
[bookingId],
);
if (!booking) { skip('Booking not found'); continue; }
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
// Direction is derived from the route (yard countries), not the stored field.
const bookingDirection = deriveTradeDirection(
{ country: booking.originCountry },
{ country: booking.destinationCountry },
);
if (bookingDirection !== dto.direction) {
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
continue;
}
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
notes: `Bulk received (${dto.direction})`,
}),
);
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `Bulk received ${dto.direction} booking`,
performedBy: dto.performedBy,
},
manager,
);
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id });
}
});
return result;
}
/** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */
async loadPassedExport(performedBy?: string): Promise<LoadPassedExportResult> {
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] };
for (const item of ready) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason });
};
if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; }
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; }
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
status: 'LOADED',
loadedAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_LOADED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: 'Bulk loaded (passed export)',
performedBy,
},
manager,
);
});
result.loadedCount += 1;
result.results.push({ inventoryId: item.id, status: 'LOADED' });
}
return result;
}
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
private async exportInventoryByStatus(
status: WarehouseInventoryStatus,
requireInspectionPassed = false,
): Promise<ReadyToLoadRow[]> {
const rows: Array<
ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
b.company_id AS "customerId",
company.name AS "customerName",
ct.container_number AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
inv.inspection_status AS "inspectionStatus",
inv.status
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
WHERE inv.deleted_at IS NULL
AND inv.status = $1
${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
ORDER BY inv.created_at DESC`,
[status],
);
return rows
.filter((r) => {
const dir = deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry });
return dir === 'EXPORT';
})
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
}
/** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
}
/** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
async loadedExport(): Promise<ReadyToLoadRow[]> {
return this.exportInventoryByStatus('LOADED');
}
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
const rows: Array<
ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT inv.id,
inv.booking_id AS "bookingId",
b.reference AS "bookingReference",
b.company_id AS "customerId",
company.name AS "customerName",
COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime",
(SELECT c.container_number FROM freight.containers c
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
inv.weight AS "weight",
ts.train_number AS "trainSchedule",
inv.inspection_status AS "inspectionStatus",
CASE WHEN b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
inv.status AS "currentStatus",
oy.country AS "originCountry",
dy.country AS "destinationCountry"
FROM freight.warehouse_inventory inv
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
WHERE inv.deleted_at IS NULL
AND inv.status = ANY($1)
ORDER BY inv.created_at DESC`,
[statuses],
);
return rows
.filter(
(r) =>
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
)
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
}
/**
* Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection
* states), with the columns the inspection screen needs. Read-only.
*/
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
}
/**
* Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP),
* awaiting customer pickup / last mile / store / dispatch. Read-only.
*/
importPickupReadyQueue(): Promise<ImportUnloadedRow[]> {
return this.importQueueByStatuses(['READY_FOR_PICKUP']);
}
/**
* Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition
* (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not
* LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT.
*/
async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise<BulkDispatchResult> {
const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] };
for (const inventoryId of inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
};
const item = await this.inventoryRepository.findById(inventoryId);
if (!item) { skip('Inventory not found'); continue; }
if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; }
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
try {
await this.dispatch(inventoryId, performedBy);
result.dispatchedCount += 1;
result.results.push({ inventoryId, status: 'DISPATCHED' });
} catch (error) {
skip(error instanceof Error ? error.message : String(error));
}
}
return result;
}
/** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */
private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [
'IN_TRANSIT',
'ARRIVED_AT_INDODE',
'ARRIVED_AT_DESTINATION',
'ARRIVED_AT_FACILITY',
];
/**
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
* items land in UNLOADED so the operator drives store/inspect/reserve/dispatch from the queue.
*/
async autoUnloadArrivedBookings(
scheduleId: string,
performedBy?: string,
): Promise<AutoUnloadArrivedResult> {
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
const [schedule] = await this.dataSource.query(
`SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.train_schedules ts
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== 'ARRIVED') {
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
}
const direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
if (direction !== 'IMPORT') {
throw new BadRequestException(`Train schedule route is ${direction}, not IMPORT`);
}
// 2. Assigned bookings on this train.
const bookings: {
id: string;
status: string;
weight: string | null;
freightType: string | null;
tradeDirection: string | null;
cargoTypeCode: string | null;
}[] = await this.dataSource.query(
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode"
FROM freight.train_schedule_bookings tsb
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
[scheduleId],
);
const fallback = await this.pickDefaultLocation();
const now = new Date();
for (const booking of bookings) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ bookingId: booking.id, status: 'SKIPPED', reason });
};
const fail = (reason: string) => {
result.failedCount += 1;
result.results.push({ bookingId: booking.id, status: 'FAILED', reason });
};
if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) {
skip(`Booking status ${booking.status} is not unload-eligible`);
continue;
}
try {
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
// Already unloaded or further along — leave it (do not regress the lifecycle).
if (existing && existing.status !== 'RECEIVED') {
skip(`Inventory already ${existing.status}`);
continue;
}
if (existing) {
await this.inventoryRepository.update(existing.id, {
status: 'UNLOADED',
unloadedAt: now,
arrivedAt: existing.arrivedAt ?? now,
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: existing.id,
warehouseId: existing.warehouseId,
description: 'Unloaded from arrived import train',
performedBy,
});
result.unloadedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' });
continue;
}
// No inventory yet — create it at the allocated (or default) location, in UNLOADED state.
const allocated = await this.allocation.resolveLocation({
freightType: booking.freightType,
tradeDirection: booking.tradeDirection,
cargoTypeCode: booking.cargoTypeCode,
});
const location = allocated ?? fallback;
if (!location) {
fail('No warehouse/yard/zone configured');
continue;
}
const saved = await this.inventoryRepository.create({
warehouseId: location.warehouseId,
yardId: location.yardId,
zoneId: location.zoneId,
bookingId: booking.id,
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'UNLOADED',
arrivedAt: now,
unloadedAt: now,
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
});
await this.activityLog.record({
activityType: 'INVENTORY_UNLOADED',
inventoryId: saved.id,
warehouseId: saved.warehouseId,
description: 'Unloaded from arrived import train',
performedBy,
});
result.unloadedCount += 1;
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' });
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
}
}
return result;
}
/**
* Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal
* report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING.
* For damage / weight-loss / images, use the per-item Inspect / Report action instead.
*/
async bulkMarkInspected(dto: BulkInspectDto): Promise<BulkInspectResult> {
const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] };
// UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
for (const inventoryId of dto.inventoryIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId, status: 'SKIPPED', reason });
};
const item = await this.inventoryRepository.findById(inventoryId);
if (!item) { skip('Inventory not found'); continue; }
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
await this.inspectionService.create(inventoryId, {
reportType: 'INSPECTION',
inspectionStatus: 'PASSED',
remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).',
inspectedById: dto.inspectedBy,
});
// A passed item advances by trade direction:
// EXPORT → Ready To Load (READY_FOR_LOADING)
// IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading.
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction === 'EXPORT') {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(inventoryId, {
status: 'READY_FOR_LOADING',
readyForLoadingAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'READY_FOR_LOADING',
inventoryId,
warehouseId: item.warehouseId,
description: 'Inspection passed → ready for loading',
performedBy: dto.inspectedBy,
},
manager,
);
});
result.results.push({ inventoryId, status: 'READY_FOR_LOADING' });
} else if (direction === 'IMPORT') {
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(inventoryId, {
status: 'READY_FOR_PICKUP',
readyForPickupAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'READY_FOR_PICKUP',
inventoryId,
warehouseId: item.warehouseId,
description: 'Destination inspection passed → pickup ready',
performedBy: dto.inspectedBy,
},
manager,
);
});
result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' });
} else {
result.results.push({ inventoryId, status: 'INSPECTED' });
}
result.inspectedCount += 1;
}
return result;
}
// ── Receive ──────────────────────────────────────────────────────────────
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
@@ -511,6 +1134,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 +1146,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 +1618,23 @@ export class WarehouseInventoryService {
return rows?.[0]?.status ?? null;
}
/** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */
private async getBookingDirection(bookingId: string): Promise<string | null> {
const rows = await this.dataSource.query(
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.bookings b
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
[bookingId],
);
if (!rows?.[0]) return null;
return deriveTradeDirection(
{ country: rows[0].originCountry },
{ country: rows[0].destinationCountry },
);
}
private assertCapacity(
label: string,
node: LocationNode,

View File

@@ -1,5 +1,5 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { FindManyOptions, ILike } from 'typeorm';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { FindManyOptions, ILike, QueryFailedError } from 'typeorm';
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
@@ -49,23 +49,27 @@ export class WarehousesService {
async create(dto: CreateWarehouseDto): Promise<Warehouse> {
await this.assertCodeUnique(dto.code.trim());
return this.warehousesRepository.create({
name: dto.name.trim(),
code: dto.code.trim(),
type: dto.type,
stationId: dto.stationId ?? null,
facilityId: dto.facilityId ?? null,
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,
});
try {
return await this.warehousesRepository.create({
name: dto.name.trim(),
code: dto.code.trim(),
type: dto.type,
stationId: dto.stationId ?? null,
facilityId: dto.facilityId ?? null,
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,
});
} catch (error) {
this.mapDbError(error);
}
}
async update(id: string, dto: UpdateWarehouseDto): Promise<Warehouse> {
@@ -77,20 +81,25 @@ export class WarehousesService {
const status = dto.status ?? existing.status;
const updated = await this.warehousesRepository.update(id, {
name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
stationId: dto.stationId ?? existing.stationId,
facilityId: dto.facilityId ?? existing.facilityId,
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',
});
let updated;
try {
updated = await this.warehousesRepository.update(id, {
name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
stationId: dto.stationId ?? existing.stationId,
facilityId: dto.facilityId ?? existing.facilityId,
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',
});
} catch (error) {
this.mapDbError(error);
}
if (!updated) {
throw new NotFoundException(`Warehouse ${id} not found`);
@@ -99,6 +108,21 @@ export class WarehousesService {
return this.findById(id);
}
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
private mapDbError(error: unknown): never {
if (error instanceof QueryFailedError) {
const driver = (error as QueryFailedError & { driverError?: { code?: string; detail?: string } }).driverError;
if (driver?.code === '23503') {
throw new BadRequestException('Selected facility does not exist.');
}
if (driver?.code === '22001') {
throw new BadRequestException('A field is too long (code max 40, name max 160 characters).');
}
throw new BadRequestException(driver?.detail ?? error.message ?? 'Invalid warehouse data.');
}
throw error as Error;
}
private async assertCodeUnique(code: string, ignoreId?: string): Promise<void> {
const [existing] = await this.warehousesRepository.findAll({ where: { code } });