mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
train loading for import
This commit is contained in:
@@ -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,
|
||||
Controller,
|
||||
Delete,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
@@ -62,7 +63,10 @@ import {
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
|
||||
import { 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 {
|
||||
@@ -86,6 +90,7 @@ export class BookingsController {
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
private readonly customerTruckService: CustomerTruckService,
|
||||
private readonly containerReceiptService: ContainerReceiptService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -353,6 +358,53 @@ export class BookingsController {
|
||||
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",
|
||||
|
||||
@@ -37,6 +37,7 @@ import { CustomerTruckAssignment } from './entities/customer-truck-assignment.en
|
||||
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";
|
||||
@@ -99,6 +100,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ContractPdfService,
|
||||
CustomerTruckAssignmentsRepository,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
],
|
||||
exports: [
|
||||
BookingsService,
|
||||
@@ -106,6 +108,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingPricingService,
|
||||
BookingInvoiceService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
],
|
||||
})
|
||||
export class BookingsModule { }
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
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';
|
||||
@@ -41,17 +42,31 @@ export class CustomerTruckService {
|
||||
const booking = await this.loadBookingGuard(bookingId);
|
||||
this.assertSelfHaulPaid(booking);
|
||||
|
||||
const requested = dto.containerNumbers.map((n) => n.trim().toUpperCase());
|
||||
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 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');
|
||||
}
|
||||
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`);
|
||||
|
||||
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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +133,71 @@ export class CustomerTruckService {
|
||||
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
|
||||
@@ -225,4 +305,17 @@ export class CustomerTruckService {
|
||||
);
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
|
||||
|
||||
/**
|
||||
* Add one external customer truck to a booking, carrying 1–2 container numbers.
|
||||
* Each container must be one of the booking's containers and not already loaded
|
||||
* onto another truck (enforced in the service + a partial unique index).
|
||||
* 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()
|
||||
@@ -33,13 +35,13 @@ export class AddCustomerTruckDto {
|
||||
@IsIn(CUSTOMER_TRUCK_TYPES)
|
||||
truckType!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@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[];
|
||||
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 })
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,14 @@ export class CustomerTruckAssignment extends BaseEntity {
|
||||
@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[];
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
@@ -2084,6 +2121,9 @@ export class WarehouseInventoryService {
|
||||
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
|
||||
@@ -2175,12 +2215,16 @@ export class WarehouseInventoryService {
|
||||
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)
|
||||
@@ -2231,8 +2275,14 @@ export class WarehouseInventoryService {
|
||||
truckPlateNumber: truck?.plateNumber ?? null,
|
||||
truckDriverName: truck?.driverName ?? null,
|
||||
truckType: truck?.truckType ?? null,
|
||||
truckContainers: truck?.containerNumbers ?? null,
|
||||
truckWeightKg: truck ? Number(truck.truckWeightTons ?? 0) * 1000 : 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 {
|
||||
@@ -3165,7 +3215,7 @@ export class WarehouseInventoryService {
|
||||
truckPlateNumber?: string | null;
|
||||
truckDriverName?: string | null;
|
||||
truckType?: string | null;
|
||||
truckContainers?: string | null;
|
||||
truckGateOut?: string | null;
|
||||
truckWeightKg?: number | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
@@ -3208,7 +3258,18 @@ export class WarehouseInventoryService {
|
||||
['Pickup Truck Plate', data.truckPlateNumber],
|
||||
['Truck Driver', data.truckDriverName],
|
||||
['Truck Type', data.truckType],
|
||||
['Containers Loaded on Truck', data.truckContainers],
|
||||
[
|
||||
'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]] : []),
|
||||
|
||||
@@ -73,6 +73,10 @@ export function CustomerTruckAssignmentCard({
|
||||
(n) => !assignedNumbers.has(n),
|
||||
);
|
||||
|
||||
// EXPORT trucks deliver known containers (pre-selected). IMPORT trucks don't —
|
||||
// staff register + weigh what was loaded when the truck leaves.
|
||||
const isExport = booking.tradeDirection === "EXPORT";
|
||||
|
||||
const resetForm = () => {
|
||||
setPlateNumber("");
|
||||
setDriverName("");
|
||||
@@ -87,7 +91,8 @@ export function CustomerTruckAssignmentCard({
|
||||
truckPlateNumber: plateNumber.trim().toUpperCase(),
|
||||
driverName: driverName.trim(),
|
||||
truckType: truckType.trim(),
|
||||
containerNumbers: containers,
|
||||
// Import: containers are registered + weighed on departure, not here.
|
||||
containerNumbers: isExport ? containers : [],
|
||||
}),
|
||||
onSuccess: (list) => {
|
||||
queryClient.setQueryData(trucksKey, list);
|
||||
@@ -118,7 +123,7 @@ export function CustomerTruckAssignmentCard({
|
||||
setError("Plate number, driver name and truck type are required.");
|
||||
return;
|
||||
}
|
||||
if (containers.length < 1 || containers.length > 2) {
|
||||
if (isExport && (containers.length < 1 || containers.length > 2)) {
|
||||
setError("Select 1 or 2 container numbers for this truck.");
|
||||
return;
|
||||
}
|
||||
@@ -202,8 +207,8 @@ export function CustomerTruckAssignmentCard({
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Add-truck form */}
|
||||
{availableContainers.length > 0 ? (
|
||||
{/* Add-truck form. Export needs unassigned containers; import always allows another truck. */}
|
||||
{(isExport ? availableContainers.length > 0 : true) ? (
|
||||
<>
|
||||
<Divider label="Add a truck" labelPosition="center" />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
|
||||
@@ -226,17 +231,19 @@ export function CustomerTruckAssignmentCard({
|
||||
value={truckType || null}
|
||||
onChange={(value) => setTruckType(value ?? "")}
|
||||
/>
|
||||
<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"
|
||||
/>
|
||||
{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
|
||||
|
||||
Reference in New Issue
Block a user