mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 03:40:56 +00:00
Merge branch 'freight/develop' of github.com:Tria-plc/edr-platform into freight_feature/priority
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user