From ad9f9de7a84778629a57524d221a40750ca37681 Mon Sep 17 00:00:00 2001 From: hagiye Date: Thu, 2 Jul 2026 12:32:18 +0300 Subject: [PATCH] Truck Assign by customer plus Handover signature on portal --- apps/edr-freight-api/package.json | 1 + .../src/modules/bookings/bookings.service.ts | 12 +- .../warehouses/scheduling-read.facade.ts | 2 + .../warehouse-inventory.controller.ts | 14 +- .../warehouses/warehouse-inventory.service.ts | 84 ++- .../scripts/seed-gate-pass-train-scenarios.ts | 627 ++++++++++++++++++ .../warehouses/ReceiveInventoryModal.tsx | 164 ++++- .../warehouses/ReleaseOrderModal.tsx | 8 +- .../backoffice/src/hooks/useWarehouses.ts | 8 +- .../src/pages/operations/LastMilePage.tsx | 28 +- .../src/pages/warehouses/ArrivalQueuePage.tsx | 176 ++++- .../backoffice/src/services/api.ts | 13 +- .../src/services/vehicles.service.ts | 2 + .../src/services/warehouse.service.ts | 8 +- .../backoffice/src/types/warehouse.ts | 1 + .../BookingDetailPage/ReadonlyBookingView.tsx | 9 +- .../delivery/ApproveDeliveryButton.tsx | 28 +- .../portal/src/services/api.ts | 6 + .../portal/src/services/bookings.service.ts | 7 + 19 files changed, 1149 insertions(+), 49 deletions(-) create mode 100644 apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts diff --git a/apps/edr-freight-api/package.json b/apps/edr-freight-api/package.json index 9edf388b9..148dfaef7 100644 --- a/apps/edr-freight-api/package.json +++ b/apps/edr-freight-api/package.json @@ -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", diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 3b146ca44..7fdc483c5 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -91,9 +91,17 @@ export class BookingsService { dto: CustomerTruckAssignmentDto, ): Promise { 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) { diff --git a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts index c1faeed22..5fcf59dd5 100644 --- a/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts +++ b/apps/edr-freight-api/src/modules/warehouses/scheduling-read.facade.ts @@ -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", diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index 6b2bd8c28..6b30dad1e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -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') diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index 3f55a8d61..11f34c892 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { + /** First matching warehouse that has at least one yard + zone (fallback location for auto-unload). */ + private async pickDefaultLocation(warehouseId?: string): Promise { + 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 { 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; diff --git a/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts new file mode 100644 index 000000000..a57ce84c7 --- /dev/null +++ b/apps/edr-freight-api/src/scripts/seed-gate-pass-train-scenarios.ts @@ -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 { + 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 { + 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>, 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 { + 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 { + 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>, departure: Date, now: Date, scheduleId: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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>, bookingId: string, containerId: string, weightTons: number, isArrived: boolean, now: Date): Promise { + 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 { + 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); +}); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index c2cd106e6..32b0b5a5d 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -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>; + 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; + 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 }) { Cargo Type Weight Arrival + Warehouse + Yard + Zone Inspection Current Status Last Mile @@ -1785,7 +1839,18 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) { - {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 ( @@ -1806,6 +1871,41 @@ function ImportTrainDetailTable({ train }: { train: ImportTrain }) { {it.cargoType ?? '—'} {formatNumber(Number(it.weight))} {formatDate(it.arrivalTime)} + + + onAssignmentChange(it.bookingId, { ...draft, yardId: value ?? undefined, zoneId: undefined }) + } + searchable + disabled={!pending || !draft.warehouseId} + w={190} + /> + + + onAssignmentChange(item.bookingId, { warehouseId: value ?? undefined })} + searchable + disabled={!pending} + w={210} + /> + + + onAssignmentChange(item.bookingId, { ...draft, zoneId: value ?? undefined })} + searchable + disabled={!pending || !draft.yardId} + w={190} + /> + {item.inspectionStatus ?? 'Not inspected'} @@ -102,7 +204,8 @@ function ImportTrainDetailRows({ scheduleId }: { scheduleId: string }) { {item.pickupOption.replace(/_/g, ' ')} - ))} + ); + })} ); @@ -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(null); const [busyScheduleId, setBusyScheduleId] = useState(null); + const [assignmentsBySchedule, setAssignmentsBySchedule] = useState>>({}); + const [readyBySchedule, setReadyBySchedule] = useState>({}); const unloadTrain = async (train: ImportTrain) => { + const assignments = Object.entries(assignmentsBySchedule[train.scheduleId] ?? {}) + .filter((entry): entry is [string, Required] => + 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() { - {trains.length} arrived import train(s) - - Open a train to review assigned bookings, then auto unload it. - + + {trains.length} arrived import train(s) + + Open a train, assign each booking to a warehouse yard and zone, then unload it. + + {isLoading ? ( @@ -254,7 +386,7 @@ export default function ArrivalQueuePage() { color={fullyUnloaded ? 'gray' : 'orange'} leftSection={busyScheduleId === train.scheduleId ? : } 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 && ( - + + setAssignmentsBySchedule((current) => ({ + ...current, + [train.scheduleId]: { + ...(current[train.scheduleId] ?? {}), + [bookingId]: draft.warehouseId + ? draft + : {}, + }, + })) + } + onReadyChange={(ready) => + setReadyBySchedule((current) => ({ ...current, [train.scheduleId]: ready })) + } + /> )} diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 59f956d2f..0df579457 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -945,12 +945,19 @@ export const api = { () => INVENTORY_INVALIDATIONS, ), - autoUnloadArrivedBookings: endpoint( + 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, diff --git a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts index b9d5129e3..d3805df1a 100644 --- a/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/vehicles.service.ts @@ -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; diff --git a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts index 5fabb47c4..e8e2fac88 100644 --- a/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/warehouse.service.ts @@ -173,10 +173,14 @@ export const warehouseService = { apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE), importTrainItems: (scheduleId: string) => apiClient.get(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( URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED, - { scheduleId }, + payload, ), importUnloadedQueue: () => apiClient.get(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE), diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 5af9982fa..fdaeee556 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -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; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 327914cd6..797f0e993 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -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"; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx index 0f0ded4ef..e9ee31722 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/delivery/ApproveDeliveryButton.tsx @@ -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={} - loading={mutation.isPending} + loading={mutation.isPending || handoverMutation.isPending} onClick={handleClick} > Approve delivery diff --git a/apps/edr-freight-web/portal/src/services/api.ts b/apps/edr-freight-web/portal/src/services/api.ts index 2421192e5..304ceaf89 100644 --- a/apps/edr-freight-web/portal/src/services/api.ts +++ b/apps/edr-freight-web/portal/src/services/api.ts @@ -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 diff --git a/apps/edr-freight-web/portal/src/services/bookings.service.ts b/apps/edr-freight-web/portal/src/services/bookings.service.ts index bbefa5478..a7f23e1eb 100644 --- a/apps/edr-freight-web/portal/src/services/bookings.service.ts +++ b/apps/edr-freight-web/portal/src/services/bookings.service.ts @@ -135,6 +135,13 @@ export const bookingsService = { ); return data; }, + downloadHandoverDocument: async (inventoryId: string): Promise => { + const { data } = await client.get( + `/api/warehouse-inventory/${inventoryId}/handover-document`, + { responseType: "blob" }, + ); + return data; + }, tracking: async (id: string): Promise => { const { data } = await client.get(`/api/bookings/${id}/tracking`); return data.data;