Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-05 22:22:34 +00:00
152 changed files with 7486 additions and 3075 deletions

View File

@@ -0,0 +1,22 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { CheckAvailabilityService } from "./check-availability.service";
@ApiTags("auth")
@Controller("auth")
@Public()
export class CheckAvailabilityController {
constructor(
private readonly checkAvailabilityService: CheckAvailabilityService,
) {}
@Get("check-availability")
@ApiOperation({
summary: "Check whether an email and/or phone number is already registered",
})
check(@Query("email") email?: string, @Query("phone") phone?: string) {
return this.checkAvailabilityService.check({ email, phone });
}
}

View File

@@ -0,0 +1,47 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
export interface CheckAvailabilityQuery {
email?: string;
phone?: string;
}
export interface CheckAvailabilityResult {
emailTaken: boolean;
phoneTaken: boolean;
}
@Injectable()
export class CheckAvailabilityService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async check({
email,
phone,
}: CheckAvailabilityQuery): Promise<CheckAvailabilityResult> {
if (!email && !phone) {
throw new BadRequestException("email or phone is required");
}
const matches = await this.userRepository.find({
where: [
...(email ? [{ email }] : []),
...(phone ? [{ phoneNumber: phone }] : []),
],
select: { id: true, email: true, phoneNumber: true },
});
return {
emailTaken: email ? matches.some((user) => user.email === email) : false,
phoneTaken: phone
? matches.some((user) => user.phoneNumber === phone)
: false,
};
}
}

View File

@@ -1,10 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
controllers: [FreightMeController],
providers: [FreightMeService],
imports: [TypeOrmModule.forFeature([User])],
controllers: [FreightMeController, CheckAvailabilityController],
providers: [FreightMeService, CheckAvailabilityService],
})
export class FreightAuthModule {}

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
@@ -61,6 +62,11 @@ import {
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.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 { UpdateBookingDto } from './dto/update-booking.dto';
import {
@@ -83,6 +89,8 @@ export class BookingsController {
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
private readonly bookingClearanceService: BookingClearanceService,
private readonly customerTruckService: CustomerTruckService,
private readonly containerReceiptService: ContainerReceiptService,
) {}
@Post()
@@ -309,6 +317,94 @@ export class BookingsController {
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 12 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')
@ApiOperation({
summary: "Shipment tracking timeline for a booking",

View File

@@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.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 { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
@@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
CustomerTruckAssignment,
CustomerTruckContainer,
]),
BillingModule,
forwardRef(() => FirstMileModule),
@@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
CustomerTruckAssignmentsRepository,
CustomerTruckService,
ContainerReceiptService,
],
exports: [
BookingsService,
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
CustomerTruckService,
ContainerReceiptService,
],
})
export class BookingsModule { }

View File

@@ -145,7 +145,28 @@ export class BookingsService {
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);
return {
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')}`;
}
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
? 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],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['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],
['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>`)
.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) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div>
<h1>Freight Order</h1>
<p>Customer external truck assignment</p>
<p>Customer external truck assignment${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${rowHtml}</table>
<table>${bookingRowHtml}</table>
${truckBlocks}
<div class="signatures">
<div>Customer / Carrier Signature</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; }
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; }
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
p { margin: 4px 0 0; color: #64748b; }
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 { 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 div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
</style>

View File

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

View File

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

View File

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

View File

@@ -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 12 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[];
}

View File

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

View File

@@ -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[];
}

View File

@@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity {
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
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;
}

View File

@@ -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 12 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[];
}

View File

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

View File

@@ -1183,9 +1183,11 @@ export class CompaniesService {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
throw new BadRequestException(
"No business license found for this TIN. Please check the number and try again.",
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
return this.etradeService.extractRegistrationData(businessInfo);
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
import { CompanyType, CompanyStatus } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -17,10 +17,7 @@ export class CreateCompanyDto {
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
tin!: string;
@IsOptional()

View File

@@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
managerName!: string;
managerEmail?: string;
managerPhone!: string;
tinTaken?: boolean;
constructor(data: CompanyRegistrationData) {
this.licenceNumber = data.licenceNumber;
@@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
this.managerName = data.managerName;
this.managerEmail = data.managerEmail;
this.managerPhone = data.managerPhone;
this.tinTaken = data.tinTaken;
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -34,10 +34,7 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@Length(10, 10)
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
tin?: string;
@IsOptional()

View File

@@ -1567,9 +1567,7 @@ export class TrainSchedulingService {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
if (!operation.loadedOnTrainAt) {
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
}
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
await this.dispatchSchedule(schedule.id);
@@ -1946,9 +1944,9 @@ export class TrainSchedulingService {
where: { trainScheduleId: schedule.id },
});
this.assertImportDjiboutiGatepassGranted(operation);
if (!operation?.loadedOnTrainAt) {
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
}
// Loading confirmation does NOT gate dispatch. Per-booking loading is
// 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> {

View File

@@ -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(
{
activityType: 'INVENTORY_RECEIVED',
@@ -1774,6 +1791,26 @@ export class WarehouseInventoryService {
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(
{
activityType: 'INVENTORY_RECEIVED',
@@ -2068,6 +2105,29 @@ export class WarehouseInventoryService {
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
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(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
@@ -2147,6 +2207,48 @@ export class WarehouseInventoryService {
}
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 reference =
row?.releaseOrderReference ||
@@ -2170,6 +2272,17 @@ export class WarehouseInventoryService {
inventoryStatus: row?.status ?? null,
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
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 {
@@ -3099,6 +3212,11 @@ export class WarehouseInventoryService {
inventoryStatus: string | null;
clearanceStatus: string;
exitInspectionSummary?: string | null;
truckPlateNumber?: string | null;
truckDriverName?: string | null;
truckType?: string | null;
truckGateOut?: string | null;
truckWeightKg?: number | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -3123,12 +3241,37 @@ export class WarehouseInventoryService {
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['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],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['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]] : []),
];