mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 10:53:40 +00:00
Merge pull request #1478 from Tria-plc/Emty-container-return
Emty container return
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { usesEdrMileService } from './mile-haulage.util';
|
||||
import { edrHaulsThisBooking, usesEdrMileService } from './mile-haulage.util';
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract and copied onto the booking. EDR
|
||||
@@ -26,27 +26,76 @@ describe('usesEdrMileService', () => {
|
||||
});
|
||||
|
||||
it('an export that chose collection uses EDR haulage', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' })),
|
||||
).toBe(true);
|
||||
expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores the delivery address on an export — delivery is the import leg', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' })),
|
||||
).toBe(false);
|
||||
expect(usesEdrMileService(booking({ tradeDirection: 'EXPORT', lastMile: 'Djibouti' }))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('a domestic booking counts either leg', () => {
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' })),
|
||||
).toBe(true);
|
||||
expect(
|
||||
usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
|
||||
).toBe(true);
|
||||
expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(usesEdrMileService(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a whitespace-only address as no choice', () => {
|
||||
expect(usesEdrMileService(booking({ lastMile: ' ' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Self-haul is closed only once EDR has committed to the leg. Delivery chosen
|
||||
* on the contract is a request the chief still has to approve; collection has
|
||||
* no approval step.
|
||||
*/
|
||||
describe('edrHaulsThisBooking', () => {
|
||||
const booking = (over: Partial<Parameters<typeof edrHaulsThisBooking>[0]> = {}) => ({
|
||||
tradeDirection: 'IMPORT',
|
||||
firstMile: null,
|
||||
lastMile: null,
|
||||
lastMileCommitted: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
it('an import whose last-mile request is not yet approved may still self-haul', () => {
|
||||
expect(edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa' }))).toBe(false);
|
||||
});
|
||||
|
||||
it('an import whose last-mile request was approved is hauled by EDR', () => {
|
||||
expect(
|
||||
edrHaulsThisBooking(booking({ lastMile: 'Bole, Addis Ababa', lastMileCommitted: true })),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('an import that chose no delivery self-hauls, whatever the leg tables say', () => {
|
||||
expect(edrHaulsThisBooking(booking({ lastMileCommitted: true }))).toBe(false);
|
||||
});
|
||||
|
||||
it('an export that chose collection is hauled by EDR — no approval step on that leg', () => {
|
||||
expect(edrHaulsThisBooking(booking({ tradeDirection: 'EXPORT', firstMile: 'Modjo' }))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('a domestic booking is blocked by collection, or by an approved delivery', () => {
|
||||
expect(edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', firstMile: 'Adama' }))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
edrHaulsThisBooking(booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa' })),
|
||||
).toBe(false);
|
||||
expect(
|
||||
edrHaulsThisBooking(
|
||||
booking({ tradeDirection: 'DOMESTIC', lastMile: 'Dire Dawa', lastMileCommitted: true }),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,7 +39,60 @@ export const SELF_HAUL_CONFLICT_MESSAGE =
|
||||
'This booking is delivered by the customer’s own truck — an EDR mile leg cannot also be assigned.';
|
||||
|
||||
export const EDR_HAULAGE_CONFLICT_MESSAGE =
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected';
|
||||
'Customer truck assignment is only allowed when first/last mile delivery is not selected, or when the EDR last-mile request has not been approved';
|
||||
|
||||
/** The booking fields that decide whether the customer may still bring their own truck. */
|
||||
export interface MileCommitmentRow extends MileHaulageRow {
|
||||
/**
|
||||
* EDR has actually committed to the delivery leg: the booking's last-mile
|
||||
* request was approved, or a `freight.last_mile` leg row exists for it.
|
||||
* Selecting delivery on the contract is only a request — see
|
||||
* `edrHaulsThisBooking`.
|
||||
*/
|
||||
lastMileCommitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL for `MileCommitmentRow.lastMileCommitted`, to be selected alongside the
|
||||
* booking row aliased `b`. Both services that gate self-haul read the same
|
||||
* fragment so the rule cannot drift between them.
|
||||
*/
|
||||
export const LAST_MILE_COMMITTED_SQL = `(
|
||||
EXISTS (SELECT 1
|
||||
FROM freight.last_mile lm
|
||||
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL)
|
||||
OR EXISTS (SELECT 1
|
||||
FROM freight.last_mile_requests lmr
|
||||
WHERE lmr.booking_id = b.id
|
||||
AND lmr.deleted_at IS NULL
|
||||
AND lmr.status = 'APPROVED')
|
||||
)`;
|
||||
|
||||
/**
|
||||
* Whether EDR is hauling this booking's road leg, such that the customer may
|
||||
* NOT assign their own truck. Stricter than `usesEdrMileService` on the
|
||||
* delivery side: choosing last-mile delivery on the contract opens a request
|
||||
* that the Truck & Machinery chief still has to approve, and until that
|
||||
* approval the customer is free to self-haul instead. Collection (the export
|
||||
* leg) has no approval step, so the contract choice alone decides it.
|
||||
*
|
||||
* `usesEdrMileService` keeps answering the other question — whether the booking
|
||||
* belongs in the EDR mile queues at all — and the queue side still refuses a
|
||||
* booking that already carries a customer truck, so the two paths remain
|
||||
* mutually exclusive whichever acts first.
|
||||
*/
|
||||
export function edrHaulsThisBooking(booking: MileCommitmentRow): boolean {
|
||||
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||
const lastMileApproved = Boolean(booking.lastMile?.trim()) && booking.lastMileCommitted;
|
||||
switch (booking.tradeDirection) {
|
||||
case 'IMPORT':
|
||||
return lastMileApproved;
|
||||
case 'EXPORT':
|
||||
return hasFirstMile;
|
||||
default:
|
||||
return hasFirstMile || lastMileApproved;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The road legs are chosen on the contract. A booking whose contract bought
|
||||
|
||||
@@ -86,6 +86,7 @@ import {
|
||||
import { ContractViewDto } from "./dto/contract-view.dto";
|
||||
import { CustomerTruckAssignmentDto } from "./dto/customer-truck-assignment.dto";
|
||||
import { AddCustomerTruckDto } from "./dto/add-customer-truck.dto";
|
||||
import { BulkCustomerTrucksDto } from "./dto/bulk-customer-truck.dto";
|
||||
import { DepartCustomerTruckDto } from "./dto/depart-customer-truck.dto";
|
||||
import { LoadCustomerTruckDto } from "./dto/load-customer-truck.dto";
|
||||
import { CustomerTruckService } from "./customer-truck.service";
|
||||
@@ -848,7 +849,7 @@ export class BookingsController {
|
||||
})
|
||||
async bulkAddCustomerTrucks(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() payload: { trucks: AddCustomerTruckDto[] },
|
||||
@Body() payload: BulkCustomerTrucksDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
|
||||
@@ -29,6 +29,11 @@ import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import {
|
||||
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||
LAST_MILE_COMMITTED_SQL,
|
||||
edrHaulsThisBooking,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -174,18 +179,23 @@ export class BookingsService {
|
||||
dto: CustomerTruckAssignmentDto,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.findById(bookingId);
|
||||
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 first/last mile delivery is not selected',
|
||||
);
|
||||
// Same rule as CustomerTruckService.assertSelfHaulPaid: an EDR delivery leg
|
||||
// closes self-haul only once it has been approved.
|
||||
const [commitment]: Array<{ lastMileCommitted: boolean }> = await this.dataSource.query(
|
||||
`SELECT ${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (
|
||||
edrHaulsThisBooking({
|
||||
tradeDirection: booking.tradeDirection ?? null,
|
||||
firstMile: booking.firstMilePickupAddress ?? null,
|
||||
lastMile: booking.lastMileDeliveryAddress ?? null,
|
||||
lastMileCommitted: Boolean(commitment?.lastMileCommitted),
|
||||
})
|
||||
) {
|
||||
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||
}
|
||||
if (booking.customerTruckAssignedAt) {
|
||||
throw new ConflictException('Customer truck assignment is already submitted and locked');
|
||||
|
||||
@@ -9,12 +9,17 @@ import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
|
||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import type {
|
||||
BulkTruckUploadError,
|
||||
BulkTruckUploadResult,
|
||||
} from './dto/bulk-customer-truck.dto';
|
||||
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import {
|
||||
EDR_HAULAGE_CONFLICT_MESSAGE,
|
||||
usesEdrMileService,
|
||||
LAST_MILE_COMMITTED_SQL,
|
||||
edrHaulsThisBooking,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
@@ -36,6 +41,8 @@ interface BookingGuardRow {
|
||||
paymentStatus: string | null;
|
||||
status: string | null;
|
||||
trainScheduleStatus: string | null;
|
||||
/** See `MileCommitmentRow` — an approved EDR last-mile leg closes self-haul. */
|
||||
lastMileCommitted: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -547,7 +554,8 @@ export class CustomerTruckService {
|
||||
ON ts.id = tsb.train_schedule_id AND ts.deleted_at IS NULL
|
||||
WHERE tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
ORDER BY ts.updated_at DESC
|
||||
LIMIT 1) AS "trainScheduleStatus"
|
||||
LIMIT 1) AS "trainScheduleStatus",
|
||||
${LAST_MILE_COMMITTED_SQL} AS "lastMileCommitted"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
@@ -557,10 +565,12 @@ export class CustomerTruckService {
|
||||
}
|
||||
|
||||
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
||||
// Shared with the EDR side (LastMileService.assertNoCustomerTruck) so the two
|
||||
// halves of this rule cannot drift apart — they did, and a booking ended up
|
||||
// with a customer truck and an EDR leg at once.
|
||||
if (usesEdrMileService(booking)) {
|
||||
// Mirrors the EDR side (LastMileService.assertEdrHaulsThisBooking) so the
|
||||
// two halves of this rule cannot drift apart — they did, and a booking ended
|
||||
// up with a customer truck and an EDR leg at once. A last-mile leg only
|
||||
// blocks self-haul once it is approved; until then the customer may still
|
||||
// bring their own truck, and doing so makes the pending request unapprovable.
|
||||
if (edrHaulsThisBooking(booking)) {
|
||||
throw new BadRequestException(EDR_HAULAGE_CONFLICT_MESSAGE);
|
||||
}
|
||||
if (booking.paymentStatus !== 'PAID') {
|
||||
@@ -631,26 +641,29 @@ export class CustomerTruckService {
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given container numbers. */
|
||||
|
||||
/**
|
||||
* Add trucks one at a time, keeping the good ones. Partial success is the
|
||||
* right shape here: one mistyped plate in a twenty-row spreadsheet should not
|
||||
* discard the other nineteen trucks. Every row still goes through `addTruck`,
|
||||
* so no guard is skipped.
|
||||
*/
|
||||
async addBulkTrucks(
|
||||
bookingId: string,
|
||||
dtos: AddCustomerTruckDto[],
|
||||
): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ row: number; truck: string; reason: string }>;
|
||||
}> {
|
||||
const errors: Array<{ row: number; truck: string; reason: string }> = [];
|
||||
): Promise<BulkTruckUploadResult> {
|
||||
const errors: BulkTruckUploadError[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
for (let i = 0; i < dtos.length; i++) {
|
||||
try {
|
||||
await this.addTruck(bookingId, dtos[i]);
|
||||
successCount++;
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
errors.push({
|
||||
row: i + 2, // Row 1 is header
|
||||
index: i,
|
||||
row: i + 2, // Row 1 is the header
|
||||
truck: dtos[i].truckPlateNumber,
|
||||
reason: err.message || 'Unknown error',
|
||||
reason: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
|
||||
|
||||
/**
|
||||
* Add one external customer truck to a booking.
|
||||
@@ -41,7 +41,7 @@ export class AddCustomerTruckDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
@Matches(ISO_CONTAINER_NUMBER, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
|
||||
@@ -1,48 +1,41 @@
|
||||
import { IsString, IsNotEmpty, IsIn, IsArray, ArrayMaxSize, ArrayUnique, Matches, IsOptional } from 'class-validator';
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
import { ArrayMaxSize, ArrayMinSize, IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class BulkCustomerTruckRow {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
truckPlateNumber!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
driverName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
each: true,
|
||||
message: 'each container must be ISO format (e.g. ABCD1234567)',
|
||||
})
|
||||
containerNumbers?: (string | null)[];
|
||||
}
|
||||
import { AddCustomerTruckDto } from './add-customer-truck.dto';
|
||||
|
||||
/**
|
||||
* Bulk self-haul truck assignment, parsed from the customer's Excel upload in
|
||||
* the browser and posted as JSON (the house pattern — the API never receives an
|
||||
* .xlsx for import).
|
||||
*
|
||||
* Rows reuse `AddCustomerTruckDto` verbatim rather than redeclaring the fields:
|
||||
* the earlier copy drifted, missing `plannedTons` / `plannedQuantity`, so bulk
|
||||
* cargo could not be uploaded at all.
|
||||
*/
|
||||
export class BulkCustomerTrucksDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(100)
|
||||
trucks!: BulkCustomerTruckRow[];
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AddCustomerTruckDto)
|
||||
trucks!: AddCustomerTruckDto[];
|
||||
}
|
||||
|
||||
export interface BulkTruckUploadError {
|
||||
/**
|
||||
* Position in the submitted array. The client knows which spreadsheet line it
|
||||
* read each entry from, so it maps this back to the row number the customer
|
||||
* actually sees.
|
||||
*/
|
||||
index: number;
|
||||
/** 1-based row assuming a single header line — a fallback for non-Excel callers. */
|
||||
row: number;
|
||||
truck: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface BulkTruckUploadResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{
|
||||
row: number;
|
||||
truck: string;
|
||||
reason: string;
|
||||
}>;
|
||||
created: Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
containers: number;
|
||||
}>;
|
||||
errors: BulkTruckUploadError[];
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
|
||||
import { CUSTOMER_TRUCK_TYPES, ISO_CONTAINER_NUMBER } from '@edr/types';
|
||||
|
||||
export const CUSTOMER_TRUCK_TYPES = [
|
||||
'Flatbed',
|
||||
'Container Chassis',
|
||||
'Lowboy',
|
||||
'Box Truck',
|
||||
'Tipper',
|
||||
] as const;
|
||||
/**
|
||||
* Re-exported for the DTOs that already import it from here. The list itself
|
||||
* lives in `@edr/types` so the portal's dropdown and its Excel template read the
|
||||
* same values this validator enforces.
|
||||
*/
|
||||
export { CUSTOMER_TRUCK_TYPES };
|
||||
|
||||
export class CustomerTruckAssignmentDto {
|
||||
@IsString()
|
||||
@@ -27,7 +27,7 @@ export class CustomerTruckAssignmentDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(16)
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
@Matches(ISO_CONTAINER_NUMBER, {
|
||||
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
containerNumberToLoad!: string;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Matches,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
import { ISO_CONTAINER_NUMBER } from '@edr/types';
|
||||
|
||||
/**
|
||||
* Register an import self-haul truck leaving the port: the containers it actually
|
||||
@@ -20,7 +21,7 @@ export class DepartCustomerTruckDto {
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
@Matches(ISO_CONTAINER_NUMBER, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrayMaxSize, ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
|
||||
import { ISO_CONTAINER_NUMBER } from '@edr/types';
|
||||
|
||||
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
|
||||
export class LoadCustomerTruckDto {
|
||||
@@ -7,7 +8,7 @@ export class LoadCustomerTruckDto {
|
||||
// A truck carries at most 2 containers (two 20ft, or one 40ft).
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||
@Matches(ISO_CONTAINER_NUMBER, {
|
||||
each: true,
|
||||
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||
})
|
||||
|
||||
@@ -16,6 +16,17 @@ import {
|
||||
import { EmptyReturnRequestsService } from './empty-return-requests.service';
|
||||
import type { EmptyReturnRequestStatus } from './entities/empty-return-request.entity';
|
||||
|
||||
/**
|
||||
* Reading the queue is OR'd with the warehouse-inventory key the rest of the
|
||||
* Imports menu uses, so the staff who already run container returns can open
|
||||
* it while the dedicated key is still being handed out. Approving and
|
||||
* rejecting stay on the review key alone — that one is a commercial decision.
|
||||
*/
|
||||
const CAN_VIEW = [
|
||||
FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
];
|
||||
|
||||
@ApiTags('empty-return-requests')
|
||||
@ApiBearerAuth()
|
||||
@Controller('empty-return-requests')
|
||||
@@ -23,7 +34,7 @@ export class EmptyReturnRequestsController {
|
||||
constructor(private readonly service: EmptyReturnRequestsService) {}
|
||||
|
||||
@Get()
|
||||
@BookingStaff(FREIGHT_PERMS.emptyReturnRequests.view)
|
||||
@BookingStaff(CAN_VIEW)
|
||||
@ApiOperation({ summary: 'Empty container return requests queue' })
|
||||
findAll(@Query('status') status?: string, @Query('bookingId') bookingId?: string) {
|
||||
return this.service.findAll({
|
||||
@@ -42,7 +53,7 @@ export class EmptyReturnRequestsController {
|
||||
}
|
||||
|
||||
@Get('eligibility/:bookingId')
|
||||
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Whether a booking may request an empty return, its free containers, and the price per container',
|
||||
@@ -55,14 +66,14 @@ export class EmptyReturnRequestsController {
|
||||
}
|
||||
|
||||
@Get('by-booking/:bookingId')
|
||||
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({ summary: "A booking's empty return requests, newest first" })
|
||||
findForBooking(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.service.findForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@MixedAudience(FREIGHT_PERMS.emptyReturnRequests.view)
|
||||
@MixedAudience(CAN_VIEW)
|
||||
@ApiOperation({ summary: 'Get an empty return request by ID' })
|
||||
findOne(@Param('id', ParseUUIDPipe) id: string, @CurrentUser() user: TCurrentUser) {
|
||||
return this.service.findById(id, this.portalUserId(user));
|
||||
|
||||
@@ -714,7 +714,10 @@ const App = () => {
|
||||
path="empty-return-requests"
|
||||
element={
|
||||
<RequirePermission
|
||||
permission={FREIGHT_PERMS.emptyReturnRequests.view}
|
||||
permission={[
|
||||
FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
]}
|
||||
>
|
||||
<EmptyReturnRequestsPage />
|
||||
</RequirePermission>
|
||||
|
||||
@@ -372,7 +372,13 @@ export const buildSidebarSections = (
|
||||
label: "Empty Return Requests",
|
||||
href: "/dashboard/empty-return-requests",
|
||||
icon: <Undo2 />,
|
||||
permission: FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
// Visible to whoever already runs container returns, OR'd with
|
||||
// the queue's own key — so the dedicated permission can be handed
|
||||
// out per position later without the menu disappearing now.
|
||||
permission: [
|
||||
FREIGHT_PERMS.emptyReturnRequests.view,
|
||||
FREIGHT_PERMS.warehouseInventory.view,
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Register Full Containers",
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from "@mantine/core";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
import ListControls from "@/components/common/ListControls";
|
||||
import { extractErrorMessage } from "@/components/warehouses/options";
|
||||
@@ -55,6 +57,11 @@ const money = (amount: number | null | undefined, currency: string | null | unde
|
||||
export default function EmptyReturnRequestsPage() {
|
||||
const { toast } = useToast();
|
||||
const qc = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
// Anyone who runs container returns can watch the queue; pricing and
|
||||
// approving is its own permission, so show the buttons disabled rather than
|
||||
// letting them fire into a 403.
|
||||
const canReview = hasPermission(user, FREIGHT_PERMS.emptyReturnRequests.review);
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const [approving, setApproving] = useState<EmptyReturnRequest | null>(null);
|
||||
const [rejecting, setRejecting] = useState<EmptyReturnRequest | null>(null);
|
||||
@@ -204,10 +211,23 @@ export default function EmptyReturnRequestsPage() {
|
||||
cell: ({ row }) =>
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button size="xs" variant="subtle" color="red" onClick={() => setRejecting(row.original)}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={!canReview}
|
||||
title={canReview ? undefined : "You do not have permission to review these requests"}
|
||||
onClick={() => setRejecting(row.original)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
<Button size="xs" variant="light" onClick={() => setApproving(row.original)}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
disabled={!canReview}
|
||||
title={canReview ? undefined : "You do not have permission to review these requests"}
|
||||
onClick={() => setApproving(row.original)}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { useFileViewer } from "@/hooks/useFileViewer";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { ApproveDeliveryButton } from "../delivery/ApproveDeliveryButton";
|
||||
@@ -182,23 +183,70 @@ export function ReadonlyBookingView({
|
||||
// backend flag counts only unsigned SELF_HAUL handovers, so no status
|
||||
// heuristics are needed here.
|
||||
const canApproveDelivery = Boolean(booking.handoverAwaitingSignature);
|
||||
// Delivery chosen on the contract only opens a last-mile request; EDR is
|
||||
// committed to that leg once the request is approved (or a leg record
|
||||
// exists). Until then the customer may still bring their own truck. Same
|
||||
// rule as the API's `edrHaulsThisBooking`. Collection (the export leg) has
|
||||
// no approval step, so the address alone decides it.
|
||||
const hasLastMileChoice =
|
||||
booking.tradeDirection !== "EXPORT" && !!booking.lastMileDeliveryAddress;
|
||||
const lastMileRequests = useQuery({
|
||||
queryKey: ["booking-last-mile-requests", booking.id],
|
||||
queryFn: () => lastMileRequestsService.listForBooking(booking.id),
|
||||
enabled: hasLastMileChoice,
|
||||
});
|
||||
const mileSummary = useQuery({
|
||||
queryKey: ["booking-mile-summary", booking.id],
|
||||
queryFn: () => bookingsService.mileSummary(booking.id),
|
||||
enabled: hasLastMileChoice,
|
||||
});
|
||||
const lastMileCommitted =
|
||||
hasLastMileChoice &&
|
||||
((lastMileRequests.data ?? []).some((r) => r.status === "APPROVED") ||
|
||||
!!mileSummary.data?.lastMile);
|
||||
const lastMileCheckPending =
|
||||
hasLastMileChoice && (lastMileRequests.isPending || mileSummary.isPending);
|
||||
const usesCustomerTruck =
|
||||
booking.tradeDirection === "IMPORT"
|
||||
? !booking.lastMileDeliveryAddress
|
||||
? !lastMileCommitted
|
||||
: booking.tradeDirection === "EXPORT"
|
||||
? !booking.firstMilePickupAddress
|
||||
: !booking.firstMilePickupAddress && !booking.lastMileDeliveryAddress;
|
||||
const canAssignCustomerTruck =
|
||||
booking.paymentStatus === "PAID" &&
|
||||
usesCustomerTruck &&
|
||||
(booking.tradeDirection === "IMPORT"
|
||||
? // Import self-haul: pickup trucks are assigned only after the train has
|
||||
// arrived at the destination.
|
||||
(status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED")
|
||||
: // Export / domestic self-haul: delivery trucks are assigned only before
|
||||
// the cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
|
||||
// (IN_TRANSIT and beyond) assignment is closed.
|
||||
["PAID", "TRUCK_ASSIGNED"].includes(status));
|
||||
: !booking.firstMilePickupAddress && !lastMileCommitted;
|
||||
// Why truck assignment is closed, or null when it is open. The card renders
|
||||
// either way — a customer who opens Logistics and finds nothing there cannot
|
||||
// tell a missing feature from a stage they have not reached yet.
|
||||
const truckBlockedReason = (() => {
|
||||
if (lastMileCheckPending) {
|
||||
return "Checking whether EDR last-mile delivery has been approved for this booking…";
|
||||
}
|
||||
if (!usesCustomerTruck) {
|
||||
return booking.tradeDirection !== "EXPORT" && lastMileCommitted
|
||||
? "EDR last-mile delivery for this booking has been approved, so no customer truck is needed."
|
||||
: "EDR is handling first-mile pickup for this booking, so no customer truck is needed.";
|
||||
}
|
||||
if (booking.paymentStatus !== "PAID") {
|
||||
return "Truck assignment opens once payment for this booking is confirmed.";
|
||||
}
|
||||
if (booking.tradeDirection === "IMPORT") {
|
||||
// Import self-haul: pickup trucks are assigned only after the train has
|
||||
// arrived at the destination.
|
||||
return status === "ARRIVED" || booking.trainScheduleStatus === "ARRIVED"
|
||||
? null
|
||||
: "Pickup trucks can be assigned once the train arrives at the destination.";
|
||||
}
|
||||
// Export / domestic self-haul: delivery trucks are assigned only before the
|
||||
// cargo is loaded onto the train (PAID / TRUCK_ASSIGNED). Once loaded
|
||||
// (IN_TRANSIT and beyond) assignment is closed.
|
||||
return ["PAID", "TRUCK_ASSIGNED"].includes(status)
|
||||
? null
|
||||
: "The cargo is already loaded onto the train — truck assignment is closed.";
|
||||
})();
|
||||
// The customer asked for EDR delivery and can still self-haul instead — say
|
||||
// so, because assigning a truck here makes that pending request unapprovable.
|
||||
const truckNotice =
|
||||
!truckBlockedReason && hasLastMileChoice && !lastMileCommitted
|
||||
? "You requested EDR last-mile delivery for this booking, and it has not been approved yet. Assigning your own truck here replaces that request — it can no longer be approved once a truck is on the booking."
|
||||
: null;
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
// Customs (Path B) bookings are created AND rebooked by Global Logistics, not
|
||||
@@ -464,14 +512,16 @@ export function ReadonlyBookingView({
|
||||
<BodyGrid
|
||||
left={
|
||||
<>
|
||||
<WarehouseLocationCard bookingId={booking.id} />
|
||||
{/* Truck assignment leads; where the cargo currently sits is
|
||||
supporting detail beneath it. */}
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
blockedReason={truckBlockedReason}
|
||||
notice={truckNotice}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
<WarehouseLocationCard bookingId={booking.id} />
|
||||
|
||||
<MileSummaryCard booking={booking} />
|
||||
</>
|
||||
|
||||
@@ -1,131 +1,237 @@
|
||||
import { useState } from "react";
|
||||
import { Alert, Button, Group, Modal, Stack, Table, Text, FileInput, Badge } from "@mantine/core";
|
||||
import { Upload, Download, AlertCircle, CheckCircle, AlertTriangle } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
FileButton,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { AlertCircle, AlertTriangle, CheckCircle, Download, Upload } from "lucide-react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { client } from "@/utils/api";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
import { generateTruckAssignmentTemplate, parseTruckAssignmentFile } from "@/utils/truck-assignment-template";
|
||||
import {
|
||||
downloadTruckAssignmentTemplate,
|
||||
parseTruckAssignmentFile,
|
||||
truckTemplateContext,
|
||||
type ParsedTruckFile,
|
||||
} from "@/utils/truck-assignment-template";
|
||||
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
|
||||
interface BulkTruckUploadResponse {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ index: number; row: number; truck: string; reason: string }>;
|
||||
}
|
||||
|
||||
interface BulkTruckUploadModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
bookingId: string;
|
||||
booking: BookingDetail;
|
||||
trucks: Freight.ICustomerTruck[];
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
/** Long error lists are unreadable in a modal — show the first few and count the rest. */
|
||||
const MAX_LISTED_ERRORS = 8;
|
||||
|
||||
export function BulkTruckUploadModal({
|
||||
opened,
|
||||
onClose,
|
||||
bookingId,
|
||||
booking,
|
||||
trucks,
|
||||
onSuccess,
|
||||
}: BulkTruckUploadModalProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [parsed, setParsed] = useState<
|
||||
Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumbers?: string[];
|
||||
}>
|
||||
>([]);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
// `resetRef` lets the customer re-pick a corrected file with the same name —
|
||||
// without it the input's onChange never fires the second time.
|
||||
const resetFile = useRef<() => void>(null);
|
||||
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [parsed, setParsed] = useState<ParsedTruckFile>({ rows: [], errors: [], rowNumbers: [] });
|
||||
const [result, setResult] = useState<BulkTruckUploadResponse | null>(null);
|
||||
|
||||
const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]);
|
||||
|
||||
const reset = () => {
|
||||
resetFile.current?.();
|
||||
setFileName(null);
|
||||
setParsed({ rows: [], errors: [], rowNumbers: [] });
|
||||
setResult(null);
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(bookingId), {
|
||||
trucks: parsed,
|
||||
});
|
||||
return data;
|
||||
const { data } = await client.post<BulkTruckUploadResponse | { data: BulkTruckUploadResponse }>(
|
||||
URL_CONSTANTS.BOOKINGS.CUSTOMER_TRUCKS_BULK(booking.id),
|
||||
{ trucks: parsed.rows },
|
||||
);
|
||||
return ("data" in data ? data.data : data) as BulkTruckUploadResponse;
|
||||
},
|
||||
onSuccess: () => {
|
||||
onSuccess: (response) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["customer-trucks", booking.id] });
|
||||
onSuccess?.();
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
onClose();
|
||||
// Only a clean run closes. A partial failure has to be shown, or the
|
||||
// customer walks away believing all their trucks were created.
|
||||
if (response.failed === 0) {
|
||||
reset();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setResult(response);
|
||||
},
|
||||
});
|
||||
|
||||
const handleFileSelect = async (selectedFile: File | null) => {
|
||||
if (!selectedFile) {
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
setParseError(null);
|
||||
const handleFile = async (selected: File | null) => {
|
||||
setResult(null);
|
||||
if (!selected) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
|
||||
setFileName(selected.name);
|
||||
try {
|
||||
setParseError(null);
|
||||
const trucks = await parseTruckAssignmentFile(selectedFile);
|
||||
setFile(selectedFile);
|
||||
setParsed(trucks);
|
||||
} catch (err: any) {
|
||||
setParseError(err.message || "Failed to parse Excel file");
|
||||
setFile(null);
|
||||
setParsed([]);
|
||||
setParsed(await parseTruckAssignmentFile(selected, ctx));
|
||||
} catch (err) {
|
||||
setParsed({
|
||||
rows: [],
|
||||
errors: [err instanceof Error ? err.message : "Could not read the file."],
|
||||
rowNumbers: [],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
generateTruckAssignmentTemplate("truck-assignments.xlsx");
|
||||
};
|
||||
const { rows, errors, rowNumbers } = parsed;
|
||||
const listedErrors = errors.slice(0, MAX_LISTED_ERRORS);
|
||||
const hiddenErrors = errors.length - listedErrors.length;
|
||||
|
||||
/** Map a server error back to the spreadsheet row the customer actually sees. */
|
||||
const excelRowFor = (index: number, fallback: number) => rowNumbers[index] ?? fallback;
|
||||
|
||||
const columnLabel =
|
||||
ctx.shape === "CONTAINER"
|
||||
? "Containers"
|
||||
: ctx.shape === "PER_ITEM"
|
||||
? `Quantity (${ctx.itemNoun})`
|
||||
: "Planned tons";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title="Bulk Upload Truck Assignments"
|
||||
onClose={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
title="Bulk upload truck assignments"
|
||||
size="lg"
|
||||
centered
|
||||
>
|
||||
<Stack gap="lg">
|
||||
<Alert icon={<AlertCircle size={16} />} color="blue">
|
||||
Download template, fill with truck data, upload Excel file to bulk-create truck assignments.
|
||||
Download the template for this booking, fill in one row per truck, then upload it.
|
||||
{ctx.shape === "CONTAINER"
|
||||
? " It lists this booking's containers and their sizes."
|
||||
: ctx.remainingTons != null
|
||||
? ` ${ctx.remainingTons} t are still to be hauled.`
|
||||
: ""}
|
||||
</Alert>
|
||||
|
||||
<Group>
|
||||
<Button
|
||||
leftSection={<Download size={16} />}
|
||||
variant="light"
|
||||
onClick={handleDownloadTemplate}
|
||||
onClick={() => downloadTruckAssignmentTemplate(ctx)}
|
||||
>
|
||||
Download Template
|
||||
Download template
|
||||
</Button>
|
||||
<FileButton resetRef={resetFile} accept=".xlsx,.xls" onChange={handleFile}>
|
||||
{(props) => (
|
||||
<Button {...props} variant="default" leftSection={<Upload size={16} />}>
|
||||
{fileName ?? "Choose Excel file"}
|
||||
</Button>
|
||||
)}
|
||||
</FileButton>
|
||||
</Group>
|
||||
|
||||
<FileInput
|
||||
label="Select Excel File"
|
||||
placeholder="Choose .xlsx file"
|
||||
accept=".xlsx,.xls"
|
||||
value={file}
|
||||
onChange={handleFileSelect}
|
||||
leftSection={<Upload size={14} />}
|
||||
/>
|
||||
|
||||
{parseError && (
|
||||
<Alert icon={<AlertTriangle size={16} />} color="red" title="Parse Error">
|
||||
{parseError}
|
||||
{errors.length > 0 && (
|
||||
<Alert
|
||||
icon={<AlertTriangle size={16} />}
|
||||
color="red"
|
||||
title="Import failed — fix the file and upload it again"
|
||||
>
|
||||
<List size="sm" spacing={4}>
|
||||
{listedErrors.map((message) => (
|
||||
<List.Item key={message}>{message}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
{hiddenErrors > 0 && (
|
||||
<Text size="sm" mt={6}>
|
||||
…and {hiddenErrors} more.
|
||||
</Text>
|
||||
)}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{parsed.length > 0 && (
|
||||
{result && result.failed > 0 && (
|
||||
<Alert
|
||||
icon={<AlertTriangle size={16} />}
|
||||
color="orange"
|
||||
title={`${result.success} truck(s) added, ${result.failed} rejected`}
|
||||
>
|
||||
<Table verticalSpacing="xs" mt="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Row</Table.Th>
|
||||
<Table.Th>Plate</Table.Th>
|
||||
<Table.Th>Reason</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{result.errors.map((e) => (
|
||||
<Table.Tr key={`${e.index}-${e.truck}`}>
|
||||
<Table.Td>{excelRowFor(e.index, e.row)}</Table.Td>
|
||||
<Table.Td>{e.truck}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{e.reason}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={600} mb="xs">
|
||||
Preview ({parsed.length} trucks)
|
||||
Preview ({rows.length} truck{rows.length !== 1 ? "s" : ""})
|
||||
</Text>
|
||||
<Table striped highlightOnHover verticalSpacing="xs">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Plate Number</Table.Th>
|
||||
<Table.Th>Driver Name</Table.Th>
|
||||
<Table.Th>Truck Type</Table.Th>
|
||||
<Table.Th>Containers</Table.Th>
|
||||
<Table.Th>Row</Table.Th>
|
||||
<Table.Th>Plate number</Table.Th>
|
||||
<Table.Th>Driver</Table.Th>
|
||||
<Table.Th>Truck type</Table.Th>
|
||||
<Table.Th>{columnLabel}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{parsed.map((truck, idx) => (
|
||||
<Table.Tr key={idx}>
|
||||
{rows.map((truck, idx) => (
|
||||
<Table.Tr key={`${truck.truckPlateNumber}-${idx}`}>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{rowNumbers[idx]}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{truck.truckPlateNumber}</Text>
|
||||
</Table.Td>
|
||||
@@ -136,18 +242,21 @@ export function BulkTruckUploadModal({
|
||||
<Text size="sm">{truck.truckType}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{truck.containerNumbers?.length ? (
|
||||
{ctx.shape === "CONTAINER" ? (
|
||||
<Group gap="xs">
|
||||
{truck.containerNumbers.map((c) => (
|
||||
{(truck.containerNumbers ?? []).map((c) => (
|
||||
<Badge key={c} size="sm">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
) : ctx.shape === "PER_ITEM" ? (
|
||||
<Text size="sm">
|
||||
{truck.plannedQuantity}
|
||||
{truck.plannedTons ? ` · ${truck.plannedTons} t` : ""}
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="sm">{truck.plannedTons} t</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -158,14 +267,14 @@ export function BulkTruckUploadModal({
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Ready to upload {parsed.length} truck(s)
|
||||
Ready to upload {rows.length} truck{rows.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
<Button
|
||||
loading={uploadMutation.isPending}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
leftSection={<CheckCircle size={16} />}
|
||||
>
|
||||
Upload Trucks
|
||||
Upload trucks
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
|
||||
@@ -16,9 +16,20 @@ import {
|
||||
TextInput,
|
||||
} from "@mantine/core";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Freight } from "@edr/types";
|
||||
import { CheckCircle2, Clock, Download, Pencil, Plus, Trash2, Truck, Upload } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { CUSTOMER_TRUCK_TYPES, type Freight } from "@edr/types";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download,
|
||||
Lock,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
Truck,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
@@ -26,9 +37,14 @@ import { customerTrucksService } from "@/services/customer-trucks.service";
|
||||
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
import { BulkTruckUploadModal } from "./BulkTruckUploadModal";
|
||||
import { generateTruckAssignmentTemplate } from "@/utils/truck-assignment-template";
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import {
|
||||
downloadTruckAssignmentTemplate,
|
||||
isTwentyFoot,
|
||||
truckTemplateContext,
|
||||
} from "@/utils/truck-assignment-template";
|
||||
|
||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||
const TRUCK_TYPES = [...CUSTOMER_TRUCK_TYPES];
|
||||
|
||||
// Waybill-style selectable copies (indexes 1-8 in the API catalog). The 2 gate
|
||||
// copies (Port Operations, Gate Security & Carrier) are always printed.
|
||||
@@ -64,9 +80,19 @@ const errorMessage = (error: unknown, fallback: string) => {
|
||||
export function CustomerTruckAssignmentCard({
|
||||
booking,
|
||||
onAssigned,
|
||||
blockedReason,
|
||||
notice,
|
||||
}: {
|
||||
booking: Freight.IBooking;
|
||||
booking: BookingDetail;
|
||||
onAssigned: () => void;
|
||||
/**
|
||||
* Why assignment is closed right now, if it is. The card still renders — and
|
||||
* still lists any trucks already assigned — with this shown in place of the
|
||||
* form, rather than the whole card vanishing from the Logistics tab.
|
||||
*/
|
||||
blockedReason?: string | null;
|
||||
/** Something the customer should know before assigning — shown above the form when it is open. */
|
||||
notice?: string | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const trucksKey = ["customer-trucks", booking.id];
|
||||
@@ -86,6 +112,12 @@ export function CustomerTruckAssignmentCard({
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
||||
|
||||
// What this booking hauls: containers, loose tonnage (PER_TON), or counted
|
||||
// items (PER_ITEM — machinery, RoRo vehicles). Drives the form, the Excel
|
||||
// template and its parser from one place.
|
||||
const ctx = useMemo(() => truckTemplateContext(booking, trucks), [booking, trucks]);
|
||||
const isPerItem = ctx.shape === "PER_ITEM";
|
||||
|
||||
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||
const assignedNumbers = new Set(
|
||||
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
||||
@@ -94,9 +126,20 @@ export function CustomerTruckAssignmentCard({
|
||||
const editingOwn = new Set(
|
||||
(trucks.find((t) => t.id === editingId)?.containers ?? []).map((c) => c.containerNumber),
|
||||
);
|
||||
const availableContainers = (booking.containerNumbers ?? []).filter(
|
||||
const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size]));
|
||||
const unassignedContainers = (booking.containerNumbers ?? []).filter(
|
||||
(n) => !assignedNumbers.has(n) || editingOwn.has(n),
|
||||
);
|
||||
// A 40ft fills the truck. Once one is picked, or two 20ft are, nothing more
|
||||
// may be added — the API rejects it either way, so don't offer the choice.
|
||||
const pickedHas40ft = containers.some((n) => !isTwentyFoot(containerSizes.get(n) ?? ""));
|
||||
const availableContainers = unassignedContainers.filter((n) => {
|
||||
if (containers.includes(n)) return true;
|
||||
if (pickedHas40ft) return false;
|
||||
// Something is already picked and it's 20ft — only another 20ft may join it.
|
||||
if (containers.length > 0) return isTwentyFoot(containerSizes.get(n) ?? "");
|
||||
return true;
|
||||
});
|
||||
// Containers on the booking not yet assigned to any truck (independent of edit).
|
||||
const pendingAssignmentCount = (booking.containerNumbers ?? []).filter(
|
||||
(n) => !assignedNumbers.has(n),
|
||||
@@ -115,16 +158,12 @@ export function CustomerTruckAssignmentCard({
|
||||
};
|
||||
|
||||
const startEdit = (t: Freight.ICustomerTruck) => {
|
||||
const planned = t as Freight.ICustomerTruck & {
|
||||
plannedTons?: number | string | null;
|
||||
plannedQuantity?: number | null;
|
||||
};
|
||||
setPlateNumber(t.plateNumber ?? "");
|
||||
setDriverName(t.driverName ?? "");
|
||||
setTruckType(t.truckType ?? "");
|
||||
setContainers((t.containers ?? []).map((c) => c.containerNumber));
|
||||
setPlannedTons(planned.plannedTons != null ? Number(planned.plannedTons) : "");
|
||||
setPlannedQty(planned.plannedQuantity != null ? Number(planned.plannedQuantity) : "");
|
||||
setPlannedTons(t.plannedTons != null ? Number(t.plannedTons) : "");
|
||||
setPlannedQty(t.plannedQuantity != null ? Number(t.plannedQuantity) : "");
|
||||
setEditingId(t.id);
|
||||
setError(null);
|
||||
};
|
||||
@@ -187,7 +226,13 @@ export function CustomerTruckAssignmentCard({
|
||||
setError("Select 1 or 2 container numbers for this truck.");
|
||||
return;
|
||||
}
|
||||
if (isBulk && plannedTons === "") {
|
||||
// Counted cargo (machinery, RoRo vehicles) is committed by item count —
|
||||
// tonnage is often unknown until the weighbridge, so it stays optional.
|
||||
if (isBulk && isPerItem && plannedQty === "") {
|
||||
setError(`Enter how many ${ctx.itemNoun} this truck will carry.`);
|
||||
return;
|
||||
}
|
||||
if (isBulk && !isPerItem && plannedTons === "") {
|
||||
setError("Enter the tonnes this truck will haul.");
|
||||
return;
|
||||
}
|
||||
@@ -204,25 +249,27 @@ export function CustomerTruckAssignmentCard({
|
||||
<CardTitle>External Truck Assignment</CardTitle>
|
||||
</Group>
|
||||
<Group gap={12}>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => generateTruckAssignmentTemplate("truck-assignments.xlsx")}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Upload size={14} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
</Group>
|
||||
{pendingAssignmentCount > 0 && (
|
||||
{!blockedReason && (
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => downloadTruckAssignmentTemplate(ctx)}
|
||||
>
|
||||
Download Template
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<Upload size={14} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
{!blockedReason && pendingAssignmentCount > 0 && (
|
||||
<Text size="sm" fw={600} c="#b45309">
|
||||
{pendingAssignmentCount} container{pendingAssignmentCount !== 1 ? "s" : ""} pending assignment
|
||||
</Text>
|
||||
@@ -306,6 +353,19 @@ export function CustomerTruckAssignmentCard({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Assignment is closed for now — say why, and keep the trucks above
|
||||
visible rather than hiding the whole card. */}
|
||||
{blockedReason ? (
|
||||
<Alert color="gray" variant="light" icon={<Lock size={16} />}>
|
||||
{blockedReason}
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
{notice && (
|
||||
<Alert color="yellow" variant="light" icon={<AlertTriangle size={16} />}>
|
||||
{notice}
|
||||
</Alert>
|
||||
)}
|
||||
{!isBulk && (
|
||||
<Alert color="blue" variant="light">
|
||||
Truck capacity: assign either <b>1 x 40ft container</b> or up to{' '}
|
||||
@@ -344,34 +404,39 @@ export function CustomerTruckAssignmentCard({
|
||||
description="20ft: up to 2 per truck · 40ft: 1 per truck"
|
||||
required
|
||||
placeholder="Select container numbers"
|
||||
data={availableContainers}
|
||||
data={availableContainers.map((n) => {
|
||||
const size = containerSizes.get(n);
|
||||
return { value: n, label: size ? `${n} · ${size}` : n };
|
||||
})}
|
||||
value={containers}
|
||||
onChange={setContainers}
|
||||
maxValues={2}
|
||||
maxValues={pickedHas40ft ? 1 : 2}
|
||||
searchable
|
||||
nothingFoundMessage="No unassigned containers"
|
||||
/>
|
||||
)}
|
||||
{isBulk && (
|
||||
<NumberInput
|
||||
label="Tonnes to load"
|
||||
label={isPerItem ? "Tonnes to load (optional)" : "Tonnes to load"}
|
||||
description={(() => {
|
||||
const total = Number(booking.cargoTotalWeightVgm) || 0;
|
||||
const assigned = trucks
|
||||
.filter((t) => t.id !== editingId)
|
||||
.reduce((s, t) => {
|
||||
const x = t as Freight.ICustomerTruck & {
|
||||
netWeightTons?: number | string | null;
|
||||
plannedTons?: number | string | null;
|
||||
};
|
||||
return s + (Number(x.netWeightTons ?? x.plannedTons) || 0);
|
||||
}, 0);
|
||||
.reduce(
|
||||
(s, t) =>
|
||||
s +
|
||||
(Number(
|
||||
(t as Freight.ICustomerTruck & { netWeightTons?: number | string | null })
|
||||
.netWeightTons ?? t.plannedTons,
|
||||
) || 0),
|
||||
0,
|
||||
);
|
||||
const remaining = Math.max(0, Math.round((total - assigned) * 1000) / 1000);
|
||||
return total > 0
|
||||
? `${assigned} t of ${total} t already on trucks · ${remaining} t remaining`
|
||||
: "Tonnage this truck hauls";
|
||||
})()}
|
||||
required
|
||||
required={!isPerItem}
|
||||
min={0}
|
||||
value={plannedTons}
|
||||
onChange={(v) => setPlannedTons(v === "" ? "" : Number(v))}
|
||||
@@ -379,9 +444,17 @@ export function CustomerTruckAssignmentCard({
|
||||
)}
|
||||
{isBulk && (
|
||||
<NumberInput
|
||||
label="Items quantity (pcs)"
|
||||
description="Optional piece count on this truck"
|
||||
// Counted cargo commits by piece count; loose bulk records it
|
||||
// only as a note alongside the tonnage that actually bills.
|
||||
label={isPerItem ? `Number of ${ctx.itemNoun}` : "Items quantity (pcs)"}
|
||||
description={
|
||||
isPerItem
|
||||
? `How many ${ctx.itemNoun} ride this truck`
|
||||
: "Optional piece count on this truck"
|
||||
}
|
||||
required={isPerItem}
|
||||
min={0}
|
||||
allowDecimal={false}
|
||||
value={plannedQty}
|
||||
onChange={(v) => setPlannedQty(v === "" ? "" : Number(v))}
|
||||
/>
|
||||
@@ -410,6 +483,8 @@ export function CustomerTruckAssignmentCard({
|
||||
</Text>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{trucks.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
@@ -464,7 +539,8 @@ export function CustomerTruckAssignmentCard({
|
||||
<BulkTruckUploadModal
|
||||
opened={bulkModalOpen}
|
||||
onClose={() => setBulkModalOpen(false)}
|
||||
bookingId={booking.id}
|
||||
booking={booking}
|
||||
trucks={trucks}
|
||||
onSuccess={() => {
|
||||
queryClient.invalidateQueries({ queryKey: trucksKey });
|
||||
onAssigned();
|
||||
|
||||
@@ -1,108 +1,551 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
CUSTOMER_TRUCK_TYPES,
|
||||
ISO_CONTAINER_NUMBER,
|
||||
type Freight,
|
||||
} from "@edr/types";
|
||||
|
||||
export function generateTruckAssignmentTemplate(filename = 'truck-assignments.xlsx'): void {
|
||||
const data = [
|
||||
{
|
||||
'Truck Plate Number': '3-12345/67890',
|
||||
'Driver Name': 'John Doe',
|
||||
'Truck Type': 'Flatbed',
|
||||
'Container 1': 'MAEU1234567',
|
||||
'Container 2': 'HLXU7654321',
|
||||
},
|
||||
{
|
||||
'Truck Plate Number': '3-98765/43210',
|
||||
'Driver Name': 'Jane Smith',
|
||||
'Truck Type': 'Flatbed',
|
||||
'Container 1': 'COSCO1111111',
|
||||
'Container 2': '',
|
||||
},
|
||||
];
|
||||
import type { BookingDetail } from "@/pages/bookings/BookingDetailPage/booking-detail-types";
|
||||
|
||||
const instructions = [
|
||||
['TRUCK ASSIGNMENT BULK UPLOAD - INSTRUCTIONS'],
|
||||
[],
|
||||
['Column', 'Required', 'Notes'],
|
||||
['Truck Plate Number', 'Yes', 'Format: 3-XXXXX/XXXXX (Ethiopian plate format)'],
|
||||
['Driver Name', 'Yes', 'Full name of truck driver'],
|
||||
['Truck Type', 'Yes', 'e.g., Flatbed, Lowbed, Tanker, Trailer, etc.'],
|
||||
['Container 1', 'Yes*', '*Required for EXPORT. Leave empty for IMPORT bulk cargo.'],
|
||||
['Container 2', 'No', 'Optional. ISO format: e.g., MAEU1234567. Max 2 containers per truck.'],
|
||||
[],
|
||||
['CONTAINER RULES'],
|
||||
['- A 40ft container fills one truck (max 1 per truck)'],
|
||||
['- Two 20ft containers fit on one truck (max 2 per truck)'],
|
||||
['- No size mixing on same truck'],
|
||||
['- Containers must be from the booking'],
|
||||
[],
|
||||
['Example Data Below →'],
|
||||
];
|
||||
/**
|
||||
* Bulk self-haul truck assignment via Excel.
|
||||
*
|
||||
* The sheet a customer gets depends on what they booked. A container booking
|
||||
* names the containers each truck carries; a bulk booking has no containers at
|
||||
* all — the truck hauls loose tonnage — and a break-bulk/RoRo booking is counted
|
||||
* in items (machinery units, vehicles), not tons. Emitting one fixed set of
|
||||
* columns for all three is what made this unusable for anything but containers.
|
||||
*
|
||||
* Parsing follows the house pattern in
|
||||
* `pages/contracts/new-shipment-form/container-excel.ts`: read the sheet as a
|
||||
* grid, match headers fuzzily, report 1-based Excel row numbers, and return
|
||||
* all-or-nothing so a half-valid file never posts.
|
||||
*/
|
||||
|
||||
const wb = XLSX.utils.book_new();
|
||||
/** Two 20ft containers fit a truck bed; one 40ft fills it. Mirrors the API's `MAX_CONTAINERS_PER_TRUCK`. */
|
||||
const MAX_CONTAINERS_PER_TRUCK = 2;
|
||||
|
||||
// Instructions sheet
|
||||
const wsInstructions = XLSX.utils.aoa_to_sheet(instructions);
|
||||
wsInstructions['!cols'] = [{ wch: 30 }, { wch: 12 }, { wch: 50 }];
|
||||
XLSX.utils.book_append_sheet(wb, wsInstructions, 'Instructions');
|
||||
export type TruckTemplateShape = "CONTAINER" | "PER_TON" | "PER_ITEM";
|
||||
|
||||
// Data template sheet
|
||||
const wsData = XLSX.utils.json_to_sheet(data, {
|
||||
header: ['Truck Plate Number', 'Driver Name', 'Truck Type', 'Container 1', 'Container 2'],
|
||||
});
|
||||
wsData['!cols'] = [{ wch: 20 }, { wch: 20 }, { wch: 15 }, { wch: 18 }, { wch: 18 }];
|
||||
XLSX.utils.book_append_sheet(wb, wsData, 'Trucks');
|
||||
|
||||
XLSX.writeFile(wb, filename);
|
||||
export interface TruckTemplateContainer {
|
||||
number: string;
|
||||
/** "20ft" / "40ft", or "" when the booking line never recorded one. */
|
||||
size: string;
|
||||
/** Already riding another truck on this booking. */
|
||||
assigned: boolean;
|
||||
}
|
||||
|
||||
export function parseTruckAssignmentFile(
|
||||
export interface TruckTemplateContext {
|
||||
shape: TruckTemplateShape;
|
||||
reference: string;
|
||||
/** Human label for the cargo, e.g. "Machinery (MACHINERY)" or "Containerised cargo". */
|
||||
cargoLabel: string;
|
||||
/** Unit noun for PER_ITEM cargo — "machinery units", "vehicles", "items". */
|
||||
itemNoun: string;
|
||||
containers: TruckTemplateContainer[];
|
||||
/** Bulk only: tonnage still to be hauled, when the booking declares a total. */
|
||||
remainingTons: number | null;
|
||||
}
|
||||
|
||||
/** A 40ft (or an unrecorded size, treated as one) fills the bed and travels alone. */
|
||||
export function isTwentyFoot(size: string): boolean {
|
||||
return size.includes("20");
|
||||
}
|
||||
|
||||
/**
|
||||
* PER_ITEM cargo is counted in pieces, and the piece has a name the customer
|
||||
* recognises. RoRo bookings ride in as TRUCK / AUTOMOBILE / CARS.
|
||||
*/
|
||||
function itemNounFor(code: string | undefined, name: string | undefined): string {
|
||||
switch ((code ?? "").toUpperCase()) {
|
||||
case "TRUCK":
|
||||
case "AUTOMOBILE":
|
||||
case "CARS":
|
||||
case "RORO":
|
||||
return "vehicles";
|
||||
case "MACHINERY":
|
||||
return "machinery units";
|
||||
default:
|
||||
return name ? `${name.trim().toLowerCase()} items` : "items";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything the template and the parser need about one booking. Derived from
|
||||
* data the detail endpoint already returns — no extra request.
|
||||
*/
|
||||
export function truckTemplateContext(
|
||||
booking: BookingDetail,
|
||||
trucks: Freight.ICustomerTruck[] = [],
|
||||
): TruckTemplateContext {
|
||||
const assigned = new Set(
|
||||
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
||||
);
|
||||
|
||||
// Container size lives on the booking_container LINE; the physical numbers
|
||||
// live on its units. Walk both to get number → size.
|
||||
const containers: TruckTemplateContainer[] = [];
|
||||
for (const line of booking.bookingContainers ?? []) {
|
||||
const size =
|
||||
line.containerSize?.trim() ||
|
||||
(line.containerType?.sizeFt ? `${line.containerType.sizeFt}ft` : "");
|
||||
for (const unit of line.units ?? []) {
|
||||
if (!unit.containerNumber) continue;
|
||||
containers.push({
|
||||
number: unit.containerNumber,
|
||||
size,
|
||||
assigned: assigned.has(unit.containerNumber),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Fall back to the flat list when the line relation didn't come through — the
|
||||
// numbers are still usable, we just can't police sizes client-side.
|
||||
if (containers.length === 0) {
|
||||
for (const number of booking.containerNumbers ?? []) {
|
||||
containers.push({ number, size: "", assigned: assigned.has(number) });
|
||||
}
|
||||
}
|
||||
|
||||
const isContainer = String(booking.freightType) === "CONTAINER";
|
||||
const unit = booking.cargoType?.unitOfMeasure;
|
||||
const shape: TruckTemplateShape = isContainer
|
||||
? "CONTAINER"
|
||||
: unit === "PER_ITEM"
|
||||
? "PER_ITEM"
|
||||
: // PER_TON, NUMBER_OF_WAGONS and unset all haul tonnage by truck.
|
||||
"PER_TON";
|
||||
|
||||
const cargoLabel = isContainer
|
||||
? "Containerised cargo"
|
||||
: booking.cargoType?.cargoTypeName
|
||||
? `${booking.cargoType.cargoTypeName.trim()}${booking.cargoType.code ? ` (${booking.cargoType.code})` : ""}`
|
||||
: "Bulk cargo";
|
||||
|
||||
// Planned tonnage already committed to live trucks draws the total down; the
|
||||
// API applies the same rule on every add.
|
||||
const totalTons = Number(booking.cargoTotalWeightVgm ?? 0);
|
||||
const committed = trucks.reduce((sum, t) => sum + Number(t.plannedTons ?? 0), 0);
|
||||
const remainingTons =
|
||||
!isContainer && totalTons > 0
|
||||
? Math.max(0, Math.round((totalTons - committed) * 1000) / 1000)
|
||||
: null;
|
||||
|
||||
return {
|
||||
shape,
|
||||
reference: booking.reference ?? "",
|
||||
cargoLabel,
|
||||
itemNoun: itemNounFor(booking.cargoType?.code, booking.cargoType?.cargoTypeName),
|
||||
containers,
|
||||
remainingTons,
|
||||
};
|
||||
}
|
||||
|
||||
const PLATE_HEADER = "Truck Plate Number";
|
||||
const DRIVER_HEADER = "Driver Name";
|
||||
const TYPE_HEADER = "Truck Type";
|
||||
const CONTAINER_1_HEADER = "Container 1";
|
||||
const CONTAINER_2_HEADER = "Container 2";
|
||||
const TONS_HEADER = "Planned Tons";
|
||||
const QUANTITY_HEADER = "Planned Quantity";
|
||||
|
||||
function headersFor(shape: TruckTemplateShape): string[] {
|
||||
const base = [PLATE_HEADER, DRIVER_HEADER, TYPE_HEADER];
|
||||
if (shape === "CONTAINER") return [...base, CONTAINER_1_HEADER, CONTAINER_2_HEADER];
|
||||
if (shape === "PER_ITEM") return [...base, QUANTITY_HEADER, TONS_HEADER];
|
||||
return [...base, TONS_HEADER];
|
||||
}
|
||||
|
||||
/** Sample rows built from the booking's own data, so the customer edits rather than invents. */
|
||||
function sampleRowsFor(ctx: TruckTemplateContext): Array<Array<string | number>> {
|
||||
const type = CUSTOMER_TRUCK_TYPES[0];
|
||||
|
||||
if (ctx.shape === "CONTAINER") {
|
||||
const free = ctx.containers.filter((c) => !c.assigned);
|
||||
if (free.length === 0) {
|
||||
return [["3-12345 ET", "Abebe Kebede", type, "", ""]];
|
||||
}
|
||||
const rows: Array<Array<string | number>> = [];
|
||||
let i = 0;
|
||||
let plate = 12345;
|
||||
while (i < free.length) {
|
||||
const first = free[i];
|
||||
// Pair only two 20ft; a 40ft (or an unknown size) takes the truck alone.
|
||||
const second =
|
||||
isTwentyFoot(first.size) && free[i + 1] && isTwentyFoot(free[i + 1].size)
|
||||
? free[i + 1]
|
||||
: null;
|
||||
rows.push([
|
||||
`3-${plate++} ET`,
|
||||
"Abebe Kebede",
|
||||
type,
|
||||
first.number,
|
||||
second?.number ?? "",
|
||||
]);
|
||||
i += second ? 2 : 1;
|
||||
if (rows.length >= 5) break;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const tons = ctx.remainingTons && ctx.remainingTons > 0 ? Math.min(30, ctx.remainingTons) : 30;
|
||||
if (ctx.shape === "PER_ITEM") {
|
||||
return [["3-12345 ET", "Abebe Kebede", type, 2, tons]];
|
||||
}
|
||||
return [["3-12345 ET", "Abebe Kebede", type, tons]];
|
||||
}
|
||||
|
||||
function instructionsFor(ctx: TruckTemplateContext): Array<Array<string | number>> {
|
||||
const rows: Array<Array<string | number>> = [
|
||||
["TRUCK ASSIGNMENT — BULK UPLOAD"],
|
||||
[],
|
||||
["Booking", ctx.reference],
|
||||
["Cargo", ctx.cargoLabel],
|
||||
[],
|
||||
["Fill in the 'Trucks' sheet. One row per truck. Do not rename the headers."],
|
||||
[],
|
||||
["Column", "Required", "Notes"],
|
||||
[PLATE_HEADER, "Yes", "The truck's plate, as written on the vehicle."],
|
||||
[DRIVER_HEADER, "Yes", "Full name of the driver."],
|
||||
[TYPE_HEADER, "Yes", `One of: ${CUSTOMER_TRUCK_TYPES.join(", ")}`],
|
||||
];
|
||||
|
||||
if (ctx.shape === "CONTAINER") {
|
||||
rows.push(
|
||||
[CONTAINER_1_HEADER, "Yes", "A container number from this booking — see the 'Containers' sheet."],
|
||||
[CONTAINER_2_HEADER, "No", "Only when pairing two 20ft containers on one truck."],
|
||||
[],
|
||||
["TRUCK CAPACITY"],
|
||||
["One 40ft container fills a truck and travels alone."],
|
||||
["Two 20ft containers may share one truck."],
|
||||
["Never mix a 40ft and a 20ft on the same truck."],
|
||||
["Each container may be assigned to exactly one truck."],
|
||||
);
|
||||
} else if (ctx.shape === "PER_ITEM") {
|
||||
rows.push(
|
||||
[QUANTITY_HEADER, "Yes", `Whole number of ${ctx.itemNoun} on this truck.`],
|
||||
[TONS_HEADER, "No", "Weight in tonnes, if known."],
|
||||
[],
|
||||
["This cargo is counted in items, not containers — leave containers out entirely."],
|
||||
);
|
||||
} else {
|
||||
rows.push(
|
||||
[TONS_HEADER, "Yes", "Tonnes this truck will haul. Decimals allowed."],
|
||||
[],
|
||||
["This is bulk cargo — the truck hauls loose tonnage and is weighed on exit."],
|
||||
);
|
||||
}
|
||||
|
||||
if (ctx.remainingTons != null) {
|
||||
rows.push([], ["Tonnage still to be hauled", `${ctx.remainingTons} t`]);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Build and download the template for this booking. */
|
||||
export function downloadTruckAssignmentTemplate(
|
||||
ctx: TruckTemplateContext,
|
||||
filename?: string,
|
||||
): void {
|
||||
const workbook = XLSX.utils.book_new();
|
||||
|
||||
const instructions = XLSX.utils.aoa_to_sheet(instructionsFor(ctx));
|
||||
instructions["!cols"] = [{ wch: 24 }, { wch: 12 }, { wch: 62 }];
|
||||
XLSX.utils.book_append_sheet(workbook, instructions, "Instructions");
|
||||
|
||||
const headers = headersFor(ctx.shape);
|
||||
const trucks = XLSX.utils.aoa_to_sheet([headers, ...sampleRowsFor(ctx)]);
|
||||
trucks["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
|
||||
XLSX.utils.book_append_sheet(workbook, trucks, "Trucks");
|
||||
|
||||
if (ctx.shape === "CONTAINER") {
|
||||
const reference = XLSX.utils.aoa_to_sheet([
|
||||
["Container Number", "Size", "Status"],
|
||||
...ctx.containers.map((c) => [
|
||||
c.number,
|
||||
c.size || "unknown",
|
||||
c.assigned ? "Already on a truck" : "Available",
|
||||
]),
|
||||
]);
|
||||
reference["!cols"] = [{ wch: 20 }, { wch: 10 }, { wch: 20 }];
|
||||
XLSX.utils.book_append_sheet(workbook, reference, "Containers");
|
||||
}
|
||||
|
||||
XLSX.writeFile(
|
||||
workbook,
|
||||
filename ?? `truck-assignments-${ctx.reference || "booking"}.xlsx`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Normalised header key — tolerates case, spaces, punctuation and stray units. */
|
||||
function headerKey(raw: string): string {
|
||||
return String(raw ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each template column to the index it occupies in the uploaded sheet.
|
||||
* Matching is by normalised substring so "Planned Tons (t)" still resolves;
|
||||
* the two container columns are matched on their digit so "Container 1" can
|
||||
* never be captured by the looser "container" test.
|
||||
*/
|
||||
function resolveColumns(headerRow: string[]): Record<string, number> {
|
||||
const keys = headerRow.map(headerKey);
|
||||
const find = (...candidates: string[]): number => {
|
||||
for (const candidate of candidates) {
|
||||
const index = keys.findIndex((k) => k.includes(candidate));
|
||||
if (index >= 0) return index;
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
return {
|
||||
plate: find("plate", "truckplate"),
|
||||
driver: find("driver"),
|
||||
type: find("trucktype", "type"),
|
||||
container1: find("container1", "containerone"),
|
||||
container2: find("container2", "containertwo"),
|
||||
tons: find("plannedtons", "tons", "weight"),
|
||||
quantity: find("plannedquantity", "quantity", "count"),
|
||||
};
|
||||
}
|
||||
|
||||
const TRUCK_TYPE_LIST = CUSTOMER_TRUCK_TYPES.join(", ");
|
||||
|
||||
/** Case-insensitive match onto the canonical spelling the API expects. */
|
||||
function canonicalTruckType(raw: string): string | null {
|
||||
const needle = raw.trim().toLowerCase();
|
||||
return CUSTOMER_TRUCK_TYPES.find((t) => t.toLowerCase() === needle) ?? null;
|
||||
}
|
||||
|
||||
export interface ParsedTruckFile {
|
||||
rows: Freight.AddCustomerTruckPayload[];
|
||||
errors: string[];
|
||||
/** Excel row number each parsed row came from, positionally aligned with `rows`. */
|
||||
rowNumbers: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded sheet against this booking. Returns every problem at once —
|
||||
* a customer fixing a 30-row file should not discover the mistakes one upload at
|
||||
* a time — and returns no rows at all unless the whole file is clean.
|
||||
*/
|
||||
export async function parseTruckAssignmentFile(
|
||||
file: File,
|
||||
): Promise<
|
||||
Array<{
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumbers?: string[];
|
||||
}>
|
||||
> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
ctx: TruckTemplateContext,
|
||||
): Promise<ParsedTruckFile> {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
const sheet =
|
||||
workbook.Sheets["Trucks"] ??
|
||||
Object.values(workbook.Sheets).find((s) => s !== workbook.Sheets["Instructions"]) ??
|
||||
Object.values(workbook.Sheets)[0];
|
||||
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = e.target?.result as ArrayBuffer;
|
||||
const wb = XLSX.read(data, { type: 'array' });
|
||||
const wsData = wb.Sheets['Trucks'] || Object.values(wb.Sheets)[0];
|
||||
if (!sheet) {
|
||||
return { rows: [], errors: ["The file has no sheets."], rowNumbers: [] };
|
||||
}
|
||||
|
||||
if (!wsData) {
|
||||
reject(new Error('No data sheet found in Excel file'));
|
||||
return;
|
||||
}
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, {
|
||||
header: 1,
|
||||
raw: false,
|
||||
defval: "",
|
||||
});
|
||||
|
||||
const jsonData = XLSX.utils.sheet_to_json(wsData) as Array<Record<string, any>>;
|
||||
// The header is the first row that names the plate column — customers add
|
||||
// titles and notes above the table.
|
||||
const headerIndex = grid.findIndex((row) =>
|
||||
row.some((cell) => headerKey(cell).includes("plate")),
|
||||
);
|
||||
if (headerIndex < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
`Could not find the "${PLATE_HEADER}" column. Use the downloaded template and keep its headers.`,
|
||||
],
|
||||
rowNumbers: [],
|
||||
};
|
||||
}
|
||||
|
||||
const trucks = jsonData.map((row) => {
|
||||
const containers = [
|
||||
row['Container 1'],
|
||||
row['Container 2'],
|
||||
]
|
||||
.filter((c) => c && c.trim())
|
||||
.map((c) => c.trim().toUpperCase());
|
||||
const columns = resolveColumns(grid[headerIndex]);
|
||||
const errors: string[] = [];
|
||||
const rows: Freight.AddCustomerTruckPayload[] = [];
|
||||
const rowNumbers: number[] = [];
|
||||
|
||||
return {
|
||||
truckPlateNumber: row['Truck Plate Number']?.trim() || '',
|
||||
driverName: row['Driver Name']?.trim() || '',
|
||||
truckType: row['Truck Type']?.trim() || '',
|
||||
containerNumbers: containers.length > 0 ? containers : undefined,
|
||||
};
|
||||
});
|
||||
const containerSizes = new Map(ctx.containers.map((c) => [c.number, c.size]));
|
||||
const alreadyAssigned = new Set(
|
||||
ctx.containers.filter((c) => c.assigned).map((c) => c.number),
|
||||
);
|
||||
const plateSeen = new Map<string, number>();
|
||||
const containerSeen = new Map<string, number>();
|
||||
let tonsInFile = 0;
|
||||
|
||||
resolve(trucks);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
for (let i = headerIndex + 1; i < grid.length; i++) {
|
||||
const row = grid[i];
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
const cell = (key: string): string => {
|
||||
const index = columns[key];
|
||||
return index >= 0 ? String(row[index] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
reader.onerror = () => reject(new Error('Failed to read file'));
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
const plate = cell("plate");
|
||||
const driver = cell("driver");
|
||||
const rawType = cell("type");
|
||||
|
||||
// A trailing blank row is normal, not an error.
|
||||
if (!plate && !driver && !rawType && row.every((c) => !String(c ?? "").trim())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let rowOk = true;
|
||||
if (!plate) {
|
||||
errors.push(`Row ${rowNo}: truck plate number is required.`);
|
||||
rowOk = false;
|
||||
}
|
||||
if (!driver) {
|
||||
errors.push(`Row ${rowNo}: driver name is required.`);
|
||||
rowOk = false;
|
||||
}
|
||||
|
||||
const truckType = canonicalTruckType(rawType);
|
||||
if (!truckType) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: truck type "${rawType || "—"}" is not accepted. Use one of: ${TRUCK_TYPE_LIST}.`,
|
||||
);
|
||||
rowOk = false;
|
||||
}
|
||||
|
||||
const plateKey = plate.toUpperCase();
|
||||
if (plate) {
|
||||
const seenAt = plateSeen.get(plateKey);
|
||||
if (seenAt) {
|
||||
errors.push(`Row ${rowNo}: plate ${plate} already appears on row ${seenAt}.`);
|
||||
rowOk = false;
|
||||
} else {
|
||||
plateSeen.set(plateKey, rowNo);
|
||||
}
|
||||
}
|
||||
|
||||
const payload: Freight.AddCustomerTruckPayload = {
|
||||
truckPlateNumber: plateKey,
|
||||
driverName: driver,
|
||||
truckType: truckType ?? "",
|
||||
};
|
||||
|
||||
if (ctx.shape === "CONTAINER") {
|
||||
const numbers = [cell("container1"), cell("container2")]
|
||||
.map((n) => n.trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
|
||||
if (numbers.length === 0) {
|
||||
errors.push(`Row ${rowNo}: at least one container number is required.`);
|
||||
rowOk = false;
|
||||
}
|
||||
if (numbers.length > MAX_CONTAINERS_PER_TRUCK) {
|
||||
errors.push(`Row ${rowNo}: a truck carries at most ${MAX_CONTAINERS_PER_TRUCK} containers.`);
|
||||
rowOk = false;
|
||||
}
|
||||
if (numbers.length === 2 && numbers[0] === numbers[1]) {
|
||||
errors.push(`Row ${rowNo}: container ${numbers[0]} is listed twice on the same truck.`);
|
||||
rowOk = false;
|
||||
}
|
||||
|
||||
for (const number of numbers) {
|
||||
if (!ISO_CONTAINER_NUMBER.test(number)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: container "${number}" is not a valid ISO number (four letters then seven digits, e.g. ABCD1234567).`,
|
||||
);
|
||||
rowOk = false;
|
||||
continue;
|
||||
}
|
||||
if (ctx.containers.length > 0 && !containerSizes.has(number)) {
|
||||
errors.push(`Row ${rowNo}: container ${number} is not on this booking.`);
|
||||
rowOk = false;
|
||||
continue;
|
||||
}
|
||||
if (alreadyAssigned.has(number)) {
|
||||
errors.push(`Row ${rowNo}: container ${number} is already loaded onto another truck.`);
|
||||
rowOk = false;
|
||||
continue;
|
||||
}
|
||||
const seenAt = containerSeen.get(number);
|
||||
if (seenAt) {
|
||||
errors.push(`Row ${rowNo}: container ${number} is already used on row ${seenAt}.`);
|
||||
rowOk = false;
|
||||
continue;
|
||||
}
|
||||
containerSeen.set(number, rowNo);
|
||||
}
|
||||
|
||||
// Pairing is allowed only when both are explicitly 20ft — a 40ft, or a
|
||||
// container whose size was never recorded, fills the bed on its own. Same
|
||||
// rule the API enforces in `assertTruckLoad`.
|
||||
if (numbers.length === 2) {
|
||||
const sizes = numbers.map((n) => containerSizes.get(n) ?? "");
|
||||
if (sizes.some((size) => !isTwentyFoot(size))) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: a truck carries either one 40ft container or two 20ft containers — ${numbers
|
||||
.map((n, idx) => `${n} (${sizes[idx] || "size unknown"})`)
|
||||
.join(" and ")} cannot share one.`,
|
||||
);
|
||||
rowOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
payload.containerNumbers = numbers;
|
||||
} else {
|
||||
const tonsRaw = cell("tons");
|
||||
const tons = Number(tonsRaw);
|
||||
const quantityRaw = cell("quantity");
|
||||
const quantity = Number(quantityRaw);
|
||||
|
||||
if (ctx.shape === "PER_ITEM") {
|
||||
if (!quantityRaw || !Number.isInteger(quantity) || quantity <= 0) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: planned quantity "${quantityRaw || "—"}" must be a whole number of ${ctx.itemNoun} greater than 0.`,
|
||||
);
|
||||
rowOk = false;
|
||||
} else {
|
||||
payload.plannedQuantity = quantity;
|
||||
}
|
||||
if (tonsRaw) {
|
||||
if (Number.isNaN(tons) || tons <= 0) {
|
||||
errors.push(`Row ${rowNo}: planned tons "${tonsRaw}" must be a number greater than 0.`);
|
||||
rowOk = false;
|
||||
} else {
|
||||
payload.plannedTons = tons;
|
||||
tonsInFile += tons;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (!tonsRaw || Number.isNaN(tons) || tons <= 0) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: planned tons "${tonsRaw || "—"}" must be a number greater than 0.`,
|
||||
);
|
||||
rowOk = false;
|
||||
} else {
|
||||
payload.plannedTons = tons;
|
||||
tonsInFile += tons;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (rowOk) {
|
||||
rows.push(payload);
|
||||
rowNumbers.push(rowNo);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.remainingTons != null &&
|
||||
tonsInFile > ctx.remainingTons + 0.001 // numeric(14,3) — tolerate float drift
|
||||
) {
|
||||
errors.push(
|
||||
`The file plans ${Math.round(tonsInFile * 1000) / 1000} t but only ${ctx.remainingTons} t are left to haul on this booking.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no truck rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0
|
||||
? { rows: [], errors, rowNumbers: [] }
|
||||
: { rows, errors: [], rowNumbers };
|
||||
}
|
||||
|
||||
@@ -648,6 +648,25 @@ export interface ICustomerTruckContainer {
|
||||
containerNumber: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The truck-type vocabulary a customer picks from when self-hauling. The API
|
||||
* validates against this exact list (`@IsIn`), the portal's dropdown renders it,
|
||||
* and the bulk-upload template documents it — all three read this constant so a
|
||||
* value the customer can type can never be one the API rejects.
|
||||
*/
|
||||
export const CUSTOMER_TRUCK_TYPES = [
|
||||
"Flatbed",
|
||||
"Container Chassis",
|
||||
"Lowboy",
|
||||
"Box Truck",
|
||||
"Tipper",
|
||||
] as const;
|
||||
|
||||
export type CustomerTruckType = (typeof CUSTOMER_TRUCK_TYPES)[number];
|
||||
|
||||
/** ISO 6346 container number: four letters then seven digits, e.g. ABCD1234567. */
|
||||
export const ISO_CONTAINER_NUMBER = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
/** A customer self-haul truck on a booking, carrying 1–2 containers. */
|
||||
export interface ICustomerTruck {
|
||||
id: string;
|
||||
@@ -659,6 +678,10 @@ export interface ICustomerTruck {
|
||||
arrivedAt?: string | null;
|
||||
departedAt?: string | null;
|
||||
containers?: ICustomerTruckContainer[];
|
||||
/** Bulk: planned tonnage this truck hauls. `numeric` — serialises as a string. */
|
||||
plannedTons?: number | string | null;
|
||||
/** Bulk PER_ITEM: planned item/piece count on this truck. */
|
||||
plannedQuantity?: number | null;
|
||||
}
|
||||
|
||||
/** Payload to add a customer self-haul truck (1–2 container numbers). */
|
||||
@@ -666,7 +689,12 @@ export interface AddCustomerTruckPayload {
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumbers: string[];
|
||||
/** Container bookings only — bulk trucks haul loose tonnage instead. */
|
||||
containerNumbers?: string[];
|
||||
/** Bulk: planned tonnage, drawn down against the booking's declared VGM. */
|
||||
plannedTons?: number;
|
||||
/** Bulk PER_ITEM: planned item/piece count. */
|
||||
plannedQuantity?: number;
|
||||
}
|
||||
|
||||
export interface IBooking extends BaseEntity {
|
||||
|
||||
Reference in New Issue
Block a user