mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge pull request #464 from Tria-plc/CutomerTruckAssign
Cutomer truck assign
This commit is contained in:
@@ -0,0 +1,59 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-truck customer (self-haul) assignment. Replaces the single
|
||||||
|
* booking.customer_truck_* fields with a per-booking list of trucks, each
|
||||||
|
* carrying 1–2 containers and tracking its own arrival. The legacy
|
||||||
|
* booking.customer_truck_* columns are kept as a synced booking-level flag
|
||||||
|
* (any truck assigned / all trucks arrived) so the warehouse exit-gate and
|
||||||
|
* delivery-approval logic keep working.
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckAssignments1950000000000 implements MigrationInterface {
|
||||||
|
name = 'AddCustomerTruckAssignments1950000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.customer_truck_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
plate_number varchar(32) NOT NULL,
|
||||||
|
driver_name varchar(120) NOT NULL,
|
||||||
|
truck_type varchar(60) NOT NULL,
|
||||||
|
assigned_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
arrived_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_customer_truck_assignments_booking" ON freight.customer_truck_assignments (booking_id);`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS freight.customer_truck_containers (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
assignment_id uuid NOT NULL REFERENCES freight.customer_truck_assignments(id) ON DELETE CASCADE,
|
||||||
|
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
|
||||||
|
container_number varchar(64) NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
deleted_at timestamptz
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_customer_truck_containers_assignment" ON freight.customer_truck_containers (assignment_id);`,
|
||||||
|
);
|
||||||
|
// One container number can be loaded onto exactly one truck per booking.
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_customer_truck_containers_booking_number"
|
||||||
|
ON freight.customer_truck_containers (booking_id, container_number)
|
||||||
|
WHERE deleted_at IS NULL;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_containers;`);
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS freight.customer_truck_assignments;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-container receive tracking. A booking's containers arrive individually
|
||||||
|
* (on separate self-haul trucks), so each container unit tracks whether it has
|
||||||
|
* been received into the port and, once staff confirm it, the GRN it belongs to.
|
||||||
|
* A single GRN covers the containers received together — so if the whole booking
|
||||||
|
* arrives at once, all its units share one GRN (per-booking GRN).
|
||||||
|
*/
|
||||||
|
export class AddContainerReceiptToBookingContainerUnits1960000000000
|
||||||
|
implements MigrationInterface
|
||||||
|
{
|
||||||
|
name = 'AddContainerReceiptToBookingContainerUnits1960000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_container_units
|
||||||
|
ADD COLUMN IF NOT EXISTS received_to_port boolean NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN IF NOT EXISTS received_at timestamptz,
|
||||||
|
ADD COLUMN IF NOT EXISTS grn_number varchar(100)
|
||||||
|
`);
|
||||||
|
await queryRunner.query(
|
||||||
|
`CREATE INDEX IF NOT EXISTS "IDX_booking_container_units_grn" ON freight.booking_container_units (grn_number);`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX IF EXISTS freight."IDX_booking_container_units_grn";`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.booking_container_units
|
||||||
|
DROP COLUMN IF EXISTS received_to_port,
|
||||||
|
DROP COLUMN IF EXISTS received_at,
|
||||||
|
DROP COLUMN IF EXISTS grn_number
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import self-haul trucks are weighed on leaving. The customer does not
|
||||||
|
* pre-specify what an import truck takes — staff register the containers loaded
|
||||||
|
* and the weighed gross when the truck departs. These columns capture that.
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckDeparture1970000000000 implements MigrationInterface {
|
||||||
|
name = 'AddCustomerTruckDeparture1970000000000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS gross_weight_kg numeric(14, 2),
|
||||||
|
ADD COLUMN IF NOT EXISTS departed_at timestamptz
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.customer_truck_assignments
|
||||||
|
DROP COLUMN IF EXISTS gross_weight_kg,
|
||||||
|
DROP COLUMN IF EXISTS departed_at
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
|
ForbiddenException,
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
@@ -61,6 +62,11 @@ import {
|
|||||||
} from './dto/request-changes.dto';
|
} from './dto/request-changes.dto';
|
||||||
import { ContractViewDto } from './dto/contract-view.dto';
|
import { ContractViewDto } from './dto/contract-view.dto';
|
||||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||||
|
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||||
|
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
|
||||||
|
import { CustomerTruckService } from './customer-truck.service';
|
||||||
|
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||||
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import {
|
import {
|
||||||
@@ -83,6 +89,8 @@ export class BookingsController {
|
|||||||
private readonly transitionService: BookingTransitionService,
|
private readonly transitionService: BookingTransitionService,
|
||||||
private readonly contractService: BookingContractService,
|
private readonly contractService: BookingContractService,
|
||||||
private readonly bookingClearanceService: BookingClearanceService,
|
private readonly bookingClearanceService: BookingClearanceService,
|
||||||
|
private readonly customerTruckService: CustomerTruckService,
|
||||||
|
private readonly containerReceiptService: ContainerReceiptService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@@ -309,6 +317,94 @@ export class BookingsController {
|
|||||||
res.send(buffer);
|
res.send(buffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get(':id/customer-trucks')
|
||||||
|
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
|
||||||
|
async listCustomerTrucks(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.listTrucks(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/customer-trucks')
|
||||||
|
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 1–2 of the booking containers' })
|
||||||
|
async addCustomerTruck(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: AddCustomerTruckDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.addTruck(id, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id/customer-trucks/:assignmentId')
|
||||||
|
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
|
||||||
|
async removeCustomerTruck(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
const booking = await this.bookingsService.findById(id);
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||||
|
}
|
||||||
|
return this.customerTruckService.removeTruck(id, assignmentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/customer-trucks/:assignmentId/depart')
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
|
||||||
|
})
|
||||||
|
async departCustomerTruck(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||||
|
@Body() dto: DepartCustomerTruckDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// Weighing + registering the load on exit is a warehouse/gate staff action.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
throw new ForbiddenException('Only warehouse staff can register a truck departure');
|
||||||
|
}
|
||||||
|
return this.customerTruckService.departTruck(id, assignmentId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id/received-pending-grn')
|
||||||
|
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
|
||||||
|
async receivedPendingGrn(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// GRN is a warehouse-staff action — no customer access.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||||||
|
}
|
||||||
|
return this.containerReceiptService.listReceivedPendingGrn(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/generate-grn')
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
|
||||||
|
})
|
||||||
|
async generateGrn(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() dto: GenerateGrnDto,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
// GRN is a warehouse-staff action — no customer access.
|
||||||
|
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||||
|
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
|
||||||
|
}
|
||||||
|
return this.containerReceiptService.generateGrn(id, dto.containerNumbers);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id/tracking')
|
@Get(':id/tracking')
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "Shipment tracking timeline for a booking",
|
summary: "Shipment tracking timeline for a booking",
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
|||||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||||
import { Booking } from './entities/booking.entity';
|
import { Booking } from './entities/booking.entity';
|
||||||
|
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
|
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||||
|
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||||
|
import { CustomerTruckService } from './customer-truck.service';
|
||||||
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
import { ContractPdfService } from '../../contracts/contract-pdf.service';
|
||||||
import { ContractsModule } from '../contracts/contracts.module';
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
|
||||||
@@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
BookingReviewNote,
|
BookingReviewNote,
|
||||||
BookingContractSignature,
|
BookingContractSignature,
|
||||||
BookingContainerAllocation,
|
BookingContainerAllocation,
|
||||||
|
CustomerTruckAssignment,
|
||||||
|
CustomerTruckContainer,
|
||||||
]),
|
]),
|
||||||
BillingModule,
|
BillingModule,
|
||||||
forwardRef(() => FirstMileModule),
|
forwardRef(() => FirstMileModule),
|
||||||
@@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
|||||||
ContractPricingScheduleBuilder,
|
ContractPricingScheduleBuilder,
|
||||||
ContractRendererService,
|
ContractRendererService,
|
||||||
ContractPdfService,
|
ContractPdfService,
|
||||||
|
CustomerTruckAssignmentsRepository,
|
||||||
|
CustomerTruckService,
|
||||||
|
ContainerReceiptService,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
BookingsService,
|
BookingsService,
|
||||||
BookingsRepository,
|
BookingsRepository,
|
||||||
BookingPricingService,
|
BookingPricingService,
|
||||||
BookingInvoiceService,
|
BookingInvoiceService,
|
||||||
|
CustomerTruckService,
|
||||||
|
ContainerReceiptService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class BookingsModule { }
|
export class BookingsModule { }
|
||||||
|
|||||||
@@ -145,7 +145,28 @@ export class BookingsService {
|
|||||||
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
|
||||||
}
|
}
|
||||||
|
|
||||||
const html = this.buildCustomerTruckFreightOrderHtml(booking);
|
const trucks: Array<{
|
||||||
|
plateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
truckType: string;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
containers: string | null;
|
||||||
|
}> = await this.dataSource.query(
|
||||||
|
`SELECT a.plate_number AS "plateNumber",
|
||||||
|
a.driver_name AS "driverName",
|
||||||
|
a.truck_type AS "truckType",
|
||||||
|
a.arrived_at AS "arrivedAt",
|
||||||
|
string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers"
|
||||||
|
FROM freight.customer_truck_assignments a
|
||||||
|
LEFT JOIN freight.customer_truck_containers c
|
||||||
|
ON c.assignment_id = a.id AND c.deleted_at IS NULL
|
||||||
|
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||||
|
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at
|
||||||
|
ORDER BY a.assigned_at`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
|
||||||
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
|
||||||
return {
|
return {
|
||||||
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||||
@@ -190,37 +211,85 @@ export class BookingsService {
|
|||||||
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
|
private buildCustomerTruckFreightOrderHtml(
|
||||||
|
booking: Booking,
|
||||||
|
trucks: Array<{
|
||||||
|
plateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
truckType: string;
|
||||||
|
arrivedAt: string | null;
|
||||||
|
containers: string | null;
|
||||||
|
}>,
|
||||||
|
): string {
|
||||||
const assignedAt = booking.customerTruckAssignedAt
|
const assignedAt = booking.customerTruckAssignedAt
|
||||||
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
|
||||||
: '-';
|
: '-';
|
||||||
const rows: Array<[string, string | null | undefined]> = [
|
const bookingRows: Array<[string, string | null | undefined]> = [
|
||||||
['Booking Reference', booking.reference],
|
['Booking Reference', booking.reference],
|
||||||
['Client Name', booking.company?.name],
|
['Client Name', booking.company?.name],
|
||||||
['Client ID', booking.companyId],
|
['Client ID', booking.companyId],
|
||||||
['Trade Direction', booking.tradeDirection],
|
['Trade Direction', booking.tradeDirection],
|
||||||
['Freight Type', booking.freightType],
|
['Freight Type', booking.freightType],
|
||||||
['Truck Plate Number', booking.customerTruckPlateNumber],
|
|
||||||
['Driver Name', booking.customerTruckDriverName],
|
|
||||||
['Truck Type', booking.customerTruckType],
|
|
||||||
['Container Number to Load', booking.customerTruckContainerNumber],
|
|
||||||
['Assigned At', assignedAt],
|
['Assigned At', assignedAt],
|
||||||
['Booking Status', booking.status],
|
['Booking Status', booking.status],
|
||||||
];
|
];
|
||||||
const rowHtml = rows
|
const bookingRowHtml = bookingRows
|
||||||
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
|
// Fall back to the legacy single-truck booking columns when there are no
|
||||||
|
// multi-truck rows (bookings assigned before the multi-truck feature).
|
||||||
|
const truckList =
|
||||||
|
trucks.length > 0
|
||||||
|
? trucks
|
||||||
|
: booking.customerTruckPlateNumber
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
plateNumber: booking.customerTruckPlateNumber,
|
||||||
|
driverName: booking.customerTruckDriverName ?? '',
|
||||||
|
truckType: booking.customerTruckType ?? '',
|
||||||
|
arrivedAt: booking.customerTruckArrivedAt
|
||||||
|
? String(booking.customerTruckArrivedAt)
|
||||||
|
: null,
|
||||||
|
containers: booking.customerTruckContainerNumber ?? null,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const truckBlocks = truckList
|
||||||
|
.map((t, i) => {
|
||||||
|
const rows: Array<[string, string | null | undefined]> = [
|
||||||
|
['Truck Plate Number', t.plateNumber],
|
||||||
|
['Driver Name', t.driverName],
|
||||||
|
['Truck Type', t.truckType],
|
||||||
|
['Containers Loaded', t.containers],
|
||||||
|
[
|
||||||
|
'Arrival',
|
||||||
|
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
const html = rows
|
||||||
|
.map(
|
||||||
|
([label, value]) =>
|
||||||
|
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
const copy = (watermark: string) => `
|
const copy = (watermark: string) => `
|
||||||
<section class="copy">
|
<section class="copy">
|
||||||
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
<div class="watermark">${this.escapeHtml(watermark)}</div>
|
||||||
<header>
|
<header>
|
||||||
<div>
|
<div>
|
||||||
<h1>Freight Order</h1>
|
<h1>Freight Order</h1>
|
||||||
<p>Customer external truck assignment</p>
|
<p>Customer external truck assignment — ${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<strong>${this.escapeHtml(booking.reference)}</strong>
|
<strong>${this.escapeHtml(booking.reference)}</strong>
|
||||||
</header>
|
</header>
|
||||||
<table>${rowHtml}</table>
|
<table>${bookingRowHtml}</table>
|
||||||
|
${truckBlocks}
|
||||||
<div class="signatures">
|
<div class="signatures">
|
||||||
<div>Customer / Carrier Signature</div>
|
<div>Customer / Carrier Signature</div>
|
||||||
<div>Port Operations Verification</div>
|
<div>Port Operations Verification</div>
|
||||||
@@ -238,11 +307,13 @@ export class BookingsService {
|
|||||||
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
|
||||||
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
|
||||||
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
|
||||||
|
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
|
||||||
p { margin: 4px 0 0; color: #64748b; }
|
p { margin: 4px 0 0; color: #64748b; }
|
||||||
strong { font-size: 16px; color: #0a9f6a; }
|
strong { font-size: 16px; color: #0a9f6a; }
|
||||||
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
|
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
|
||||||
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
|
||||||
th { width: 34%; background: #f1f5f9; }
|
th { width: 34%; background: #f1f5f9; }
|
||||||
|
.truck { page-break-inside: avoid; }
|
||||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
|
||||||
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
export interface ReceivedUnitRow {
|
||||||
|
id: string;
|
||||||
|
containerNumber: string;
|
||||||
|
receivedToPort: boolean;
|
||||||
|
receivedAt: string | null;
|
||||||
|
grnNumber: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-container receive + GRN tracking on booking_container_units.
|
||||||
|
*
|
||||||
|
* Containers arrive individually (on separate self-haul trucks), so each unit is
|
||||||
|
* flipped `received_to_port` when its truck arrives (auto). Staff then confirm a
|
||||||
|
* Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a
|
||||||
|
* batch, so if the whole booking arrives together every unit shares a single GRN
|
||||||
|
* (per-booking GRN); if trucks arrive separately each batch gets its own GRN.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ContainerReceiptService {
|
||||||
|
constructor(private readonly dataSource: DataSource) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-mark the containers loaded on an arrived truck as received into the
|
||||||
|
* port. Idempotent — only flips units not already received. Runs inside the
|
||||||
|
* caller's transaction when a manager is supplied.
|
||||||
|
*/
|
||||||
|
async markReceivedForAssignment(
|
||||||
|
bookingId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
await m.query(
|
||||||
|
`UPDATE freight.booking_container_units bcu
|
||||||
|
SET received_to_port = true,
|
||||||
|
received_at = COALESCE(bcu.received_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM freight.booking_containers bc,
|
||||||
|
freight.customer_truck_containers ctc
|
||||||
|
WHERE bc.id = bcu.booking_container_id
|
||||||
|
AND bc.booking_id = $1
|
||||||
|
AND ctc.assignment_id = $2
|
||||||
|
AND ctc.deleted_at IS NULL
|
||||||
|
AND ctc.container_number = bcu.container_number
|
||||||
|
AND bcu.deleted_at IS NULL
|
||||||
|
AND bcu.received_to_port = false`,
|
||||||
|
[bookingId, assignmentId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Received-into-port containers that have not yet been assigned a GRN. */
|
||||||
|
async listReceivedPendingGrn(bookingId: string): Promise<ReceivedUnitRow[]> {
|
||||||
|
return this.dataSource.query(
|
||||||
|
`SELECT bcu.id,
|
||||||
|
bcu.container_number AS "containerNumber",
|
||||||
|
bcu.received_to_port AS "receivedToPort",
|
||||||
|
bcu.received_at AS "receivedAt",
|
||||||
|
bcu.grn_number AS "grnNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND bcu.deleted_at IS NULL
|
||||||
|
AND bcu.received_to_port = true
|
||||||
|
AND bcu.grn_number IS NULL
|
||||||
|
ORDER BY bcu.received_at`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a GRN over the currently received-but-un-GRN'd containers (optionally
|
||||||
|
* a subset by container number). Assigns one GRN number to the whole batch and
|
||||||
|
* returns it with the covered containers. If the batch covers every container
|
||||||
|
* on the booking it is effectively a per-booking GRN.
|
||||||
|
*/
|
||||||
|
async generateGrn(
|
||||||
|
bookingId: string,
|
||||||
|
containerNumbers?: string[],
|
||||||
|
): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> {
|
||||||
|
const [booking] = await this.dataSource.query(
|
||||||
|
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
|
||||||
|
return this.dataSource.transaction(async (manager) => {
|
||||||
|
const wanted = containerNumbers?.map((n) => n.trim().toUpperCase());
|
||||||
|
const pending: ReceivedUnitRow[] = await manager.query(
|
||||||
|
`SELECT bcu.id, bcu.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1
|
||||||
|
AND bcu.deleted_at IS NULL
|
||||||
|
AND bcu.received_to_port = true
|
||||||
|
AND bcu.grn_number IS NULL
|
||||||
|
${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`,
|
||||||
|
wanted ? [bookingId, wanted] : [bookingId],
|
||||||
|
);
|
||||||
|
if (!pending.length) {
|
||||||
|
throw new BadRequestException('No received containers are awaiting a GRN');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch sequence = number of GRNs already issued for this booking + 1.
|
||||||
|
const [{ batches }]: Array<{ batches: string }> = await manager.query(
|
||||||
|
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
const seq = Number(batches) + 1;
|
||||||
|
const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const ids = pending.map((p) => p.id);
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.booking_container_units
|
||||||
|
SET grn_number = $1, updated_at = NOW()
|
||||||
|
WHERE id = ANY($2::uuid[])`,
|
||||||
|
[grnNumber, ids],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Per-booking when no container on the booking is left un-GRN'd.
|
||||||
|
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
|
||||||
|
`SELECT COUNT(*) AS remaining
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
grnNumber,
|
||||||
|
containerNumbers: pending.map((p) => p.containerNumber),
|
||||||
|
perBooking: Number(remaining) === 0 && seq === 1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { BaseRepository } from '@edr/api-common';
|
||||||
|
|
||||||
|
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerTruckAssignmentsRepository extends BaseRepository<CustomerTruckAssignment> {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(CustomerTruckAssignment)
|
||||||
|
private readonly repo: Repository<CustomerTruckAssignment>,
|
||||||
|
) {
|
||||||
|
super(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All trucks assigned to a booking, oldest first, with their containers. */
|
||||||
|
findByBookingId(bookingId: string): Promise<CustomerTruckAssignment[]> {
|
||||||
|
return this.repo.find({
|
||||||
|
where: { bookingId },
|
||||||
|
relations: { containers: true },
|
||||||
|
order: { assignedAt: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findByIdWithContainers(id: string): Promise<CustomerTruckAssignment | null> {
|
||||||
|
return this.repo.findOne({ where: { id }, relations: { containers: true } });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ConflictException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||||
|
|
||||||
|
import { AddCustomerTruckDto } from './dto/add-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 { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||||
|
|
||||||
|
interface BookingGuardRow {
|
||||||
|
tradeDirection: string | null;
|
||||||
|
firstMile: string | null;
|
||||||
|
lastMile: string | null;
|
||||||
|
paymentStatus: string | null;
|
||||||
|
status: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg
|
||||||
|
* can have several customer trucks, each carrying 1–2 of its containers and
|
||||||
|
* tracking its own arrival. The legacy booking.customer_truck_* columns are kept
|
||||||
|
* as a booking-level flag (any truck assigned / all arrived) so the warehouse
|
||||||
|
* exit-gate + delivery-approval logic keep working unchanged.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CustomerTruckService {
|
||||||
|
constructor(
|
||||||
|
private readonly dataSource: DataSource,
|
||||||
|
private readonly assignments: CustomerTruckAssignmentsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
|
||||||
|
return this.assignments.findByBookingId(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
|
||||||
|
const booking = await this.loadBookingGuard(bookingId);
|
||||||
|
this.assertSelfHaulPaid(booking);
|
||||||
|
|
||||||
|
const isExport = booking.tradeDirection === 'EXPORT';
|
||||||
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
|
||||||
|
// EXPORT: the truck delivers 1–2 known containers. IMPORT: containers are
|
||||||
|
// not pre-specified — they are registered + weighed when the truck leaves.
|
||||||
|
if (isExport) {
|
||||||
|
if (requested.length < 1 || requested.length > 2) {
|
||||||
|
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
|
||||||
|
}
|
||||||
|
} else if (requested.length > 2) {
|
||||||
|
throw new BadRequestException('A truck carries at most 2 containers');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requested.length) {
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (!bookingNumbers.includes(n)) {
|
||||||
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (alreadyAssigned.includes(n)) {
|
||||||
|
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
const assignment = await manager.getRepository(CustomerTruckAssignment).save(
|
||||||
|
manager.getRepository(CustomerTruckAssignment).create({
|
||||||
|
bookingId,
|
||||||
|
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
|
||||||
|
driverName: dto.driverName.trim(),
|
||||||
|
truckType: dto.truckType.trim(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
|
requested.map((containerNumber) =>
|
||||||
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
|
assignmentId: assignment.id,
|
||||||
|
bookingId,
|
||||||
|
containerNumber,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Booking-level flag: first truck marks the booking as truck-assigned.
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.bookings
|
||||||
|
SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()),
|
||||||
|
status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.listTrucks(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeTruck(bookingId: string, assignmentId: string): Promise<CustomerTruckAssignment[]> {
|
||||||
|
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||||
|
if (!assignment || assignment.bookingId !== bookingId) {
|
||||||
|
throw new NotFoundException('Truck assignment not found for this booking');
|
||||||
|
}
|
||||||
|
if (assignment.arrivedAt) {
|
||||||
|
throw new ConflictException('Cannot remove a truck that has already arrived');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId);
|
||||||
|
const remaining = await manager
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.count({ where: { bookingId } });
|
||||||
|
if (remaining === 0) {
|
||||||
|
// No trucks left — clear the booking-level flag and revert the status.
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.bookings
|
||||||
|
SET customer_truck_assigned_at = NULL,
|
||||||
|
status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.listTrucks(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an IMPORT self-haul truck leaving the port: the containers it
|
||||||
|
* actually loaded (replacing any provisional list) and its weighed gross.
|
||||||
|
* Export bookings have no truck departure — trucks only deliver (receive).
|
||||||
|
*/
|
||||||
|
async departTruck(
|
||||||
|
bookingId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
dto: DepartCustomerTruckDto,
|
||||||
|
): Promise<CustomerTruckAssignment[]> {
|
||||||
|
const booking = await this.loadBookingGuard(bookingId);
|
||||||
|
if (booking.tradeDirection !== 'IMPORT') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Truck departure/weighing applies to import self-haul only (export trucks only deliver)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
|
||||||
|
if (!assignment || assignment.bookingId !== bookingId) {
|
||||||
|
throw new NotFoundException('Truck assignment not found for this booking');
|
||||||
|
}
|
||||||
|
// Once filled, the departure record is uneditable.
|
||||||
|
if (assignment.departedAt) {
|
||||||
|
throw new ConflictException('This truck has already departed — its exit record is locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
|
||||||
|
if (requested.length) {
|
||||||
|
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (!bookingNumbers.includes(n)) {
|
||||||
|
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
|
||||||
|
for (const n of requested) {
|
||||||
|
if (elsewhere.includes(n)) {
|
||||||
|
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dataSource.transaction(async (manager) => {
|
||||||
|
if (requested.length) {
|
||||||
|
// Replace the truck's containers with what was actually loaded.
|
||||||
|
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
|
||||||
|
await manager.getRepository(CustomerTruckContainer).save(
|
||||||
|
requested.map((containerNumber) =>
|
||||||
|
manager.getRepository(CustomerTruckContainer).create({
|
||||||
|
assignmentId,
|
||||||
|
bookingId,
|
||||||
|
containerNumber,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
|
||||||
|
grossWeightKg: dto.grossWeightKg,
|
||||||
|
departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(),
|
||||||
|
arrivedAt: assignment.arrivedAt ?? new Date(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.listTrucks(bookingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
|
||||||
|
* receive flow. When every truck on the booking has arrived, the booking-level
|
||||||
|
* customer_truck_arrived_at flag is stamped (used by the delivery-approval
|
||||||
|
* gate). No-op when the container is not on any customer truck.
|
||||||
|
*/
|
||||||
|
async markArrivedByContainer(
|
||||||
|
bookingId: string,
|
||||||
|
containerNumber: string,
|
||||||
|
manager?: EntityManager,
|
||||||
|
): Promise<void> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
const cn = containerNumber.trim().toUpperCase();
|
||||||
|
const container = await m.getRepository(CustomerTruckContainer).findOne({
|
||||||
|
where: { bookingId, containerNumber: cn },
|
||||||
|
});
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
await m
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||||||
|
|
||||||
|
await this.syncBookingArrival(bookingId, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark every truck on the booking arrived (fallback when no container is known). */
|
||||||
|
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
|
||||||
|
const m = manager ?? this.dataSource.manager;
|
||||||
|
await m
|
||||||
|
.getRepository(CustomerTruckAssignment)
|
||||||
|
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
|
||||||
|
await this.syncBookingArrival(bookingId, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp the booking-level arrival flag on the FIRST truck arrival. The import
|
||||||
|
* handover is signed once, before the first truck leaves, even though trucks
|
||||||
|
* pick up per-container — so the flag fires on the first arrival (COALESCE
|
||||||
|
* keeps it), not once all trucks have arrived.
|
||||||
|
*/
|
||||||
|
private async syncBookingArrival(bookingId: string, m: EntityManager): Promise<void> {
|
||||||
|
await m.query(
|
||||||
|
`UPDATE freight.bookings
|
||||||
|
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
|
||||||
|
const [row]: BookingGuardRow[] = await this.dataSource.query(
|
||||||
|
`SELECT trade_direction AS "tradeDirection",
|
||||||
|
first_mile_pickup_address AS "firstMile",
|
||||||
|
last_mile_delivery_address AS "lastMile",
|
||||||
|
payment_status AS "paymentStatus",
|
||||||
|
status
|
||||||
|
FROM freight.bookings
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertSelfHaulPaid(booking: BookingGuardRow): void {
|
||||||
|
const hasFirstMile = Boolean(booking.firstMile?.trim());
|
||||||
|
const hasLastMile = Boolean(booking.lastMile?.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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (booking.paymentStatus !== 'PAID') {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Booking must be paid before assigning an external customer truck',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT bcu.container_number AS "containerNumber"
|
||||||
|
FROM freight.booking_container_units bcu
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assignedContainerNumbers(bookingId: string): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT container_number AS "containerNumber"
|
||||||
|
FROM freight.customer_truck_containers
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assignedContainerNumbersExcept(
|
||||||
|
bookingId: string,
|
||||||
|
exceptAssignmentId: string,
|
||||||
|
): Promise<string[]> {
|
||||||
|
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
|
||||||
|
`SELECT container_number AS "containerNumber"
|
||||||
|
FROM freight.customer_truck_containers
|
||||||
|
WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`,
|
||||||
|
[bookingId, exceptAssignmentId],
|
||||||
|
);
|
||||||
|
return rows.map((r) => r.containerNumber.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add one external customer truck to a booking.
|
||||||
|
* - EXPORT: the truck delivers 1–2 known containers (required, validated in the
|
||||||
|
* service against the booking's containers).
|
||||||
|
* - IMPORT: the customer does not pre-specify — containers are registered and
|
||||||
|
* weighed when the truck leaves, so `containerNumbers` may be omitted/empty.
|
||||||
|
*/
|
||||||
|
export class AddCustomerTruckDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(32)
|
||||||
|
truckPlateNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
@MaxLength(120)
|
||||||
|
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 number must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
|
containerNumbers?: string[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
|
ArrayUnique,
|
||||||
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register an import self-haul truck leaving the port: the containers it actually
|
||||||
|
* loaded (staff read them off the truck) and the weighed gross. Container numbers
|
||||||
|
* are optional here only because they may already have been recorded; the weighed
|
||||||
|
* gross is required.
|
||||||
|
*/
|
||||||
|
export class DepartCustomerTruckDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayMaxSize(2)
|
||||||
|
@ArrayUnique()
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
each: true,
|
||||||
|
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
|
containerNumbers?: string[];
|
||||||
|
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0)
|
||||||
|
grossWeightKg!: number;
|
||||||
|
|
||||||
|
/** Gate-out time. Defaults to now when omitted. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
gateOutTime?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Confirm a Goods Received Note. Omit `containerNumbers` to GRN every
|
||||||
|
* received-but-un-GRN'd container on the booking (per-booking when that's all of
|
||||||
|
* them); pass a subset to GRN just those.
|
||||||
|
*/
|
||||||
|
export class GenerateGrnDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@ArrayUnique()
|
||||||
|
@Matches(/^[A-Z]{4}\d{7}$/, {
|
||||||
|
each: true,
|
||||||
|
message: 'each container number must match ISO container format, e.g. ABCD1234567',
|
||||||
|
})
|
||||||
|
containerNumbers?: string[];
|
||||||
|
}
|
||||||
@@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity {
|
|||||||
|
|
||||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||||
sortOrder!: number;
|
sortOrder!: number;
|
||||||
|
|
||||||
|
/** Whether this container has been received into the port (auto-set when its
|
||||||
|
* self-haul truck arrives). */
|
||||||
|
@Column({ name: 'received_to_port', type: 'boolean', default: false })
|
||||||
|
receivedToPort!: boolean;
|
||||||
|
|
||||||
|
@Column({ name: 'received_at', type: 'timestamptz', nullable: true })
|
||||||
|
receivedAt?: Date | null;
|
||||||
|
|
||||||
|
/** The GRN this container was received under (assigned when staff confirm the
|
||||||
|
* Goods Received Note for a batch of received containers). */
|
||||||
|
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
|
||||||
|
grnNumber?: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||||
|
|
||||||
|
import { Booking } from './booking.entity';
|
||||||
|
import { CustomerTruckContainer } from './customer-truck-container.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One external (self-haul) truck a customer assigns to a booking that has no
|
||||||
|
* EDR first/last-mile leg. Each truck carries 1–2 containers and tracks its own
|
||||||
|
* arrival at the terminal/warehouse.
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'customer_truck_assignments' })
|
||||||
|
@Index(['bookingId'])
|
||||||
|
export class CustomerTruckAssignment extends BaseEntity {
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'booking_id' })
|
||||||
|
booking?: Booking;
|
||||||
|
|
||||||
|
@Column({ name: 'plate_number', type: 'varchar', length: 32 })
|
||||||
|
plateNumber!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'driver_name', type: 'varchar', length: 120 })
|
||||||
|
driverName!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'truck_type', type: 'varchar', length: 60 })
|
||||||
|
truckType!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' })
|
||||||
|
assignedAt!: Date;
|
||||||
|
|
||||||
|
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||||
|
arrivedAt?: Date | null;
|
||||||
|
|
||||||
|
/** Weighed gross of what the truck actually loaded (import), captured on
|
||||||
|
* leaving. Null until the truck departs. */
|
||||||
|
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
|
||||||
|
grossWeightKg?: number | null;
|
||||||
|
|
||||||
|
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||||
|
departedAt?: Date | null;
|
||||||
|
|
||||||
|
@OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true })
|
||||||
|
containers?: CustomerTruckContainer[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { BaseEntity } from '@edr/api-common';
|
||||||
|
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||||
|
|
||||||
|
import { CustomerTruckAssignment } from './customer-truck-assignment.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A container number loaded onto a customer truck. A container may be loaded
|
||||||
|
* onto exactly one truck per booking (enforced by a partial unique index on
|
||||||
|
* booking_id + container_number).
|
||||||
|
*/
|
||||||
|
@Entity({ schema: 'freight', name: 'customer_truck_containers' })
|
||||||
|
@Index(['assignmentId'])
|
||||||
|
export class CustomerTruckContainer extends BaseEntity {
|
||||||
|
@Column({ name: 'assignment_id', type: 'uuid' })
|
||||||
|
assignmentId!: string;
|
||||||
|
|
||||||
|
@ManyToOne(() => CustomerTruckAssignment, (a) => a.containers, { onDelete: 'CASCADE' })
|
||||||
|
@JoinColumn({ name: 'assignment_id' })
|
||||||
|
assignment?: CustomerTruckAssignment;
|
||||||
|
|
||||||
|
@Column({ name: 'booking_id', type: 'uuid' })
|
||||||
|
bookingId!: string;
|
||||||
|
|
||||||
|
@Column({ name: 'container_number', type: 'varchar', length: 64 })
|
||||||
|
containerNumber!: string;
|
||||||
|
}
|
||||||
@@ -1243,9 +1243,7 @@ export class TrainSchedulingService {
|
|||||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||||
this.assertImportDjiboutiGatepassGranted(operation);
|
this.assertImportDjiboutiGatepassGranted(operation);
|
||||||
if (!operation.loadedOnTrainAt) {
|
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
|
||||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||||||
await this.dispatchSchedule(schedule.id);
|
await this.dispatchSchedule(schedule.id);
|
||||||
@@ -1622,9 +1620,9 @@ export class TrainSchedulingService {
|
|||||||
where: { trainScheduleId: schedule.id },
|
where: { trainScheduleId: schedule.id },
|
||||||
});
|
});
|
||||||
this.assertImportDjiboutiGatepassGranted(operation);
|
this.assertImportDjiboutiGatepassGranted(operation);
|
||||||
if (!operation?.loadedOnTrainAt) {
|
// Loading confirmation does NOT gate dispatch. Per-booking loading is
|
||||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
// tracking only and the loaded-on-train step is optional — a scheduled train
|
||||||
}
|
// dispatches without waiting on loading.
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||||
|
|||||||
@@ -920,6 +920,23 @@ export class WarehouseInventoryService {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Receiving the booking flags every container unit as received into the
|
||||||
|
// port (self-haul export: the delivering truck's goods are now in) so
|
||||||
|
// staff can raise the per-container GRN over what's received.
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.booking_container_units bcu
|
||||||
|
SET received_to_port = true,
|
||||||
|
received_at = COALESCE(bcu.received_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM freight.booking_containers bc
|
||||||
|
WHERE bc.id = bcu.booking_container_id
|
||||||
|
AND bc.booking_id = $1
|
||||||
|
AND bc.deleted_at IS NULL
|
||||||
|
AND bcu.deleted_at IS NULL
|
||||||
|
AND bcu.received_to_port = false`,
|
||||||
|
[bookingId],
|
||||||
|
);
|
||||||
|
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
activityType: 'INVENTORY_RECEIVED',
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
@@ -1774,6 +1791,26 @@ export class WarehouseInventoryService {
|
|||||||
|
|
||||||
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
|
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
|
||||||
|
|
||||||
|
// Per-container receive: flag this container's unit as received into the
|
||||||
|
// port so staff can raise the GRN over what's received.
|
||||||
|
if (dto.bookingId && dto.containerId) {
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.booking_container_units bcu
|
||||||
|
SET received_to_port = true,
|
||||||
|
received_at = COALESCE(bcu.received_at, NOW()),
|
||||||
|
updated_at = NOW()
|
||||||
|
FROM freight.booking_containers bc, freight.containers cont
|
||||||
|
WHERE bc.id = bcu.booking_container_id
|
||||||
|
AND bc.booking_id = $1
|
||||||
|
AND bc.deleted_at IS NULL
|
||||||
|
AND cont.id = $2
|
||||||
|
AND cont.container_number = bcu.container_number
|
||||||
|
AND bcu.deleted_at IS NULL
|
||||||
|
AND bcu.received_to_port = false`,
|
||||||
|
[dto.bookingId, dto.containerId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await this.activityLog.record(
|
await this.activityLog.record(
|
||||||
{
|
{
|
||||||
activityType: 'INVENTORY_RECEIVED',
|
activityType: 'INVENTORY_RECEIVED',
|
||||||
@@ -2068,6 +2105,29 @@ export class WarehouseInventoryService {
|
|||||||
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
|
||||||
});
|
});
|
||||||
if (!isTruckLeaving && item.bookingId) {
|
if (!isTruckLeaving && item.bookingId) {
|
||||||
|
// Per-truck arrival: mark the customer truck carrying THIS item's
|
||||||
|
// container as arrived (matched via the physical container number).
|
||||||
|
if (item.containerId) {
|
||||||
|
await manager.query(
|
||||||
|
`UPDATE freight.customer_truck_assignments a
|
||||||
|
SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
|
||||||
|
FROM freight.customer_truck_containers c
|
||||||
|
JOIN freight.containers cont ON cont.container_number = c.container_number
|
||||||
|
WHERE c.assignment_id = a.id
|
||||||
|
AND c.deleted_at IS NULL
|
||||||
|
AND c.booking_id = $1
|
||||||
|
AND cont.id = $2
|
||||||
|
AND a.arrived_at IS NULL
|
||||||
|
AND a.deleted_at IS NULL`,
|
||||||
|
[item.bookingId, item.containerId],
|
||||||
|
);
|
||||||
|
// NB: import arrival changes nothing on the goods — received_to_port is
|
||||||
|
// an EXPORT concept (set when a truck delivers into the port). Import
|
||||||
|
// load + weight are captured on truck departure, not arrival.
|
||||||
|
}
|
||||||
|
// Booking-level flag stamped on the FIRST truck arrival. The import
|
||||||
|
// handover is signed ONCE (before the first truck leaves), even though
|
||||||
|
// trucks pick up per-container — COALESCE keeps the first timestamp.
|
||||||
await manager.query(
|
await manager.query(
|
||||||
`UPDATE freight.bookings
|
`UPDATE freight.bookings
|
||||||
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
|
||||||
@@ -2147,6 +2207,48 @@ export class WarehouseInventoryService {
|
|||||||
}
|
}
|
||||||
await this.invoices.assertClearanceAllowed(id);
|
await this.invoices.assertClearanceAllowed(id);
|
||||||
|
|
||||||
|
// Import self-haul: the exit paper names the pickup truck + all containers it
|
||||||
|
// carries, so gate staff can verify the goods leaving on that truck.
|
||||||
|
let truck: {
|
||||||
|
plateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
truckType: string;
|
||||||
|
containerNumbers: string;
|
||||||
|
truckWeightTons: string | number | null;
|
||||||
|
grossWeightKg: string | number | null;
|
||||||
|
departedAt: string | null;
|
||||||
|
} | null = null;
|
||||||
|
if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
|
||||||
|
const [truckRow] = await this.dataSource.query(
|
||||||
|
`SELECT a.plate_number AS "plateNumber",
|
||||||
|
a.driver_name AS "driverName",
|
||||||
|
a.truck_type AS "truckType",
|
||||||
|
a.gross_weight_kg AS "grossWeightKg",
|
||||||
|
a.departed_at AS "departedAt",
|
||||||
|
string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers",
|
||||||
|
COALESCE((
|
||||||
|
SELECT SUM(bcu.vgm_tons)
|
||||||
|
FROM freight.customer_truck_containers cc
|
||||||
|
JOIN freight.booking_container_units bcu
|
||||||
|
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
|
||||||
|
JOIN freight.booking_containers bc
|
||||||
|
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||||
|
AND bc.booking_id = c.booking_id
|
||||||
|
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
|
||||||
|
), 0) AS "truckWeightTons"
|
||||||
|
FROM freight.customer_truck_containers c
|
||||||
|
JOIN freight.customer_truck_assignments a
|
||||||
|
ON a.id = c.assignment_id AND a.deleted_at IS NULL
|
||||||
|
JOIN freight.customer_truck_containers c2
|
||||||
|
ON c2.assignment_id = a.id AND c2.deleted_at IS NULL
|
||||||
|
WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL
|
||||||
|
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id
|
||||||
|
LIMIT 1`,
|
||||||
|
[row.bookingId, row.containerNumber],
|
||||||
|
);
|
||||||
|
truck = truckRow ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
const bookingReference = row?.bookingReference || 'N/A';
|
const bookingReference = row?.bookingReference || 'N/A';
|
||||||
const reference =
|
const reference =
|
||||||
row?.releaseOrderReference ||
|
row?.releaseOrderReference ||
|
||||||
@@ -2170,6 +2272,17 @@ export class WarehouseInventoryService {
|
|||||||
inventoryStatus: row?.status ?? null,
|
inventoryStatus: row?.status ?? null,
|
||||||
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
||||||
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
|
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
|
||||||
|
truckPlateNumber: truck?.plateNumber ?? null,
|
||||||
|
truckDriverName: truck?.driverName ?? null,
|
||||||
|
truckType: truck?.truckType ?? null,
|
||||||
|
truckGateOut: truck?.departedAt ?? null,
|
||||||
|
// Prefer the weighed gross captured on departure; fall back to the summed
|
||||||
|
// container VGM when the truck hasn't been weighed yet.
|
||||||
|
truckWeightKg: truck
|
||||||
|
? Number(truck.grossWeightKg ?? 0) > 0
|
||||||
|
? Number(truck.grossWeightKg)
|
||||||
|
: Number(truck.truckWeightTons ?? 0) * 1000
|
||||||
|
: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -3099,6 +3212,11 @@ export class WarehouseInventoryService {
|
|||||||
inventoryStatus: string | null;
|
inventoryStatus: string | null;
|
||||||
clearanceStatus: string;
|
clearanceStatus: string;
|
||||||
exitInspectionSummary?: string | null;
|
exitInspectionSummary?: string | null;
|
||||||
|
truckPlateNumber?: string | null;
|
||||||
|
truckDriverName?: string | null;
|
||||||
|
truckType?: string | null;
|
||||||
|
truckGateOut?: string | null;
|
||||||
|
truckWeightKg?: number | null;
|
||||||
}): string {
|
}): string {
|
||||||
const esc = (value: unknown) =>
|
const esc = (value: unknown) =>
|
||||||
String(value ?? '-')
|
String(value ?? '-')
|
||||||
@@ -3123,12 +3241,37 @@ export class WarehouseInventoryService {
|
|||||||
['Container Number', data.containerNumber],
|
['Container Number', data.containerNumber],
|
||||||
['Cargo / Goods Description', data.cargoDescription],
|
['Cargo / Goods Description', data.cargoDescription],
|
||||||
['Quantity', data.quantity],
|
['Quantity', data.quantity],
|
||||||
['Declared Weight', `${data.weight.toLocaleString()} kg`],
|
[
|
||||||
|
data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight',
|
||||||
|
`${(data.truckPlateNumber && data.truckWeightKg
|
||||||
|
? data.truckWeightKg
|
||||||
|
: data.weight
|
||||||
|
).toLocaleString()} kg`,
|
||||||
|
],
|
||||||
['Warehouse', data.warehouse],
|
['Warehouse', data.warehouse],
|
||||||
['Yard', data.yard],
|
['Yard', data.yard],
|
||||||
['Zone', data.zone],
|
['Zone', data.zone],
|
||||||
['Inventory Status', data.inventoryStatus],
|
['Inventory Status', data.inventoryStatus],
|
||||||
['Clearance Status', data.clearanceStatus],
|
['Clearance Status', data.clearanceStatus],
|
||||||
|
...(data.truckPlateNumber
|
||||||
|
? ([
|
||||||
|
['Pickup Truck Plate', data.truckPlateNumber],
|
||||||
|
['Truck Driver', data.truckDriverName],
|
||||||
|
['Truck Type', data.truckType],
|
||||||
|
[
|
||||||
|
'Gate-Out Time',
|
||||||
|
data.truckGateOut
|
||||||
|
? new Date(data.truckGateOut).toLocaleString('en-GB', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
] as [string, string | null][])
|
||||||
|
: []),
|
||||||
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
|
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -107,6 +107,9 @@ export const URL_CONSTANTS = {
|
|||||||
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
|
CONTRACT_DOWNLOAD: (id: string) => `/api/bookings/${id}/contract`,
|
||||||
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
CANCEL: (id: string | number) => `/api/bookings/${id}/cancel`,
|
||||||
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
CONFIRM: (id: string | number) => `/api/bookings/${id}/confirm`,
|
||||||
|
CUSTOMER_TRUCKS: (id: string) => `/api/bookings/${id}/customer-trucks`,
|
||||||
|
CUSTOMER_TRUCK: (id: string, assignmentId: string) =>
|
||||||
|
`/api/bookings/${id}/customer-trucks/${assignmentId}`,
|
||||||
},
|
},
|
||||||
|
|
||||||
CONTRACTS: {
|
CONTRACTS: {
|
||||||
|
|||||||
@@ -1,15 +1,30 @@
|
|||||||
import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
|
import {
|
||||||
import { useMutation } from "@tanstack/react-query";
|
ActionIcon,
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Divider,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
MultiSelect,
|
||||||
|
Select,
|
||||||
|
SimpleGrid,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import { Download, Lock, Truck } from "lucide-react";
|
import { CheckCircle2, Clock, Download, Plus, Trash2, Truck } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
|
import { customerTrucksService } from "@/services/customer-trucks.service";
|
||||||
|
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
|
||||||
const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/;
|
|
||||||
|
|
||||||
const downloadBlob = (blob: Blob, filename: string) => {
|
const downloadBlob = (blob: Blob, filename: string) => {
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -22,6 +37,13 @@ const downloadBlob = (blob: Blob, filename: string) => {
|
|||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const errorMessage = (error: unknown, fallback: string) => {
|
||||||
|
const data = (error as { response?: { data?: { message?: string | string[] } } })?.response?.data;
|
||||||
|
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||||
|
if (data?.message) return data.message;
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
};
|
||||||
|
|
||||||
export function CustomerTruckAssignmentCard({
|
export function CustomerTruckAssignmentCard({
|
||||||
booking,
|
booking,
|
||||||
onAssigned,
|
onAssigned,
|
||||||
@@ -29,48 +51,86 @@ export function CustomerTruckAssignmentCard({
|
|||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
onAssigned: () => void;
|
onAssigned: () => void;
|
||||||
}) {
|
}) {
|
||||||
const assigned = Boolean(booking.customerTruckAssignedAt);
|
const queryClient = useQueryClient();
|
||||||
const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? "");
|
const trucksKey = ["customer-trucks", booking.id];
|
||||||
const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? "");
|
|
||||||
const [truckType, setTruckType] = useState(booking.customerTruckType ?? "");
|
const { data: trucks = [], isLoading } = useQuery({
|
||||||
const [containerNumberToLoad, setContainerNumberToLoad] = useState(
|
queryKey: trucksKey,
|
||||||
booking.customerTruckContainerNumber ?? "",
|
queryFn: () => customerTrucksService.list(booking.id),
|
||||||
);
|
});
|
||||||
|
|
||||||
|
const [plateNumber, setPlateNumber] = useState("");
|
||||||
|
const [driverName, setDriverName] = useState("");
|
||||||
|
const [truckType, setTruckType] = useState("");
|
||||||
|
const [containers, setContainers] = useState<string[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Physical container numbers on this booking — the customer picks which one to
|
// Container numbers on the booking that aren't already loaded onto a truck.
|
||||||
// load onto the truck instead of typing it. Falls back to free entry when the
|
const assignedNumbers = new Set(
|
||||||
// booking has no container numbers recorded.
|
trucks.flatMap((t) => (t.containers ?? []).map((c) => c.containerNumber)),
|
||||||
const containerOptions = booking.containerNumbers ?? [];
|
);
|
||||||
|
const availableContainers = (booking.containerNumbers ?? []).filter(
|
||||||
|
(n) => !assignedNumbers.has(n),
|
||||||
|
);
|
||||||
|
|
||||||
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
|
// EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
|
||||||
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
// staff register + weigh what was loaded when the truck leaves.
|
||||||
|
const isExport = booking.tradeDirection === "EXPORT";
|
||||||
|
|
||||||
const submit = async () => {
|
const resetForm = () => {
|
||||||
const payload = {
|
setPlateNumber("");
|
||||||
truckPlateNumber: truckPlateNumber.trim().toUpperCase(),
|
setDriverName("");
|
||||||
driverName: driverName.trim(),
|
setTruckType("");
|
||||||
truckType: truckType.trim(),
|
setContainers([]);
|
||||||
containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(),
|
|
||||||
};
|
|
||||||
if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) {
|
|
||||||
setError("All truck assignment fields are required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) {
|
|
||||||
setError("Container number must match ISO format, e.g. ABCD1234567.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError(null);
|
setError(null);
|
||||||
await assignMutation.mutateAsync({ id: booking.id, payload });
|
|
||||||
onAssigned();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
customerTrucksService.add(booking.id, {
|
||||||
|
truckPlateNumber: plateNumber.trim().toUpperCase(),
|
||||||
|
driverName: driverName.trim(),
|
||||||
|
truckType: truckType.trim(),
|
||||||
|
// Import: containers are registered + weighed on departure, not here.
|
||||||
|
containerNumbers: isExport ? containers : [],
|
||||||
|
}),
|
||||||
|
onSuccess: (list) => {
|
||||||
|
queryClient.setQueryData(trucksKey, list);
|
||||||
|
resetForm();
|
||||||
|
onAssigned();
|
||||||
|
toast.success("Truck added");
|
||||||
|
},
|
||||||
|
onError: (e) => setError(errorMessage(e, "Could not add truck")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (assignmentId: string) => customerTrucksService.remove(booking.id, assignmentId),
|
||||||
|
onSuccess: (list) => {
|
||||||
|
queryClient.setQueryData(trucksKey, list);
|
||||||
|
onAssigned();
|
||||||
|
},
|
||||||
|
onError: (e) => toast.error(errorMessage(e, "Could not remove truck")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
|
||||||
const downloadFreightOrder = async () => {
|
const downloadFreightOrder = async () => {
|
||||||
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
const blob = await downloadMutation.mutateAsync({ id: booking.id });
|
||||||
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const submitAdd = () => {
|
||||||
|
if (!plateNumber.trim() || !driverName.trim() || !truckType.trim()) {
|
||||||
|
setError("Plate number, driver name and truck type are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isExport && (containers.length < 1 || containers.length > 2)) {
|
||||||
|
setError("Select 1 or 2 container numbers for this truck.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
addMutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -79,78 +139,135 @@ export function CustomerTruckAssignmentCard({
|
|||||||
<Truck size={18} color="#0a9f6a" />
|
<Truck size={18} color="#0a9f6a" />
|
||||||
<CardTitle>External Truck Assignment</CardTitle>
|
<CardTitle>External Truck Assignment</CardTitle>
|
||||||
</Group>
|
</Group>
|
||||||
{assigned && (
|
{trucks.length > 0 && (
|
||||||
<Group gap={6} c="#0a9f6a">
|
<Text size="sm" fw={700} c="#0a9f6a">
|
||||||
<Lock size={14} />
|
{trucks.length} truck{trucks.length !== 1 ? "s" : ""}
|
||||||
<Text size="sm" fw={700}>
|
</Text>
|
||||||
Truck Assigned
|
|
||||||
</Text>
|
|
||||||
</Group>
|
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{/* Assigned trucks */}
|
||||||
|
{isLoading ? (
|
||||||
|
<Group justify="center" py="sm">
|
||||||
|
<Loader size="sm" />
|
||||||
|
</Group>
|
||||||
|
) : (
|
||||||
|
trucks.map((t) => (
|
||||||
|
<Group
|
||||||
|
key={t.id}
|
||||||
|
justify="space-between"
|
||||||
|
align="flex-start"
|
||||||
|
wrap="nowrap"
|
||||||
|
style={{ border: "1px solid #EEF2F6", borderRadius: 12, padding: "12px 14px" }}
|
||||||
|
>
|
||||||
|
<Stack gap={4} style={{ minWidth: 0 }}>
|
||||||
|
<Group gap={8} wrap="nowrap">
|
||||||
|
<Text fz="14px" fw={700} c="#10202F">
|
||||||
|
{t.plateNumber}
|
||||||
|
</Text>
|
||||||
|
{t.arrivedAt ? (
|
||||||
|
<Badge color="green" variant="light" leftSection={<CheckCircle2 size={12} />}>
|
||||||
|
Arrived
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge color="orange" variant="light" leftSection={<Clock size={12} />}>
|
||||||
|
Awaiting arrival
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
<Text fz="12.5px" c="#6B7C8E">
|
||||||
|
{t.driverName} · {t.truckType}
|
||||||
|
</Text>
|
||||||
|
<Group gap={6}>
|
||||||
|
{(t.containers ?? []).map((c) => (
|
||||||
|
<Badge key={c.id} variant="outline" color="gray">
|
||||||
|
{c.containerNumber}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
{!t.arrivedAt && (
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
color="red"
|
||||||
|
aria-label="Remove truck"
|
||||||
|
onClick={() => removeMutation.mutate(t.id)}
|
||||||
|
loading={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<Alert color="red" variant="light">
|
<Alert color="red" variant="light">
|
||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{assignMutation.isError && (
|
|
||||||
<Alert color="red" variant="light">
|
{/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
|
||||||
{assignMutation.error instanceof Error
|
{(isExport ? availableContainers.length > 0 : true) ? (
|
||||||
? assignMutation.error.message
|
<>
|
||||||
: "Truck assignment failed."}
|
<Divider label="Add a truck" labelPosition="center" />
|
||||||
</Alert>
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||||
|
<TextInput
|
||||||
|
label="Truck Plate Number"
|
||||||
|
required
|
||||||
|
value={plateNumber}
|
||||||
|
onChange={(e) => setPlateNumber(e.currentTarget.value.toUpperCase())}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Driver Name"
|
||||||
|
required
|
||||||
|
value={driverName}
|
||||||
|
onChange={(e) => setDriverName(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Truck Type"
|
||||||
|
required
|
||||||
|
data={TRUCK_TYPES}
|
||||||
|
value={truckType || null}
|
||||||
|
onChange={(value) => setTruckType(value ?? "")}
|
||||||
|
/>
|
||||||
|
{isExport && (
|
||||||
|
<MultiSelect
|
||||||
|
label="Containers to load (1–2)"
|
||||||
|
required
|
||||||
|
placeholder="Select container numbers"
|
||||||
|
data={availableContainers}
|
||||||
|
value={containers}
|
||||||
|
onChange={setContainers}
|
||||||
|
maxValues={2}
|
||||||
|
searchable
|
||||||
|
nothingFoundMessage="No unassigned containers"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SimpleGrid>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button
|
||||||
|
leftSection={<Plus size={16} />}
|
||||||
|
color="edr-green"
|
||||||
|
onClick={submitAdd}
|
||||||
|
loading={addMutation.isPending}
|
||||||
|
>
|
||||||
|
Add truck
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
trucks.length > 0 && (
|
||||||
|
<Text fz="12.5px" c="#9AA8B5">
|
||||||
|
All containers on this booking have been assigned to a truck.
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
{trucks.length > 0 && (
|
||||||
<TextInput
|
<Group justify="flex-end">
|
||||||
label="Truck Plate Number"
|
|
||||||
required
|
|
||||||
value={truckPlateNumber}
|
|
||||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
|
||||||
readOnly={assigned}
|
|
||||||
/>
|
|
||||||
<TextInput
|
|
||||||
label="Driver Name"
|
|
||||||
required
|
|
||||||
value={driverName}
|
|
||||||
onChange={(e) => setDriverName(e.currentTarget.value)}
|
|
||||||
readOnly={assigned}
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
label="Truck Type"
|
|
||||||
required
|
|
||||||
data={TRUCK_TYPES}
|
|
||||||
value={truckType || null}
|
|
||||||
onChange={(value) => setTruckType(value ?? "")}
|
|
||||||
disabled={assigned}
|
|
||||||
/>
|
|
||||||
{containerOptions.length > 0 ? (
|
|
||||||
<Select
|
|
||||||
label="Container Number to Load"
|
|
||||||
required
|
|
||||||
placeholder="Select a container from this booking"
|
|
||||||
data={containerOptions}
|
|
||||||
value={containerNumberToLoad || null}
|
|
||||||
onChange={(value) => setContainerNumberToLoad(value ?? "")}
|
|
||||||
searchable
|
|
||||||
disabled={assigned}
|
|
||||||
nothingFoundMessage="No matching container"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<TextInput
|
|
||||||
label="Container Number to Load"
|
|
||||||
required
|
|
||||||
value={containerNumberToLoad}
|
|
||||||
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
|
|
||||||
readOnly={assigned}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SimpleGrid>
|
|
||||||
|
|
||||||
<Group justify="flex-end">
|
|
||||||
{assigned ? (
|
|
||||||
<Button
|
<Button
|
||||||
|
variant="light"
|
||||||
leftSection={<Download size={16} />}
|
leftSection={<Download size={16} />}
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
onClick={downloadFreightOrder}
|
onClick={downloadFreightOrder}
|
||||||
@@ -158,12 +275,8 @@ export function CustomerTruckAssignmentCard({
|
|||||||
>
|
>
|
||||||
Generate Freight Order Copies
|
Generate Freight Order Copies
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
</Group>
|
||||||
<Button color="edr-green" onClick={submit} loading={assignMutation.isPending}>
|
)}
|
||||||
Verify & Submit Assignment
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import { client } from "../utils/api";
|
||||||
|
|
||||||
|
const B = URL_CONSTANTS.BOOKINGS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-truck self-haul assignment for a booking (no EDR first/last mile).
|
||||||
|
* Each truck carries 1–2 of the booking's containers and tracks its own arrival.
|
||||||
|
*/
|
||||||
|
export const customerTrucksService = {
|
||||||
|
list: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
|
||||||
|
const { data } = await client.get(B.CUSTOMER_TRUCKS(bookingId));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
add: async (
|
||||||
|
bookingId: string,
|
||||||
|
payload: Freight.AddCustomerTruckPayload,
|
||||||
|
): Promise<Freight.ICustomerTruck[]> => {
|
||||||
|
const { data } = await client.post(B.CUSTOMER_TRUCKS(bookingId), payload);
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
|
||||||
|
remove: async (
|
||||||
|
bookingId: string,
|
||||||
|
assignmentId: string,
|
||||||
|
): Promise<Freight.ICustomerTruck[]> => {
|
||||||
|
const { data } = await client.delete(B.CUSTOMER_TRUCK(bookingId, assignmentId));
|
||||||
|
return data.data ?? data;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -383,6 +383,32 @@ export interface IYard extends BaseEntity {
|
|||||||
displayOrder: number;
|
displayOrder: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One container number loaded onto a customer self-haul truck. */
|
||||||
|
export interface ICustomerTruckContainer {
|
||||||
|
id: string;
|
||||||
|
containerNumber: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A customer self-haul truck on a booking, carrying 1–2 containers. */
|
||||||
|
export interface ICustomerTruck {
|
||||||
|
id: string;
|
||||||
|
bookingId: string;
|
||||||
|
plateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
truckType: string;
|
||||||
|
assignedAt: string;
|
||||||
|
arrivedAt?: string | null;
|
||||||
|
containers?: ICustomerTruckContainer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payload to add a customer self-haul truck (1–2 container numbers). */
|
||||||
|
export interface AddCustomerTruckPayload {
|
||||||
|
truckPlateNumber: string;
|
||||||
|
driverName: string;
|
||||||
|
truckType: string;
|
||||||
|
containerNumbers: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IBooking extends BaseEntity {
|
export interface IBooking extends BaseEntity {
|
||||||
reference: string;
|
reference: string;
|
||||||
customerId: string;
|
customerId: string;
|
||||||
@@ -434,6 +460,8 @@ export interface IBooking extends BaseEntity {
|
|||||||
customerTruckArrivedAt?: string | null;
|
customerTruckArrivedAt?: string | null;
|
||||||
|
|
||||||
customsClearingEnabled?: boolean;
|
customsClearingEnabled?: boolean;
|
||||||
|
// (multi-truck self-haul lives in ICustomerTruck[], fetched via the
|
||||||
|
// /customer-trucks endpoint; the fields above are the booking-level flag.)
|
||||||
customsClearingAgent?: string | null;
|
customsClearingAgent?: string | null;
|
||||||
|
|
||||||
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
equipmentReturn: "WITH_RETURN" | "WITHOUT_RETURN";
|
||||||
|
|||||||
Reference in New Issue
Block a user