Truck Assign by customer plus Handover signature on portal

This commit is contained in:
hagiye
2026-07-02 12:32:18 +03:00
parent 129448e437
commit ad9f9de7a8
19 changed files with 1149 additions and 49 deletions

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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",

View File

@@ -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')

View File

@@ -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;

View File

@@ -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);
});

View File

@@ -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>
)}

View File

@@ -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);

View File

@@ -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) {

View File

@@ -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));
};

View File

@@ -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>
)}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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),

View File

@@ -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;

View File

@@ -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";

View File

@@ -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

View File

@@ -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

View File

@@ -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;