mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 07:51:02 +00:00
Truck Assign by customer plus Handover signature on portal
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"seed:gate-pass-train-scenarios": "ts-node -r tsconfig-paths/register src/scripts/seed-gate-pass-train-scenarios.ts",
|
||||
"auto-unload:arrived-import-trains": "ts-node -r tsconfig-paths/register src/scripts/auto-unload-arrived-import-trains.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:gov-companies": "ts-node -r tsconfig-paths/register src/scripts/seed-gov-companies.ts",
|
||||
|
||||
@@ -91,9 +91,17 @@ export class BookingsService {
|
||||
dto: CustomerTruckAssignmentDto,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
if (booking.lastMileDeliveryAddress?.trim()) {
|
||||
const hasFirstMile = Boolean(booking.firstMilePickupAddress?.trim());
|
||||
const hasLastMile = Boolean(booking.lastMileDeliveryAddress?.trim());
|
||||
const usesMileService =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? hasLastMile
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? hasFirstMile
|
||||
: hasFirstMile || hasLastMile;
|
||||
if (usesMileService) {
|
||||
throw new BadRequestException(
|
||||
'Customer truck assignment is only allowed when last mile delivery is not selected',
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
|
||||
);
|
||||
}
|
||||
if (booking.customerTruckAssignedAt) {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ImportTrainItemRow {
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
@@ -274,6 +275,7 @@ export class SchedulingReadFacade {
|
||||
w.wagon_number AS "wagonNumber",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
wba.allocated_weight_tons AS "allocatedWeightTons",
|
||||
b.freight_type AS "freightType",
|
||||
(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",
|
||||
|
||||
@@ -139,8 +139,18 @@ export class WarehouseInventoryController {
|
||||
|
||||
@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);
|
||||
autoUnloadArrivedBookings(@Body() dto: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
performedBy?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) {
|
||||
return this.inventoryService.autoUnloadArrivedBookings(
|
||||
dto.scheduleId,
|
||||
dto.performedBy,
|
||||
dto.warehouseId,
|
||||
dto.assignments,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('import/unloaded-queue')
|
||||
|
||||
@@ -180,6 +180,10 @@ interface LocationRef {
|
||||
zoneId: string;
|
||||
}
|
||||
|
||||
interface BookingUnloadLocation extends LocationRef {
|
||||
bookingId: string;
|
||||
}
|
||||
|
||||
interface LocationNode {
|
||||
capacityWeight?: number | null;
|
||||
capacityContainers?: number | null;
|
||||
@@ -514,8 +518,9 @@ export class WarehouseInventoryService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** First warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||||
private async pickDefaultLocation(): Promise<DefaultLocation | null> {
|
||||
/** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */
|
||||
private async pickDefaultLocation(warehouseId?: string): Promise<DefaultLocation | null> {
|
||||
const params = warehouseId ? [warehouseId] : [];
|
||||
const [row]: DefaultLocation[] = await this.dataSource.query(
|
||||
`SELECT wh.id AS "warehouseId", wh.facility_id AS "facilityId",
|
||||
yard.id AS "yardId", zone.id AS "zoneId"
|
||||
@@ -523,8 +528,10 @@ export class WarehouseInventoryService {
|
||||
JOIN freight.warehouse_yards yard ON yard.warehouse_id = wh.id AND yard.deleted_at IS NULL
|
||||
JOIN freight.warehouse_zones zone ON zone.yard_id = yard.id AND zone.deleted_at IS NULL
|
||||
WHERE wh.deleted_at IS NULL
|
||||
${warehouseId ? 'AND wh.id = $1' : ''}
|
||||
ORDER BY wh.created_at ASC
|
||||
LIMIT 1`,
|
||||
LIMIT 1`,
|
||||
params,
|
||||
);
|
||||
return row ?? null;
|
||||
}
|
||||
@@ -602,6 +609,7 @@ export class WarehouseInventoryService {
|
||||
dto.warehouseId && dto.yardId && dto.zoneId
|
||||
? { warehouseId: dto.warehouseId, yardId: dto.yardId, zoneId: dto.zoneId, facilityId: dto.facilityId ?? null }
|
||||
: null;
|
||||
if (!location && dto.warehouseId) location = await this.pickDefaultLocation(dto.warehouseId);
|
||||
if (!location) location = await this.pickDefaultLocation();
|
||||
if (!location) {
|
||||
throw new BadRequestException('No warehouse/yard/zone provided or configured for unloading');
|
||||
@@ -1185,6 +1193,8 @@ export class WarehouseInventoryService {
|
||||
async autoUnloadArrivedBookings(
|
||||
scheduleId: string,
|
||||
performedBy?: string,
|
||||
warehouseId?: string,
|
||||
assignments: BookingUnloadLocation[] = [],
|
||||
): Promise<AutoUnloadArrivedResult> {
|
||||
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
|
||||
|
||||
@@ -1231,7 +1241,21 @@ export class WarehouseInventoryService {
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
const fallback = await this.pickDefaultLocation();
|
||||
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
|
||||
if (warehouseId && !requestedLocation) {
|
||||
throw new BadRequestException('Selected warehouse has no yard/zone configured for unloading');
|
||||
}
|
||||
const fallback = requestedLocation ?? (await this.pickDefaultLocation());
|
||||
const assignmentByBooking = new Map(
|
||||
assignments.map((assignment) => [
|
||||
assignment.bookingId,
|
||||
{
|
||||
warehouseId: assignment.warehouseId,
|
||||
yardId: assignment.yardId,
|
||||
zoneId: assignment.zoneId,
|
||||
} satisfies LocationRef,
|
||||
]),
|
||||
);
|
||||
const now = new Date();
|
||||
|
||||
for (const booking of bookings) {
|
||||
@@ -1251,6 +1275,8 @@ export class WarehouseInventoryService {
|
||||
|
||||
try {
|
||||
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
|
||||
const assignedLocation = assignmentByBooking.get(booking.id) ?? null;
|
||||
const unloadLocation = assignedLocation ?? requestedLocation;
|
||||
|
||||
// Already unloaded or further along — leave it (do not regress the lifecycle).
|
||||
if (existing && existing.status !== 'RECEIVED') {
|
||||
@@ -1260,6 +1286,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
if (existing) {
|
||||
await this.inventoryRepository.update(existing.id, {
|
||||
...(unloadLocation
|
||||
? {
|
||||
warehouseId: unloadLocation.warehouseId,
|
||||
yardId: unloadLocation.yardId,
|
||||
zoneId: unloadLocation.zoneId,
|
||||
}
|
||||
: {}),
|
||||
status: 'UNLOADED',
|
||||
unloadedAt: now,
|
||||
arrivedAt: existing.arrivedAt ?? now,
|
||||
@@ -1267,7 +1300,7 @@ export class WarehouseInventoryService {
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
inventoryId: existing.id,
|
||||
warehouseId: existing.warehouseId,
|
||||
warehouseId: unloadLocation?.warehouseId ?? existing.warehouseId,
|
||||
description: 'Unloaded from arrived import train',
|
||||
performedBy,
|
||||
});
|
||||
@@ -1282,7 +1315,7 @@ export class WarehouseInventoryService {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
cargoTypeCode: booking.cargoTypeCode,
|
||||
});
|
||||
const location = allocated ?? fallback;
|
||||
const location = assignedLocation ?? requestedLocation ?? allocated ?? fallback;
|
||||
if (!location) {
|
||||
fail('No warehouse/yard/zone configured');
|
||||
continue;
|
||||
@@ -2003,8 +2036,13 @@ export class WarehouseInventoryService {
|
||||
const releaseDate = isTruckLeaving
|
||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||
: item.releaseDate ?? null;
|
||||
const reference = dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
||||
const reference = isTruckLeaving
|
||||
? item.releaseOrderReference || dto.reference?.trim() || (await this.generateReleaseReference(item))
|
||||
: dto.reference?.trim() || (await this.generateReleaseReference(item));
|
||||
const exitInspectionDto = isTruckLeaving
|
||||
? this.preserveTruckArrivalForExit(dto, item.notes)
|
||||
: dto;
|
||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
@@ -3629,6 +3667,24 @@ export class WarehouseInventoryService {
|
||||
return rows.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private preserveTruckArrivalForExit(dto: ReleaseOrderDto, notes: string | null | undefined): ReleaseOrderDto {
|
||||
const inspection = this.extractExitInspectionNote(notes);
|
||||
if (!inspection) return dto;
|
||||
|
||||
return {
|
||||
...dto,
|
||||
truckPlateNumber: this.extractExitInspectionLine(inspection, 'Truck Plate') || dto.truckPlateNumber,
|
||||
trailerPlateNumber: this.extractExitInspectionLine(inspection, 'Trailer Plate') || dto.trailerPlateNumber,
|
||||
driverName: this.extractExitInspectionLine(inspection, 'Driver') || dto.driverName,
|
||||
driverLicense: this.extractExitInspectionLine(inspection, 'Driver License') || dto.driverLicense,
|
||||
driverPhone: this.extractExitInspectionLine(inspection, 'Driver Phone') || dto.driverPhone,
|
||||
truckType: this.extractExitInspectionLine(inspection, 'Truck Type') || dto.truckType,
|
||||
containerNumber: this.extractExitInspectionLine(inspection, 'Container Number') || dto.containerNumber,
|
||||
gateInTime: this.extractExitInspectionLine(inspection, 'Gate In Time') || dto.gateInTime,
|
||||
tareWeight: this.extractExitInspectionNumber(inspection, 'Tare Weight') ?? dto.tareWeight,
|
||||
};
|
||||
}
|
||||
|
||||
private replaceExitInspectionNote(notes: string | null | undefined, exitInspectionNote: string | null): string | null {
|
||||
const trimmed = notes?.trim();
|
||||
if (!exitInspectionNote) return trimmed || null;
|
||||
@@ -3650,6 +3706,18 @@ export class WarehouseInventoryService {
|
||||
return notes.slice(index + marker.length).trim() || null;
|
||||
}
|
||||
|
||||
private extractExitInspectionLine(note: string | null | undefined, label: string): string | null {
|
||||
const match = note?.match(new RegExp(`^${label}:\\s*(.+)$`, 'im'));
|
||||
return match?.[1]?.trim() || null;
|
||||
}
|
||||
|
||||
private extractExitInspectionNumber(note: string | null | undefined, label: string): number | undefined {
|
||||
const value = this.extractExitInspectionLine(note, label)?.replace(/\s*kg$/i, '');
|
||||
if (!value) return undefined;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
private extractReceiveSummary(notes?: string | null): string | null {
|
||||
if (!notes?.trim()) return null;
|
||||
const withoutExit = notes.split('\n\n[Exit Inspection]')[0] ?? notes;
|
||||
|
||||
@@ -0,0 +1,627 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
import { In } from 'typeorm';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { CompanyProfile, ProfileStatus, ProfileType } from '../modules/companies/entities/company-profile.entity';
|
||||
import { Company, CompanyKind, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
|
||||
import { Container } from '../modules/container-management/entities/container.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { WagonAllocationContainerItem } from '../modules/train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../modules/train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../modules/train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
import { WagonType } from '../modules/wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../modules/wagons/entities/wagon.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
|
||||
type Direction = 'IMPORT' | 'EXPORT';
|
||||
type TrainStatus = 'SCHEDULED' | 'ARRIVED';
|
||||
|
||||
interface ScenarioTrain {
|
||||
trainNumber: string;
|
||||
direction: Direction;
|
||||
status: TrainStatus;
|
||||
departureOffsetHours: number;
|
||||
arrivalOffsetHours: number;
|
||||
bookings: Array<{
|
||||
reference: string;
|
||||
mileVariant: 'FIRST_MILE' | 'LAST_MILE' | 'TERMINAL';
|
||||
containerNumber: string;
|
||||
weightTons: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const SCENARIOS: ScenarioTrain[] = [
|
||||
{
|
||||
trainNumber: 'GP-IMP-ARR-01',
|
||||
direction: 'IMPORT',
|
||||
status: 'ARRIVED',
|
||||
departureOffsetHours: -18,
|
||||
arrivalOffsetHours: -6,
|
||||
bookings: [
|
||||
{ reference: 'GP-IMP-ARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000001', weightTons: 22 },
|
||||
{ reference: 'GP-IMP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000002', weightTons: 24 },
|
||||
],
|
||||
},
|
||||
{
|
||||
trainNumber: 'GP-IMP-NARR-01',
|
||||
direction: 'IMPORT',
|
||||
status: 'SCHEDULED',
|
||||
departureOffsetHours: 6,
|
||||
arrivalOffsetHours: 18,
|
||||
bookings: [
|
||||
{ reference: 'GP-IMP-NARR-LM-001', mileVariant: 'LAST_MILE', containerNumber: 'GPIM0000003', weightTons: 21 },
|
||||
{ reference: 'GP-IMP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPIM0000004', weightTons: 23 },
|
||||
],
|
||||
},
|
||||
{
|
||||
trainNumber: 'GP-EXP-ARR-01',
|
||||
direction: 'EXPORT',
|
||||
status: 'ARRIVED',
|
||||
departureOffsetHours: -16,
|
||||
arrivalOffsetHours: -4,
|
||||
bookings: [
|
||||
{ reference: 'GP-EXP-ARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000001', weightTons: 20 },
|
||||
{ reference: 'GP-EXP-ARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000002', weightTons: 22 },
|
||||
],
|
||||
},
|
||||
{
|
||||
trainNumber: 'GP-EXP-NARR-01',
|
||||
direction: 'EXPORT',
|
||||
status: 'SCHEDULED',
|
||||
departureOffsetHours: 8,
|
||||
arrivalOffsetHours: 20,
|
||||
bookings: [
|
||||
{ reference: 'GP-EXP-NARR-FM-001', mileVariant: 'FIRST_MILE', containerNumber: 'GPEX0000003', weightTons: 19 },
|
||||
{ reference: 'GP-EXP-NARR-TM-002', mileVariant: 'TERMINAL', containerNumber: 'GPEX0000004', weightTons: 21 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const addHours = (date: Date, hours: number): Date => new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
|
||||
async function main() {
|
||||
const dataSource = await AppDataSource.initialize();
|
||||
|
||||
try {
|
||||
const seeded = await dataSource.transaction(async (manager) => {
|
||||
if (await isAlreadySeeded(manager)) {
|
||||
return null;
|
||||
}
|
||||
const refs = await ensureReferences(manager);
|
||||
const now = new Date();
|
||||
const result: Array<{ trainNumber: string; bookings: string[] }> = [];
|
||||
|
||||
for (const scenario of SCENARIOS) {
|
||||
const schedule = await seedScenarioTrain(manager, scenario, refs, now);
|
||||
result.push({
|
||||
trainNumber: schedule.trainNumber ?? scenario.trainNumber,
|
||||
bookings: scenario.bookings.map((booking) => booking.reference),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
console.log('Gate-pass train scenario seed complete.');
|
||||
if (seeded) {
|
||||
for (const row of seeded) {
|
||||
console.log(`${row.trainNumber}: ${row.bookings.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
console.log('Gate-pass train scenarios already seeded; nothing changed.');
|
||||
}
|
||||
} finally {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
async function isAlreadySeeded(manager: any): Promise<boolean> {
|
||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||
const bookingRepo = manager.getRepository(Booking);
|
||||
const trainNumbers = SCENARIOS.map((scenario) => scenario.trainNumber);
|
||||
const bookingRefs = SCENARIOS.flatMap((scenario) => scenario.bookings.map((booking) => booking.reference));
|
||||
|
||||
const [scheduleCount, bookingCount] = await Promise.all([
|
||||
scheduleRepo.count({ where: { trainNumber: In(trainNumbers) } }),
|
||||
bookingRepo.count({ where: { reference: In(bookingRefs) } }),
|
||||
]);
|
||||
|
||||
return scheduleCount === trainNumbers.length && bookingCount === bookingRefs.length;
|
||||
}
|
||||
|
||||
async function ensureReferences(manager: any) {
|
||||
const yardRepo = manager.getRepository(Yard);
|
||||
const serviceTypeRepo = manager.getRepository(ServiceType);
|
||||
const containerTypeRepo = manager.getRepository(ContainerType);
|
||||
const wagonTypeRepo = manager.getRepository(WagonType);
|
||||
const companyRepo = manager.getRepository(Company);
|
||||
const profileRepo = manager.getRepository(CompanyProfile);
|
||||
const warehouseRepo = manager.getRepository(Warehouse);
|
||||
const warehouseYardRepo = manager.getRepository(WarehouseYard);
|
||||
const warehouseZoneRepo = manager.getRepository(WarehouseZone);
|
||||
|
||||
const djiboutiYard =
|
||||
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
|
||||
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'NAGAD',
|
||||
label: 'Nagad Port',
|
||||
country: 'Djibouti',
|
||||
isActive: true,
|
||||
displayOrder: 90,
|
||||
}),
|
||||
));
|
||||
|
||||
const ethiopiaYard =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'INDODE',
|
||||
label: 'Indode Dry Port',
|
||||
country: 'Ethiopia',
|
||||
isActive: true,
|
||||
displayOrder: 91,
|
||||
}),
|
||||
));
|
||||
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.save(
|
||||
serviceTypeRepo.create({
|
||||
code: 'RAIL_CONTAINER',
|
||||
serviceName: 'Rail Container Service',
|
||||
description: 'Rail container service for gate-pass scenario seed',
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: false,
|
||||
includesLastMile: false,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
));
|
||||
|
||||
const containerType =
|
||||
(await containerTypeRepo.findOne({ where: { code: '40FT' } })) ??
|
||||
(await containerTypeRepo.findOne({ where: { isActive: true } })) ??
|
||||
(await containerTypeRepo.save(
|
||||
containerTypeRepo.create({
|
||||
code: '40FT',
|
||||
label: '40FT',
|
||||
sizeFt: 40,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
}),
|
||||
));
|
||||
|
||||
const wagonType =
|
||||
(await wagonTypeRepo.findOne({ where: { code: 'GP-FLAT' } })) ??
|
||||
(await wagonTypeRepo.findOne({ where: { supportsContainer: true } })) ??
|
||||
(await wagonTypeRepo.findOne({ where: { isActive: true } })) ??
|
||||
(await wagonTypeRepo.save(
|
||||
wagonTypeRepo.create({
|
||||
code: 'GP-FLAT',
|
||||
name: 'Gate Pass Demo Flat Wagon',
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
maxWagonsPerTrain: 53,
|
||||
supportedLoadTypes: ['CONTAINER'],
|
||||
isActive: true,
|
||||
equatedLengthM: 14,
|
||||
tareWeightTons: 20,
|
||||
supportsContainer: true,
|
||||
maxContainerGrossT: 70,
|
||||
}),
|
||||
));
|
||||
|
||||
const company =
|
||||
(await companyRepo.findOne({ where: { tin: 'GTPASS001' } })) ??
|
||||
(await companyRepo.save(
|
||||
companyRepo.create({
|
||||
name: 'Gate Pass Scenario Customer',
|
||||
type: CompanyType.Customer,
|
||||
kind: CompanyKind.Commercial,
|
||||
status: CompanyStatus.Active,
|
||||
tin: 'GTPASS001',
|
||||
vatNumber: 'GTPASS001',
|
||||
fanNumber: 'GTPASS0000001',
|
||||
country: 'Ethiopia',
|
||||
address: 'Indode Dry Port',
|
||||
phone: '251900000555',
|
||||
email: 'gate-pass-scenarios@edr.local',
|
||||
contactPersonName: 'Gate Pass Tester',
|
||||
contactPersonPhone: '251900000555',
|
||||
}),
|
||||
));
|
||||
|
||||
const importerProfile = await ensureProfile(profileRepo, company.id, ProfileType.importer, 'GP-IMP');
|
||||
const exporterProfile = await ensureProfile(profileRepo, company.id, ProfileType.exporter, 'GP-EXP');
|
||||
|
||||
const warehouse =
|
||||
(await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } })) ??
|
||||
(await warehouseRepo.findOne({ where: {} }));
|
||||
if (!warehouse) {
|
||||
throw new Error('No warehouse found. Run the Indode/warehouse seed before gate-pass scenarios.');
|
||||
}
|
||||
const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } });
|
||||
if (!warehouseYard) {
|
||||
throw new Error(`No warehouse yard found for ${warehouse.code ?? warehouse.id}.`);
|
||||
}
|
||||
const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } });
|
||||
if (!warehouseZone) {
|
||||
throw new Error(`No warehouse zone found for yard ${warehouseYard.id}.`);
|
||||
}
|
||||
|
||||
return {
|
||||
djiboutiYard,
|
||||
ethiopiaYard,
|
||||
serviceType,
|
||||
containerType,
|
||||
wagonType,
|
||||
company,
|
||||
importerProfile,
|
||||
exporterProfile,
|
||||
warehouse,
|
||||
warehouseYard,
|
||||
warehouseZone,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureProfile(repo: any, companyId: string, type: ProfileType, reference: string): Promise<CompanyProfile> {
|
||||
const existing = await repo.findOne({ where: { companyId, type } });
|
||||
if (existing) return existing;
|
||||
return repo.save(
|
||||
repo.create({
|
||||
companyId,
|
||||
type,
|
||||
reference,
|
||||
status: ProfileStatus.Active,
|
||||
businessLicense: `${reference}-LICENSE`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function seedScenarioTrain(manager: any, scenario: ScenarioTrain, refs: Awaited<ReturnType<typeof ensureReferences>>, now: Date) {
|
||||
const locomotiveRepo = manager.getRepository(Locomotive);
|
||||
const trainSetRepo = manager.getRepository(TrainSet);
|
||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||
const trainSetWagonRepo = manager.getRepository(TrainSetWagon);
|
||||
const wagonRepo = manager.getRepository(Wagon);
|
||||
|
||||
const departure = addHours(now, scenario.departureOffsetHours);
|
||||
const arrival = addHours(now, scenario.arrivalOffsetHours);
|
||||
const isArrived = scenario.status === 'ARRIVED';
|
||||
const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
|
||||
const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
|
||||
const totalWeightTons = scenario.bookings.reduce((sum, booking) => sum + booking.weightTons, 0);
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'GP-DEMO-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'GP-DEMO-LOCO',
|
||||
name: 'Gate Pass Scenario Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
currentYardId: originYard.id,
|
||||
}),
|
||||
));
|
||||
|
||||
let schedule = await scheduleRepo.findOne({ where: { trainNumber: scenario.trainNumber } });
|
||||
let trainSet: TrainSet | null = schedule?.trainSetId
|
||||
? await trainSetRepo.findOne({ where: { id: schedule.trainSetId } })
|
||||
: null;
|
||||
|
||||
if (!trainSet) {
|
||||
trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons,
|
||||
totalLengthMeters: scenario.bookings.length * 14,
|
||||
wagonCount: scenario.bookings.length,
|
||||
status: isArrived ? 'COMPLETED' : 'ASSIGNED',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
await trainSetRepo.update(trainSet.id, {
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons,
|
||||
totalLengthMeters: scenario.bookings.length * 14,
|
||||
wagonCount: scenario.bookings.length,
|
||||
status: isArrived ? 'COMPLETED' : 'ASSIGNED',
|
||||
});
|
||||
}
|
||||
if (!trainSet) {
|
||||
throw new Error(`Could not create train set for ${scenario.trainNumber}`);
|
||||
}
|
||||
const trainSetId = trainSet.id;
|
||||
|
||||
if (!schedule) {
|
||||
schedule = scheduleRepo.create({ trainNumber: scenario.trainNumber });
|
||||
}
|
||||
Object.assign(schedule, {
|
||||
trainSetId,
|
||||
originStationId: originYard.id,
|
||||
destinationStationId: destinationYard.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualDepartureAt: isArrived ? departure : null,
|
||||
actualArrivalAt: isArrived ? arrival : null,
|
||||
status: scenario.status,
|
||||
direction: scenario.direction,
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
});
|
||||
schedule = await scheduleRepo.save(schedule);
|
||||
|
||||
for (const [index, bookingSpec] of scenario.bookings.entries()) {
|
||||
const sequenceNo = index + 1;
|
||||
const wagon = await ensureWagon(manager, scenario, sequenceNo, refs.wagonType.id, originYard.id, schedule.id);
|
||||
const trainSetWagon = await ensureTrainSetWagon(
|
||||
trainSetWagonRepo,
|
||||
trainSetId,
|
||||
refs.wagonType.id,
|
||||
wagon.id,
|
||||
sequenceNo,
|
||||
bookingSpec.weightTons,
|
||||
isArrived,
|
||||
);
|
||||
await wagonRepo.update(wagon.id, { trainSetWagonId: trainSetWagon.id });
|
||||
|
||||
const booking = await ensureBooking(manager, scenario, bookingSpec, refs, departure, now, schedule.id);
|
||||
const bookingContainer = await ensureBookingContainer(manager, booking.id, refs.containerType.id, bookingSpec);
|
||||
const allocation = await ensureAllocation(manager, trainSetWagon.id, booking.id, bookingSpec.weightTons, isArrived, now);
|
||||
const container = await ensureContainer(manager, booking.id, bookingContainer.id, allocation.id, wagon.id, sequenceNo, bookingSpec, refs.containerType.id, isArrived);
|
||||
await ensureContainerItem(manager, allocation.id, bookingContainer.id, container.id, refs.containerType.id, sequenceNo, bookingSpec);
|
||||
await ensureScheduleBooking(manager, schedule.id, booking.id);
|
||||
if (scenario.direction === 'EXPORT') {
|
||||
await ensureExportInventory(manager, refs, booking.id, container.id, bookingSpec.weightTons, isArrived, now);
|
||||
}
|
||||
}
|
||||
|
||||
if (scenario.direction === 'IMPORT') {
|
||||
await ensureImportOperation(manager, schedule.id, scenario, departure, isArrived);
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async function ensureWagon(manager: any, scenario: ScenarioTrain, sequenceNo: number, wagonTypeId: string, yardId: string, scheduleId: string): Promise<Wagon> {
|
||||
const repo = manager.getRepository(Wagon);
|
||||
const wagonNumber = `${scenario.trainNumber}-W${String(sequenceNo).padStart(2, '0')}`;
|
||||
const existing = await repo.findOne({ where: { wagonNumber } });
|
||||
const values = {
|
||||
wagonNumber,
|
||||
wagonTypeId,
|
||||
trainId: null,
|
||||
sequenceNumber: sequenceNo,
|
||||
tareWeight: 20,
|
||||
maxPayloadWeight: 70,
|
||||
status: WagonStatus.Assigned,
|
||||
currentYardId: yardId,
|
||||
currentTrainScheduleId: scheduleId,
|
||||
notes: 'Gate-pass scenario seed wagon',
|
||||
};
|
||||
return repo.save(repo.create({ ...(existing ?? {}), ...values }));
|
||||
}
|
||||
|
||||
async function ensureTrainSetWagon(repo: any, trainSetId: string, wagonTypeId: string, wagonId: string, sequenceNo: number, weightTons: number, isArrived: boolean): Promise<TrainSetWagon> {
|
||||
const existing = await repo.findOne({ where: { trainSetId, sequenceNo } });
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
trainSetId,
|
||||
wagonTypeId,
|
||||
physicalWagonId: wagonId,
|
||||
sequenceNo,
|
||||
capacityTons: 70,
|
||||
lengthMeters: 14,
|
||||
assignedWeightTons: weightTons,
|
||||
status: isArrived ? 'DEPARTED' : 'LOADED',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureBooking(manager: any, scenario: ScenarioTrain, bookingSpec: ScenarioTrain['bookings'][number], refs: Awaited<ReturnType<typeof ensureReferences>>, departure: Date, now: Date, scheduleId: string): Promise<Booking> {
|
||||
const repo = manager.getRepository(Booking);
|
||||
const originYard = scenario.direction === 'IMPORT' ? refs.djiboutiYard : refs.ethiopiaYard;
|
||||
const destinationYard = scenario.direction === 'IMPORT' ? refs.ethiopiaYard : refs.djiboutiYard;
|
||||
const existing = await repo.findOne({ where: { reference: bookingSpec.reference } });
|
||||
const profile = scenario.direction === 'IMPORT' ? refs.importerProfile : refs.exporterProfile;
|
||||
const hasFirstMile = bookingSpec.mileVariant === 'FIRST_MILE';
|
||||
const hasLastMile = bookingSpec.mileVariant === 'LAST_MILE';
|
||||
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
reference: bookingSpec.reference,
|
||||
companyId: refs.company.id,
|
||||
companyProfileId: profile.id,
|
||||
originYardId: originYard.id,
|
||||
destinationYardId: destinationYard.id,
|
||||
serviceTypeId: refs.serviceType.id,
|
||||
status: scenario.status === 'ARRIVED' ? 'IN_TRANSIT' : 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: departure,
|
||||
estimatedShipmentDate: departure,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: scenario.direction,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
cargoFreeText: `${scenario.direction} gate-pass scenario ${bookingSpec.mileVariant.toLowerCase().replace('_', ' ')}`,
|
||||
cargoTotalWeightVgm: bookingSpec.weightTons * 1000,
|
||||
firstMilePickupAddress: hasFirstMile ? 'Customer factory pickup - Addis Ababa' : null,
|
||||
firstMilePickupLat: hasFirstMile ? 9.03 : null,
|
||||
firstMilePickupLng: hasFirstMile ? 38.74 : null,
|
||||
lastMileDeliveryAddress: hasLastMile ? 'Customer warehouse delivery - Addis Ababa' : null,
|
||||
lastMileDeliveryLat: hasLastMile ? 8.98 : null,
|
||||
lastMileDeliveryLng: hasLastMile ? 38.8 : null,
|
||||
trainScheduleId: scheduleId,
|
||||
schedulingStatus: scenario.status === 'ARRIVED' ? 'DISPATCHED' : 'SCHEDULED',
|
||||
scheduledAt: now,
|
||||
wagonsRequired: 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureBookingContainer(manager: any, bookingId: string, containerTypeId: string, bookingSpec: ScenarioTrain['bookings'][number]): Promise<BookingContainer> {
|
||||
const repo = manager.getRepository(BookingContainer);
|
||||
const existing = await repo.findOne({ where: { bookingId } });
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
bookingId,
|
||||
containerTypeId,
|
||||
containerNumber: bookingSpec.containerNumber,
|
||||
containerSize: '40',
|
||||
quantity: 1,
|
||||
hazardousQuantity: 0,
|
||||
reeferQuantity: 0,
|
||||
vgmPerUnitTons: bookingSpec.weightTons,
|
||||
totalVgmTons: bookingSpec.weightTons,
|
||||
wagonsRequired: 1,
|
||||
weightLimitRuleId: null,
|
||||
isOverweight: false,
|
||||
overweightExcessTons: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureAllocation(manager: any, trainSetWagonId: string, bookingId: string, weightTons: number, isArrived: boolean, now: Date): Promise<WagonBookingAllocation> {
|
||||
const repo = manager.getRepository(WagonBookingAllocation);
|
||||
const existing = await repo.findOne({ where: { trainSetWagonId, bookingId } });
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
trainSetWagonId,
|
||||
bookingId,
|
||||
allocatedWeightTons: weightTons,
|
||||
loadType: 'CONTAINER',
|
||||
status: isArrived ? 'DEPARTED' : 'LOADED',
|
||||
confirmedAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureContainer(manager: any, bookingId: string, bookingContainerId: string, allocationId: string, wagonId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number], containerTypeId: string, isArrived: boolean): Promise<Container> {
|
||||
const repo = manager.getRepository(Container);
|
||||
const existing = await repo.findOne({ where: { containerNumber: bookingSpec.containerNumber } });
|
||||
return repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
containerNumber: bookingSpec.containerNumber,
|
||||
containerTypeId,
|
||||
wagonId,
|
||||
position,
|
||||
tareWeight: 3800,
|
||||
maxGrossWeight: 30480,
|
||||
sealNumber: `SEAL-${bookingSpec.containerNumber}`,
|
||||
status: isArrived ? 'IN_TRANSIT' : 'LOADED',
|
||||
bookingId,
|
||||
wagonBookingAllocationId: allocationId,
|
||||
bookingContainerId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureContainerItem(manager: any, allocationId: string, bookingContainerId: string, containerId: string, containerTypeId: string, position: number, bookingSpec: ScenarioTrain['bookings'][number]): Promise<void> {
|
||||
const repo = manager.getRepository(WagonAllocationContainerItem);
|
||||
await repo.delete({ wagonBookingAllocationId: allocationId });
|
||||
await repo.save(
|
||||
repo.create({
|
||||
wagonBookingAllocationId: allocationId,
|
||||
bookingContainerId,
|
||||
containerId,
|
||||
containerNumber: bookingSpec.containerNumber,
|
||||
containerTypeId,
|
||||
positionOnWagon: position,
|
||||
sealNumber: `SEAL-${bookingSpec.containerNumber}`,
|
||||
chassisNumber: `CHS-${bookingSpec.containerNumber}`,
|
||||
grossWeightTons: bookingSpec.weightTons,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureScheduleBooking(manager: any, scheduleId: string, bookingId: string): Promise<void> {
|
||||
const repo = manager.getRepository(TrainScheduleBooking);
|
||||
const existing = await repo.findOne({ where: { bookingId } });
|
||||
await repo.save(repo.create({ ...(existing ?? {}), trainScheduleId: scheduleId, bookingId }));
|
||||
}
|
||||
|
||||
async function ensureExportInventory(manager: any, refs: Awaited<ReturnType<typeof ensureReferences>>, bookingId: string, containerId: string, weightTons: number, isArrived: boolean, now: Date): Promise<void> {
|
||||
const repo = manager.getRepository(WarehouseInventory);
|
||||
const existing = await repo.findOne({ where: { bookingId } });
|
||||
await repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
warehouseId: refs.warehouse.id,
|
||||
yardId: refs.warehouseYard.id,
|
||||
zoneId: refs.warehouseZone.id,
|
||||
bookingId,
|
||||
containerId,
|
||||
quantity: 1,
|
||||
weight: weightTons * 1000,
|
||||
status: 'LOADED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: addHours(now, -24),
|
||||
inspectedAt: addHours(now, -22),
|
||||
readyForLoadingAt: addHours(now, -20),
|
||||
loadedAt: isArrived ? addHours(now, -16) : null,
|
||||
notes: '[GP-SCENARIO] Export train gate-pass scenario inventory',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureImportOperation(manager: any, scheduleId: string, scenario: ScenarioTrain, departure: Date, isArrived: boolean): Promise<void> {
|
||||
const repo = manager.getRepository(ImportDjiboutiOperation);
|
||||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
await repo.save(
|
||||
repo.create({
|
||||
...(existing ?? {}),
|
||||
trainScheduleId: scheduleId,
|
||||
documents: existing?.documents ?? {},
|
||||
gatepassGrantedAt: null,
|
||||
readyForLoadingAt: null,
|
||||
loadedOnTrainAt: null,
|
||||
departedFromDjiboutiAt: isArrived ? departure : null,
|
||||
loadListGeneratedAt: null,
|
||||
performedBy: 'Gate Pass Scenario Seeder',
|
||||
notes: `[GP-SCENARIO] ${scenario.trainNumber}; fill gate-pass dates during testing`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Gate-pass train scenario seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '@/services/api';
|
||||
import { QUERY_KEYS } from '@/constants/QUERY_KEYS';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useInventoryInquiry } from '@/hooks/useWarehouses';
|
||||
import { useAllWarehouseYards, useAllWarehouseZones, useInventoryInquiry } from '@/hooks/useWarehouses';
|
||||
import { firstMileService } from '@/services/first-mile.service';
|
||||
import { warehouseService } from '@/services/warehouse.service';
|
||||
import type {
|
||||
@@ -54,7 +54,10 @@ import type {
|
||||
ReadyToLoadRow,
|
||||
ReceiveInventoryPayload,
|
||||
TruckEntrancePayload,
|
||||
Warehouse,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseYard,
|
||||
WarehouseZone,
|
||||
} from '@/types/warehouse';
|
||||
import { BookingSelect } from './BookingSelect';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
@@ -70,6 +73,9 @@ import { extractErrorMessage, formatDate, formatNumber, inventoryStatusOptions }
|
||||
import { openPdfBlob } from './pdf';
|
||||
import '@/components/overview/overview.css';
|
||||
|
||||
type ImportUnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
||||
type ImportUnloadAssignmentDraft = Partial<Omit<ImportUnloadAssignment, 'bookingId'>>;
|
||||
|
||||
interface ReceiveInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
@@ -1741,14 +1747,59 @@ function LoadedExportTab({
|
||||
);
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train (read-only detail view). */
|
||||
function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
const importLocationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isImportContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
const isImportUnloadPending = (item: ImportTrainItem) =>
|
||||
!item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||
|
||||
/** Assigned bookings/items for an arrived import train with per-booking unload locations. */
|
||||
function ImportTrainDetailTable({
|
||||
train,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
assignments,
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
train: ImportTrain;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
assignments: Record<string, ImportUnloadAssignmentDraft>;
|
||||
onAssignmentChange: (bookingId: string, draft: ImportUnloadAssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useQuery(
|
||||
api.warehouses.importTrainItems.queryOptions({
|
||||
input: { scheduleId: train.scheduleId },
|
||||
enabled: Boolean(train.scheduleId),
|
||||
}),
|
||||
);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isImportUnloadPending);
|
||||
onReadyChange(
|
||||
pending.length > 0 &&
|
||||
pending.every((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
|
||||
}),
|
||||
);
|
||||
}, [assignments, items]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -1778,6 +1829,9 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Current Status</Table.Th>
|
||||
<Table.Th>Last Mile</Table.Th>
|
||||
@@ -1785,7 +1839,18 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((it: ImportTrainItem) => (
|
||||
{items.map((it: ImportTrainItem) => {
|
||||
const draft = assignments[it.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = importLocationTypesForFreight(it.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isImportUnloadPending(it);
|
||||
|
||||
return (
|
||||
<Table.Tr key={`${it.bookingId}-${it.sequenceNo ?? 'wagon'}`}>
|
||||
<Table.Td>
|
||||
<Text size="xs" fw={600}>
|
||||
@@ -1806,6 +1871,41 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
<Table.Td>{it.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(Number(it.weight))}</Table.Td>
|
||||
<Table.Td>{formatDate(it.arrivalTime)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Warehouse"
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) => onAssignmentChange(it.bookingId, { warehouseId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending}
|
||||
w={210}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isImportContainerFreight(it.freightType) ? 'Container yard' : 'Bulk yard'}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) =>
|
||||
onAssignmentChange(it.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
||||
}
|
||||
searchable
|
||||
disabled={!pending || !draft.warehouseId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isImportContainerFreight(it.freightType) ? 'Container zone' : 'Bulk zone'}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => onAssignmentChange(it.bookingId, { ...draft, zoneId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending || !draft.yardId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="xs" variant="light" color={it.inspectionStatus === 'PASSED' ? 'green' : 'gray'}>
|
||||
{it.inspectionStatus ?? 'Not inspected'}
|
||||
@@ -1821,7 +1921,8 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) {
|
||||
</Table.Td>
|
||||
<Table.Td>{it.pickupOption}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
@@ -1845,13 +1946,42 @@ function ImportArriveQueueTab({
|
||||
const { data: trains = [], isLoading } = useQuery(
|
||||
api.warehouses.importArriveQueue.queryOptions({ enabled }),
|
||||
);
|
||||
const { data: warehouses = [], isLoading: warehousesLoading } = useQuery(
|
||||
api.warehouses.list.queryOptions({ input: { filter: { status: 'ACTIVE' } }, enabled }),
|
||||
);
|
||||
const { data: yards = [] } = useAllWarehouseYards();
|
||||
const { data: zones = [] } = useAllWarehouseZones();
|
||||
const autoUnloadMutation = useMutation(
|
||||
api.warehouses.autoUnloadArrivedBookings.mutationOptions(),
|
||||
);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<
|
||||
Record<string, Record<string, ImportUnloadAssignmentDraft>>
|
||||
>({});
|
||||
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
||||
|
||||
const autoUnload = async (train: ImportTrain) => {
|
||||
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
||||
.filter((entry): entry is [string, Required<ImportUnloadAssignmentDraft>] =>
|
||||
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
|
||||
)
|
||||
.map(([bookingId, draft]) => ({
|
||||
bookingId,
|
||||
warehouseId: draft.warehouseId,
|
||||
yardId: draft.yardId,
|
||||
zoneId: draft.zoneId,
|
||||
}));
|
||||
|
||||
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Assign locations',
|
||||
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
@@ -1862,7 +1992,7 @@ function ImportArriveQueueTab({
|
||||
|
||||
setBusyId(train.scheduleId);
|
||||
try {
|
||||
const r = await autoUnloadMutation.mutateAsync(train.scheduleId);
|
||||
const r = await autoUnloadMutation.mutateAsync({ scheduleId: train.scheduleId, assignments });
|
||||
const alreadyUnloaded = r.unloadedCount === 0 && r.skippedCount > 0 && r.failedCount === 0;
|
||||
const firstReason = r.results.find((item) => item.reason)?.reason;
|
||||
const extra = [
|
||||
@@ -1961,7 +2091,7 @@ function ImportArriveQueueTab({
|
||||
color={fullyUnloaded ? 'gray' : 'indigo'}
|
||||
leftSection={<Truck size={14} />}
|
||||
loading={busyId === t.scheduleId}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0}
|
||||
disabled={fullyUnloaded || t.totalBookings === 0 || !readyBySchedule[t.scheduleId] || warehousesLoading}
|
||||
onClick={() => autoUnload(t)}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload Arrived Bookings'}
|
||||
@@ -1972,7 +2102,25 @@ function ImportArriveQueueTab({
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={11} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailTable train={t} />
|
||||
<ImportTrainDetailTable
|
||||
train={t}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
assignments={assignmentsBySchedule[t.scheduleId] ?? {}}
|
||||
onAssignmentChange={(bookingId, draft) =>
|
||||
setAssignmentsBySchedule((current) => ({
|
||||
...current,
|
||||
[t.scheduleId]: {
|
||||
...(current[t.scheduleId] ?? {}),
|
||||
[bookingId]: draft.warehouseId ? draft : {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
onReadyChange={(ready) =>
|
||||
setReadyBySchedule((current) => ({ ...current, [t.scheduleId]: ready }))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
@@ -158,13 +158,13 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
|
||||
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
|
||||
const prefillContainerNumber = truckPrefill?.containerNumber ?? '';
|
||||
setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
|
||||
setTruckPlateNumber(inspection.truckPlateNumber || assignedTruckPlate || truckPrefill?.truckPlateNumber || '');
|
||||
setTruckPlateNumber(inspection.truckPlateNumber || truckPrefill?.truckPlateNumber || assignedTruckPlate || '');
|
||||
setTrailerPlateNumber(inspection.trailerPlateNumber || truckPrefill?.trailerPlateNumber || '');
|
||||
setDriverName(inspection.driverName || assignedDriverName || truckPrefill?.driverName || '');
|
||||
setDriverName(inspection.driverName || truckPrefill?.driverName || assignedDriverName || '');
|
||||
setDriverLicense(inspection.driverLicense || truckPrefill?.driverLicense || '');
|
||||
setDriverPhone(inspection.driverPhone || truckPrefill?.driverPhone || '');
|
||||
setTruckType(inspection.truckType || assignedTruckType || truckPrefill?.truckType || '');
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || assignedContainerNumber || prefillContainerNumber));
|
||||
setTruckType(inspection.truckType || truckPrefill?.truckType || assignedTruckType || '');
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || prefillContainerNumber || assignedContainerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
|
||||
@@ -280,7 +280,13 @@ export function useImportTrainItems(scheduleId?: string) {
|
||||
|
||||
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
|
||||
export const useAutoUnloadArrivedBookings = () =>
|
||||
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
|
||||
useInventoryMutation((payload: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) =>
|
||||
warehouseService.autoUnloadArrivedBookings(payload),
|
||||
);
|
||||
|
||||
/** Arrived EXPORT trains at Djibouti-side ports. Read-only. */
|
||||
export function useExportDjiboutiArrivalQueue(enabled = true) {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
lastMileService,
|
||||
} from "@/services/last-mile.service";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { driversService, type Driver } from "@/services/drivers.service";
|
||||
import { ratesService } from "@/services/rates.service";
|
||||
import { LastMileContainerAllocationTable, type LastMileContainerRow } from "@/components/LastMileContainerAllocationTable";
|
||||
import { ReleaseOrderModal, type ReleaseOrderTruckPrefill } from "@/components/warehouses/ReleaseOrderModal";
|
||||
@@ -145,13 +146,20 @@ const toReleaseInventoryItem = (row: ImportUnloadedItem): WarehouseInventoryItem
|
||||
const releasePrefillFromLastMile = (
|
||||
record: LastMileRecord,
|
||||
row?: ImportUnloadedItem | null,
|
||||
driversById?: Map<string, Driver>,
|
||||
): ReleaseOrderTruckPrefill => {
|
||||
const vehicle = record.vehicle;
|
||||
const truckType = [vehicle?.manufacturer, vehicle?.model].filter(Boolean).join(" ").trim();
|
||||
const assignedDriver = vehicle?.assignedDriverId ? driversById?.get(vehicle.assignedDriverId) : undefined;
|
||||
const assignedDriverName = assignedDriver
|
||||
? `${assignedDriver.firstName ?? ""} ${assignedDriver.lastName ?? ""}`.trim()
|
||||
: "";
|
||||
return {
|
||||
truckPlateNumber: vehicle?.powerPlateNo || vehicle?.plateNumber || null,
|
||||
trailerPlateNumber: vehicle?.trailerPlateNo || null,
|
||||
driverName: vehicle?.assignedDriverName || null,
|
||||
driverName: vehicle?.assignedDriverName || assignedDriverName || null,
|
||||
driverLicense: assignedDriver?.licenseNumber || null,
|
||||
driverPhone: assignedDriver?.phoneNumber || null,
|
||||
truckType: vehicle?.vehicleType || truckType || null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
};
|
||||
@@ -401,6 +409,22 @@ const LastMilePage = () => {
|
||||
|
||||
const records = listData?.data ?? [];
|
||||
|
||||
const needsDriverLookup = records.some((record) => record.vehicle?.assignedDriverId);
|
||||
|
||||
const { data: driversData } = useQuery({
|
||||
queryKey: ["drivers", "list", "ACTIVE"],
|
||||
queryFn: async () => {
|
||||
const res = await driversService.getAll({ status: "ACTIVE" });
|
||||
return res.data;
|
||||
},
|
||||
enabled: needsDriverLookup,
|
||||
});
|
||||
|
||||
const driversById = useMemo(
|
||||
() => new Map((driversData ?? []).map((driver) => [driver.id, driver])),
|
||||
[driversData],
|
||||
);
|
||||
|
||||
const { data: pickupReadyRows = [] } = useQuery({
|
||||
queryKey: ["warehouse-inventory", "import-pickup-ready-queue"],
|
||||
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
|
||||
@@ -733,7 +757,7 @@ const LastMilePage = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
setReleaseTruckPrefill(releasePrefillFromLastMile(record, row));
|
||||
setReleaseTruckPrefill(releasePrefillFromLastMile(record, row, driversById));
|
||||
setReleaseItem(toReleaseInventoryItem(row));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Fragment, useState } from 'react';
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
@@ -21,11 +22,17 @@ import {
|
||||
} from '@/components/warehouses';
|
||||
import {
|
||||
useAutoUnloadArrivedBookings,
|
||||
useAllWarehouseYards,
|
||||
useAllWarehouseZones,
|
||||
useImportArriveQueue,
|
||||
useImportTrainItems,
|
||||
useWarehouses,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem } from '@/types/warehouse';
|
||||
import type { AutoUnloadArrivedResult, ImportTrain, ImportTrainItem, Warehouse, WarehouseYard, WarehouseZone } from '@/types/warehouse';
|
||||
|
||||
type UnloadAssignment = { bookingId: string; warehouseId: string; yardId: string; zoneId: string };
|
||||
type AssignmentDraft = Partial<Omit<UnloadAssignment, 'bookingId'>>;
|
||||
|
||||
const getErrorMessage = (error: unknown) => {
|
||||
if (error && typeof error === 'object' && 'response' in error) {
|
||||
@@ -43,8 +50,54 @@ const getPendingUnloadBookings = (train: ImportTrain) =>
|
||||
const isFullyUnloaded = (train: ImportTrain) =>
|
||||
Boolean(train.fullyUnloaded) || (train.totalBookings > 0 && getPendingUnloadBookings(train) === 0);
|
||||
|
||||
function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
const locationTypesForFreight = (freightType: string | null | undefined) => {
|
||||
const normalized = (freightType ?? '').toUpperCase();
|
||||
if (normalized === 'CONTAINER') {
|
||||
return { yardTypes: ['CONTAINER_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['CONTAINER_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
}
|
||||
return { yardTypes: ['BULK_YARD', 'GENERAL_CARGO_YARD'], zoneTypes: ['BULK_ZONE', 'GENERAL_CARGO_ZONE'] };
|
||||
};
|
||||
|
||||
const isContainerFreight = (freightType: string | null | undefined) =>
|
||||
(freightType ?? '').toUpperCase() === 'CONTAINER';
|
||||
|
||||
function isUnloadPending(item: ImportTrainItem) {
|
||||
return !item.currentStatus || item.currentStatus === 'RECEIVED';
|
||||
}
|
||||
|
||||
function ImportTrainDetailRows({
|
||||
scheduleId,
|
||||
warehouses,
|
||||
yards,
|
||||
zones,
|
||||
assignments,
|
||||
onAssignmentChange,
|
||||
onReadyChange,
|
||||
}: {
|
||||
scheduleId: string;
|
||||
warehouses: Warehouse[];
|
||||
yards: WarehouseYard[];
|
||||
zones: WarehouseZone[];
|
||||
assignments: Record<string, AssignmentDraft>;
|
||||
onAssignmentChange: (bookingId: string, draft: AssignmentDraft) => void;
|
||||
onReadyChange: (ready: boolean) => void;
|
||||
}) {
|
||||
const { data: items = [], isLoading } = useImportTrainItems(scheduleId);
|
||||
const warehouseOptions = useMemo(
|
||||
() => warehouses.map((warehouse) => ({ value: warehouse.id, label: `${warehouse.name} (${warehouse.code})` })),
|
||||
[warehouses],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const pending = items.filter(isUnloadPending);
|
||||
onReadyChange(
|
||||
pending.length > 0 &&
|
||||
pending.every((item) => {
|
||||
const draft = assignments[item.bookingId];
|
||||
return Boolean(draft?.warehouseId && draft.yardId && draft.zoneId);
|
||||
}),
|
||||
);
|
||||
}, [assignments, items]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -73,12 +126,26 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Arrival</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Warehouse</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
<Table.Th>Zone</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Pickup</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((item: ImportTrainItem) => (
|
||||
{items.map((item: ImportTrainItem) => {
|
||||
const draft = assignments[item.bookingId] ?? {};
|
||||
const { yardTypes, zoneTypes } = locationTypesForFreight(item.freightType);
|
||||
const yardOptions = yards
|
||||
.filter((yard) => yard.warehouseId === draft.warehouseId && yardTypes.includes(yard.type))
|
||||
.map((yard) => ({ value: yard.id, label: `${yard.name} (${yard.code})` }));
|
||||
const zoneOptions = zones
|
||||
.filter((zone) => zone.yardId === draft.yardId && zoneTypes.includes(zone.type))
|
||||
.map((zone) => ({ value: zone.id, label: `${zone.name} (${zone.code})` }));
|
||||
const pending = isUnloadPending(item);
|
||||
|
||||
return (
|
||||
<Table.Tr key={item.bookingId}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
@@ -95,6 +162,41 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
{item.currentStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder="Warehouse"
|
||||
data={warehouseOptions}
|
||||
value={draft.warehouseId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { warehouseId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending}
|
||||
w={210}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container yard' : 'Bulk yard'}
|
||||
data={yardOptions}
|
||||
value={draft.yardId ?? null}
|
||||
onChange={(value) =>
|
||||
onAssignmentChange(item.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined })
|
||||
}
|
||||
searchable
|
||||
disabled={!pending || !draft.warehouseId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Select
|
||||
placeholder={isContainerFreight(item.freightType) ? 'Container zone' : 'Bulk zone'}
|
||||
data={zoneOptions}
|
||||
value={draft.zoneId ?? null}
|
||||
onChange={(value) => onAssignmentChange(item.bookingId, { ...draft, zoneId: value ?? undefined })}
|
||||
searchable
|
||||
disabled={!pending || !draft.yardId}
|
||||
w={190}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge variant="light" color={item.inspectionStatus === 'PASSED' ? 'green' : 'gray'} size="sm">
|
||||
{item.inspectionStatus ?? 'Not inspected'}
|
||||
@@ -102,7 +204,8 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
</Table.Td>
|
||||
<Table.Td>{item.pickupOption.replace(/_/g, ' ')}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
@@ -112,11 +215,36 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) {
|
||||
export default function ArrivalQueuePage() {
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useImportArriveQueue();
|
||||
const { data: warehouses = [], isLoading: warehousesLoading } = useWarehouses({ status: 'ACTIVE' });
|
||||
const { data: yards = [] } = useAllWarehouseYards();
|
||||
const { data: zones = [] } = useAllWarehouseZones();
|
||||
const autoUnload = useAutoUnloadArrivedBookings();
|
||||
const [openScheduleId, setOpenScheduleId] = useState<string | null>(null);
|
||||
const [busyScheduleId, setBusyScheduleId] = useState<string | null>(null);
|
||||
const [assignmentsBySchedule, setAssignmentsBySchedule] = useState<Record<string, Record<string, AssignmentDraft>>>({});
|
||||
const [readyBySchedule, setReadyBySchedule] = useState<Record<string, boolean>>({});
|
||||
|
||||
const unloadTrain = async (train: ImportTrain) => {
|
||||
const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {})
|
||||
.filter((entry): entry is [string, Required<AssignmentDraft>] =>
|
||||
Boolean(entry[1].warehouseId && entry[1].yardId && entry[1].zoneId),
|
||||
)
|
||||
.map(([bookingId, draft]) => ({
|
||||
bookingId,
|
||||
warehouseId: draft.warehouseId,
|
||||
yardId: draft.yardId,
|
||||
zoneId: draft.zoneId,
|
||||
}));
|
||||
|
||||
if (!readyBySchedule[train.scheduleId] || assignments.length === 0) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Assign locations',
|
||||
description: 'Select warehouse, yard and zone for each pending booking before unloading.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFullyUnloaded(train)) {
|
||||
toast({
|
||||
title: 'Already unloaded',
|
||||
@@ -127,7 +255,9 @@ export default function ArrivalQueuePage() {
|
||||
|
||||
setBusyScheduleId(train.scheduleId);
|
||||
try {
|
||||
const res = (await autoUnload.mutateAsync(train.scheduleId)) as { data: AutoUnloadArrivedResult };
|
||||
const res = (await autoUnload.mutateAsync({ scheduleId: train.scheduleId, assignments })) as {
|
||||
data: AutoUnloadArrivedResult;
|
||||
};
|
||||
const result = res.data;
|
||||
const alreadyUnloaded = result.unloadedCount === 0 && result.skippedCount > 0 && result.failedCount === 0;
|
||||
const firstReason = result.results.find((item) => item.reason)?.reason;
|
||||
@@ -169,10 +299,12 @@ export default function ArrivalQueuePage() {
|
||||
|
||||
<Card withBorder radius="md" padding="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train to review assigned bookings, then auto unload it.
|
||||
</Text>
|
||||
<Stack gap={2}>
|
||||
<Text fw={600}>{trains.length} arrived import train(s)</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Open a train, assign each booking to a warehouse yard and zone, then unload it.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -254,7 +386,7 @@ export default function ArrivalQueuePage() {
|
||||
color={fullyUnloaded ? 'gray' : 'orange'}
|
||||
leftSection={busyScheduleId === train.scheduleId ? <PackageOpen size={14} /> : <Truck size={14} />}
|
||||
loading={busyScheduleId === train.scheduleId}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0}
|
||||
disabled={fullyUnloaded || train.totalBookings === 0 || !readyBySchedule[train.scheduleId] || warehousesLoading}
|
||||
onClick={() => unloadTrain(train)}
|
||||
>
|
||||
{fullyUnloaded ? 'Already Unloaded' : 'Auto Unload'}
|
||||
@@ -265,7 +397,27 @@ export default function ArrivalQueuePage() {
|
||||
{isOpen && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={10} bg="var(--mantine-color-gray-0)">
|
||||
<ImportTrainDetailRows scheduleId={train.scheduleId} />
|
||||
<ImportTrainDetailRows
|
||||
scheduleId={train.scheduleId}
|
||||
warehouses={warehouses}
|
||||
yards={yards}
|
||||
zones={zones}
|
||||
assignments={assignmentsBySchedule[train.scheduleId] ?? {}}
|
||||
onAssignmentChange={(bookingId, draft) =>
|
||||
setAssignmentsBySchedule((current) => ({
|
||||
...current,
|
||||
[train.scheduleId]: {
|
||||
...(current[train.scheduleId] ?? {}),
|
||||
[bookingId]: draft.warehouseId
|
||||
? draft
|
||||
: {},
|
||||
},
|
||||
}))
|
||||
}
|
||||
onReadyChange={(ready) =>
|
||||
setReadyBySchedule((current) => ({ ...current, [train.scheduleId]: ready }))
|
||||
}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
|
||||
@@ -945,12 +945,19 @@ export const api = {
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
),
|
||||
|
||||
autoUnloadArrivedBookings: endpoint<string, AutoUnloadArrivedResult>(
|
||||
autoUnloadArrivedBookings: endpoint<
|
||||
{
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
},
|
||||
AutoUnloadArrivedResult
|
||||
>(
|
||||
"warehouse-inventory",
|
||||
"auto-unload-arrived-bookings",
|
||||
(scheduleId) =>
|
||||
({ scheduleId, warehouseId, assignments }) =>
|
||||
warehouseService
|
||||
.autoUnloadArrivedBookings(scheduleId)
|
||||
.autoUnloadArrivedBookings({ scheduleId, warehouseId, assignments })
|
||||
.then((r) => r.data),
|
||||
undefined,
|
||||
() => INVENTORY_INVALIDATIONS,
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface Vehicle {
|
||||
capacity: number;
|
||||
status: VehicleStatus;
|
||||
description?: string | null;
|
||||
assignedDriverId?: string | null;
|
||||
assignedDriverName?: string | null;
|
||||
code?: string | null;
|
||||
powerPlateNo?: string | null;
|
||||
trailerPlateNo?: string | null;
|
||||
|
||||
@@ -173,10 +173,14 @@ export const warehouseService = {
|
||||
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
|
||||
importTrainItems: (scheduleId: string) =>
|
||||
apiClient.get<ImportTrainItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)),
|
||||
autoUnloadArrivedBookings: (scheduleId: string) =>
|
||||
autoUnloadArrivedBookings: (payload: {
|
||||
scheduleId: string;
|
||||
warehouseId?: string;
|
||||
assignments?: { bookingId: string; warehouseId: string; yardId: string; zoneId: string }[];
|
||||
}) =>
|
||||
apiClient.post<AutoUnloadArrivedResult>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED,
|
||||
{ scheduleId },
|
||||
payload,
|
||||
),
|
||||
importUnloadedQueue: () =>
|
||||
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE),
|
||||
|
||||
@@ -605,6 +605,7 @@ export interface ImportTrainItem {
|
||||
wagonNumber: string | null;
|
||||
sequenceNo: number | null;
|
||||
allocatedWeightTons: number | null;
|
||||
freightType: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
|
||||
@@ -83,10 +83,15 @@ export function ReadonlyBookingView({
|
||||
const canApproveDelivery =
|
||||
status === "COMPLETED" ||
|
||||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
|
||||
const usesCustomerTruck =
|
||||
booking.tradeDirection === "IMPORT"
|
||||
? !booking.lastMileDeliveryAddress
|
||||
: booking.tradeDirection === "EXPORT"
|
||||
? !booking.firstMilePickupAddress
|
||||
: !booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress;
|
||||
const canAssignCustomerTruck =
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
booking.paymentStatus === "PAID" &&
|
||||
!booking.lastMileDeliveryAddress &&
|
||||
usesCustomerTruck &&
|
||||
["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status);
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
|
||||
@@ -21,6 +21,17 @@ const errorMessage = (error: unknown) => {
|
||||
return error instanceof Error ? error.message : "Could not approve delivery";
|
||||
};
|
||||
|
||||
const downloadBlob = (blob: Blob, filename: string) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export function ApproveDeliveryButton({
|
||||
bookingId,
|
||||
stopPropagation,
|
||||
@@ -32,10 +43,19 @@ export function ApproveDeliveryButton({
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const handoverMutation = useMutation(api.bookings.downloadHandoverDocument.mutationOptions());
|
||||
|
||||
const mutation = useMutation({
|
||||
...api.bookings.approveDelivery.mutationOptions(),
|
||||
onSuccess: async () => {
|
||||
toast.success("Delivery approved and handover signed");
|
||||
onSuccess: async (result) => {
|
||||
try {
|
||||
const blob = await handoverMutation.mutateAsync({ inventoryId: result.inventoryId });
|
||||
downloadBlob(blob, `handover-${bookingId}.pdf`);
|
||||
toast.success("Delivery approved and signed handover downloaded");
|
||||
} catch {
|
||||
toast.success("Delivery approved and handover signed");
|
||||
toast.error("Signed handover document could not be downloaded");
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.get.queryKey({ id: bookingId }) }),
|
||||
queryClient.invalidateQueries({ queryKey: api.bookings.list.queryKey() }),
|
||||
@@ -48,6 +68,8 @@ export function ApproveDeliveryButton({
|
||||
toast.error(message);
|
||||
if (message.toLowerCase().includes("save your signature")) {
|
||||
navigate("/signature");
|
||||
} else if (/demurrage|storage|warehouse invoice|fully paid/i.test(message)) {
|
||||
navigate("/billing");
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -64,7 +86,7 @@ export function ApproveDeliveryButton({
|
||||
variant={variant}
|
||||
color="edr-green"
|
||||
leftSection={<CheckCircle2 size={16} />}
|
||||
loading={mutation.isPending}
|
||||
loading={mutation.isPending || handoverMutation.isPending}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Approve delivery
|
||||
|
||||
@@ -224,6 +224,12 @@ export const api = {
|
||||
({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id),
|
||||
),
|
||||
|
||||
downloadHandoverDocument: endpoint<{ inventoryId: string }, Blob>(
|
||||
"bookings",
|
||||
"downloadHandoverDocument",
|
||||
({ inventoryId }) => bookingsService.downloadHandoverDocument(inventoryId),
|
||||
),
|
||||
|
||||
create: endpoint<
|
||||
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||
Freight.IBooking
|
||||
|
||||
@@ -135,6 +135,13 @@ export const bookingsService = {
|
||||
);
|
||||
return data;
|
||||
},
|
||||
downloadHandoverDocument: async (inventoryId: string): Promise<Blob> => {
|
||||
const { data } = await client.get(
|
||||
`/api/warehouse-inventory/${inventoryId}/handover-document`,
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return data;
|
||||
},
|
||||
tracking: async (id: string): Promise<Freight.IBookingTracking> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
|
||||
Reference in New Issue
Block a user