mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* EDR last-mile is multi-truck: a booking can be served by as many trucks as it
|
||||
* has containers (bulk hauls until the tonnage is drawn down). Arrival/delivery
|
||||
* were stamped once per `last_mile` record, so every truck shared one timestamp.
|
||||
* These per-vehicle columns give each EDR truck its own arrival, leaving and
|
||||
* weighed load — the same granularity self-haul trucks already have.
|
||||
*
|
||||
* Weights are TONNES (matching bookings.cargo_total_weight_vgm and the exit
|
||||
* weighing UI). Named `*_tons` deliberately: the older
|
||||
* customer_truck_assignments.gross_weight_kg is named kg but stores tonnes.
|
||||
* All nullable — legacy rows predate per-truck tracking.
|
||||
*/
|
||||
export class AddLastMileTruckArrivalDeparture2260000000000 implements MigrationInterface {
|
||||
name = 'AddLastMileTruckArrivalDeparture2260000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
ADD COLUMN IF NOT EXISTS arrived_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS departed_at timestamptz NULL,
|
||||
ADD COLUMN IF NOT EXISTS gross_weight_tons numeric(14, 3) NULL,
|
||||
ADD COLUMN IF NOT EXISTS net_weight_tons numeric(14, 3) NULL
|
||||
`);
|
||||
|
||||
// A truck carries 1x40ft OR 2x20ft, so an EDR truck needs MORE than the one
|
||||
// container the legacy scalar `container_number` can hold. Mirrors the
|
||||
// self-haul customer_truck_containers child table. The scalar stays in place
|
||||
// (synced to the first container) for backward compatibility.
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.last_mile_vehicle_containers (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
assignment_id uuid NOT NULL REFERENCES freight.last_mile_vehicle_assignments(id) ON DELETE CASCADE,
|
||||
last_mile_id uuid NOT NULL,
|
||||
container_number varchar(32) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz NULL
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "IDX_last_mile_vehicle_containers_assignment"
|
||||
ON freight.last_mile_vehicle_containers (assignment_id)
|
||||
`);
|
||||
// A container rides exactly one truck per delivery.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_last_mile_vehicle_container"
|
||||
ON freight.last_mile_vehicle_containers (last_mile_id, container_number)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.last_mile_vehicle_containers`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.last_mile_vehicle_assignments
|
||||
DROP COLUMN IF EXISTS arrived_at,
|
||||
DROP COLUMN IF EXISTS departed_at,
|
||||
DROP COLUMN IF EXISTS gross_weight_tons,
|
||||
DROP COLUMN IF EXISTS net_weight_tons
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Re-seed the EDR wagon fleet onto the official ER numbering.
|
||||
*
|
||||
* Supersedes SeedWagonsWithYardAssignment1784000000001, which seeded 500 wagons
|
||||
* on a `<CODE>-NNNN` scheme and wrote the status as 'Available' — mixed case
|
||||
* that never matches WagonStatus.Available ('AVAILABLE'), so status filters
|
||||
* silently returned nothing. This seed uses the enum value.
|
||||
*
|
||||
* Every wagon lands unassigned: current_yard_id NULL, status AVAILABLE. Wagon
|
||||
* specs (capacity/length/tare) stay owned by wagon_types and are not touched —
|
||||
* the types already exist and only the wagon↔type link is (re)established here.
|
||||
*/
|
||||
type FleetRow = {
|
||||
code: string;
|
||||
start: number;
|
||||
end: number;
|
||||
count: number;
|
||||
};
|
||||
|
||||
/** Official fleet: 1100 wagons, ER0001–ER1100, contiguous across 10 types. */
|
||||
const FLEET: FleetRow[] = [
|
||||
{ code: 'PW2', start: 1, end: 220, count: 220 },
|
||||
{ code: 'CW4', start: 221, end: 330, count: 110 },
|
||||
{ code: 'CW3', start: 331, end: 350, count: 20 },
|
||||
{ code: 'KW2', start: 351, end: 370, count: 20 },
|
||||
{ code: 'KW3', start: 371, end: 390, count: 20 },
|
||||
{ code: 'NW5', start: 391, end: 940, count: 550 },
|
||||
{ code: 'BW1', start: 941, end: 950, count: 10 },
|
||||
{ code: 'GW2', start: 951, end: 1060, count: 110 },
|
||||
{ code: 'NW6', start: 1061, end: 1080, count: 20 },
|
||||
{ code: 'NW7', start: 1081, end: 1100, count: 20 },
|
||||
];
|
||||
|
||||
const wagonNumber = (sequence: number) => `ER${String(sequence).padStart(4, '0')}`;
|
||||
|
||||
export class SeedEdrWagonFleetErNumbering2260000000000 implements MigrationInterface {
|
||||
name = 'SeedEdrWagonFleetErNumbering2260000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// Full replacement: the ER range is the fleet of record, so any wagon
|
||||
// outside it is stale seed data. Safe to hard-delete — containers and
|
||||
// train_set_wagons null their link, wagon_movements cascade.
|
||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||
|
||||
// Wagon.wagonNumber declares `unique: true`, but some environments never got
|
||||
// the constraint. Repair it here — the table is empty at this point, so the
|
||||
// index build cannot fail on pre-existing duplicates.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS wagons_wagon_number_key
|
||||
ON freight.wagons (wagon_number);
|
||||
`);
|
||||
|
||||
for (const row of FLEET) {
|
||||
if (row.end - row.start + 1 !== row.count) {
|
||||
throw new Error(`wagon_range_mismatch:${row.code}`);
|
||||
}
|
||||
|
||||
const [typeRecord] = await queryRunner.query(
|
||||
`SELECT id FROM freight.wagon_types WHERE code = $1 AND deleted_at IS NULL LIMIT 1;`,
|
||||
[row.code],
|
||||
);
|
||||
|
||||
if (!typeRecord?.id) {
|
||||
throw new Error(`wagon_type_missing:${row.code}`);
|
||||
}
|
||||
|
||||
// generate_series builds the range server-side — one round trip per type
|
||||
// instead of 1100 individual INSERTs. No ON CONFLICT clause: every wagon
|
||||
// was deleted above, so a plain INSERT cannot collide, and the clause would
|
||||
// otherwise hard-require a unique index this table lacks on some envs.
|
||||
await queryRunner.query(
|
||||
`
|
||||
INSERT INTO freight.wagons (
|
||||
wagon_number,
|
||||
wagon_type_id,
|
||||
status,
|
||||
current_yard_id,
|
||||
train_id,
|
||||
sequence_number,
|
||||
notes,
|
||||
train_set_wagon_id,
|
||||
current_train_schedule_id
|
||||
)
|
||||
SELECT
|
||||
'ER' || LPAD(seq::text, 4, '0'),
|
||||
$1::uuid,
|
||||
'AVAILABLE',
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL,
|
||||
NULL
|
||||
FROM generate_series($2::int, $3::int) AS seq;
|
||||
`,
|
||||
[typeRecord.id, row.start, row.end],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.wagons WHERE wagon_number BETWEEN $1 AND $2;`,
|
||||
[wagonNumber(FLEET[0].start), wagonNumber(FLEET[FLEET.length - 1].end)],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -913,9 +913,9 @@ export class BillingService {
|
||||
dueAt,
|
||||
...(issuing
|
||||
? {
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
issuedAt: invoice.issuedAt ?? new Date(),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
await mg.update(Invoice, { id: invoice.id }, patch);
|
||||
@@ -1032,20 +1032,20 @@ export class BillingService {
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// // DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||
// // waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||
// // `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||
// // remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||
// if (!result.immediateSuccess) {
|
||||
// await this.payment.handlePaymentEvent({
|
||||
// eventType: "payment.succeeded",
|
||||
// eventId: `demo-${result.intentId}`,
|
||||
// referenceId: invoice.sourceId,
|
||||
// intentId: result.intentId,
|
||||
// providerTxnId: result.providerTxnId,
|
||||
// paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||
// });
|
||||
// }
|
||||
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||
if (!result.immediateSuccess) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
referenceId: invoice.sourceId,
|
||||
intentId: result.intentId,
|
||||
providerTxnId: result.providerTxnId,
|
||||
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
|
||||
@@ -204,17 +204,27 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
private async attachClearancePhases(contracts: Contract[]): Promise<void> {
|
||||
if (contracts.length === 0) return;
|
||||
const ids = contracts.map((c) => c.id);
|
||||
const rows: Array<{ contract_id: string; current_phase: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (contract_id) contract_id, current_phase
|
||||
FROM freight.contract_clearance_cycles
|
||||
WHERE contract_id = ANY($1)
|
||||
ORDER BY contract_id, cycle_number DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r.current_phase]));
|
||||
const rows: Array<{
|
||||
contract_id: string;
|
||||
current_phase: string | null;
|
||||
booking_id: string | null;
|
||||
booking_status: string | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT DISTINCT ON (ccc.contract_id)
|
||||
ccc.contract_id, ccc.current_phase,
|
||||
b.id AS booking_id, b.status AS booking_status
|
||||
FROM freight.contract_clearance_cycles ccc
|
||||
LEFT JOIN freight.bookings b ON b.id = ccc.booking_id
|
||||
WHERE ccc.contract_id = ANY($1)
|
||||
ORDER BY ccc.contract_id, ccc.cycle_number DESC`,
|
||||
[ids],
|
||||
);
|
||||
const byContract = new Map(rows.map((r) => [r.contract_id, r]));
|
||||
for (const contract of contracts) {
|
||||
contract.clearancePhase = byContract.get(contract.id) ?? null;
|
||||
const row = byContract.get(contract.id);
|
||||
contract.clearancePhase = row?.current_phase ?? null;
|
||||
contract.latestCycleBookingId = row?.booking_id ?? null;
|
||||
contract.latestCycleBookingStatus = row?.booking_status ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -312,6 +312,14 @@ export class Contract extends BaseEntity {
|
||||
*/
|
||||
clearancePhase?: string | null;
|
||||
|
||||
/**
|
||||
* Latest clearance cycle's linked booking (id + status), attached alongside
|
||||
* clearancePhase. Lets the GL queue tell an expired (unpaid) booking apart
|
||||
* from a live one so it can offer a rebook. Not columns.
|
||||
*/
|
||||
latestCycleBookingId?: string | null;
|
||||
latestCycleBookingStatus?: string | null;
|
||||
|
||||
/**
|
||||
* Body of the most recent CHANGES_REQUESTED review note, attached by
|
||||
* ContractsService.findById so the portal can show the customer what staff
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { IsArray, IsOptional, IsString, IsUUID, ValidateNested } from 'class-validator';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export class LastMileVehicleInput {
|
||||
@IsUUID()
|
||||
vehicleId!: string;
|
||||
|
||||
/**
|
||||
* Containers this truck carries: one 40ft, or up to two 20ft. Omit for bulk
|
||||
* (the truck hauls loose tonnage and is weighed out on exit).
|
||||
*/
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(2)
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
containerNumbers?: string[];
|
||||
|
||||
/** @deprecated Single-container form — use `containerNumbers`. Still accepted. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
}
|
||||
|
||||
/** Replace the full set of vehicles (with their container numbers) on a delivery. */
|
||||
/** Replace the full set of vehicles (with their containers) on a delivery. */
|
||||
export class SetVehiclesDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, Unique } from 'typeorm';
|
||||
|
||||
import { Vehicle } from '../../vehicles/entities/vehicle.entity';
|
||||
import { LastMile } from './last-mile.entity';
|
||||
import { LastMileVehicleContainer } from './last-mile-vehicle-container.entity';
|
||||
|
||||
/**
|
||||
* One row per vehicle assigned to a last-mile delivery. A delivery can be
|
||||
@@ -28,12 +29,34 @@ export class LastMileVehicleAssignment extends BaseEntity {
|
||||
@JoinColumn({ name: 'vehicle_id' })
|
||||
vehicle?: Vehicle;
|
||||
|
||||
/** Container this truck carries — auto-filled from the booking's container
|
||||
* number when known, else entered manually at assignment time. */
|
||||
/** Legacy single container this truck carries. Kept in sync with the FIRST
|
||||
* entry of `containers` for backward compatibility — a truck can hold 1x40ft
|
||||
* or 2x20ft, so `containers` is the authoritative list. */
|
||||
@Column({ name: 'container_number', type: 'varchar', nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
/** Containers riding this truck (1x40ft, or up to 2x20ft). */
|
||||
@OneToMany(() => LastMileVehicleContainer, (c) => c.assignment, { cascade: true })
|
||||
containers?: LastMileVehicleContainer[];
|
||||
|
||||
/** Actual distance driven by this truck (km), entered per vehicle. */
|
||||
@Column({ name: 'distance_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
distanceKm?: number | null;
|
||||
|
||||
/** This truck reached the warehouse (stamped by the arrival weighing step). */
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
/** This truck left the warehouse (stamped by the exit weighing step). */
|
||||
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
|
||||
departedAt?: Date | null;
|
||||
|
||||
/** Weighed gross on exit, in TONNES (not kg — see the migration note). */
|
||||
@Column({ name: 'gross_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
grossWeightTons?: number | null;
|
||||
|
||||
/** Cargo actually taken by this truck (gross − tare), in TONNES. Drives the
|
||||
* bulk drawdown: remaining = booking VGM − SUM(net) over departed trucks. */
|
||||
@Column({ name: 'net_weight_tons', type: 'numeric', precision: 14, scale: 3, nullable: true })
|
||||
netWeightTons?: number | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { LastMileVehicleAssignment } from './last-mile-vehicle-assignment.entity';
|
||||
|
||||
/**
|
||||
* A container riding a specific EDR last-mile truck. A truck carries 1x40ft OR
|
||||
* 2x20ft, so the assignment needs more than the single legacy `container_number`
|
||||
* scalar. Mirrors the self-haul `customer_truck_containers` child table.
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'last_mile_vehicle_containers' })
|
||||
@Index(['assignmentId'])
|
||||
export class LastMileVehicleContainer extends BaseEntity {
|
||||
@Column({ name: 'assignment_id', type: 'uuid' })
|
||||
assignmentId!: string;
|
||||
|
||||
@ManyToOne(() => LastMileVehicleAssignment, (a) => a.containers, {
|
||||
nullable: false,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'assignment_id' })
|
||||
assignment?: LastMileVehicleAssignment;
|
||||
|
||||
/** Denormalised for the "one container, one truck per delivery" unique index. */
|
||||
@Column({ name: 'last_mile_id', type: 'uuid' })
|
||||
lastMileId!: string;
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 32 })
|
||||
containerNumber!: string;
|
||||
}
|
||||
@@ -73,6 +73,12 @@ export class LastMileController {
|
||||
return this.lastMileService.arrivalTrucksForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Get('booking/:bookingId/remaining-tons')
|
||||
@ApiOperation({ summary: 'Bulk drawdown: tonnage still to be hauled (total − departed trucks)' })
|
||||
remainingTons(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.lastMileService.remainingTonsForBooking(bookingId);
|
||||
}
|
||||
|
||||
@Post('accept/:reference')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.accept)
|
||||
@ApiOperation({ summary: 'Accept a paid booking and create a last-mile leg' })
|
||||
|
||||
@@ -10,6 +10,7 @@ import { VehiclesModule } from '../vehicles/vehicles.module';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||
import { LastMileController } from './last-mile.controller';
|
||||
import { LastMileInvoiceService } from './last-mile-invoice.service';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
@@ -17,7 +18,12 @@ import { LastMileService } from './last-mile.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([LastMile, LastMileContainerAllocation, LastMileVehicleAssignment]),
|
||||
TypeOrmModule.forFeature([
|
||||
LastMile,
|
||||
LastMileContainerAllocation,
|
||||
LastMileVehicleAssignment,
|
||||
LastMileVehicleContainer,
|
||||
]),
|
||||
BillingModule,
|
||||
forwardRef(() => BookingsModule),
|
||||
VehiclesModule,
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, FindOptionsWhere, In, IsNull, Not } from 'typeorm';
|
||||
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
@@ -12,6 +18,7 @@ import { RecordProofOfDeliveryDto } from './dto/record-proof-of-delivery.dto';
|
||||
import { LastMile, LastMileStatus } from './entities/last-mile.entity';
|
||||
import { LastMileContainerAllocation } from './entities/last-mile-container-allocation.entity';
|
||||
import { LastMileVehicleAssignment } from './entities/last-mile-vehicle-assignment.entity';
|
||||
import { LastMileVehicleContainer } from './entities/last-mile-vehicle-container.entity';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { BillingService, InvoiceEventPayload } from '../billing/billing.service';
|
||||
@@ -175,7 +182,7 @@ export class LastMileService {
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||
vehicle: true,
|
||||
vehicleAssignments: { vehicle: true },
|
||||
vehicleAssignments: { vehicle: true, containers: true },
|
||||
},
|
||||
order: { [sortBy]: sortOrder },
|
||||
skip: (page - 1) * pageSize,
|
||||
@@ -200,7 +207,7 @@ export class LastMileService {
|
||||
relations: {
|
||||
booking: { company: true, serviceType: true, originYard: true, destinationYard: true, cargoType: true, bookingContainers: { containerType: true, units: true } },
|
||||
vehicle: true,
|
||||
vehicleAssignments: { vehicle: true },
|
||||
vehicleAssignments: { vehicle: true, containers: true },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -277,7 +284,7 @@ export class LastMileService {
|
||||
> {
|
||||
const [lm] = await this.lastMileRepository.findAll({
|
||||
where: { bookingId },
|
||||
relations: { vehicle: true, vehicleAssignments: { vehicle: true } },
|
||||
relations: { vehicle: true, vehicleAssignments: { vehicle: true, containers: true } },
|
||||
take: 1,
|
||||
});
|
||||
if (!lm) return [];
|
||||
@@ -552,22 +559,164 @@ export class LastMileService {
|
||||
* for each added/removed vehicle. The first vehicle is mirrored onto the legacy
|
||||
* `vehicleId` column for back-compat with single-vehicle readers.
|
||||
*/
|
||||
/** Container numbers on the booking (upper-cased). */
|
||||
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_container 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());
|
||||
}
|
||||
|
||||
/** Contract container sizes (e.g. "20ft" / "40ft") for the given numbers. */
|
||||
private async containerSizes(bookingId: string, numbers: string[]): Promise<string[]> {
|
||||
if (!numbers.length) return [];
|
||||
const rows: Array<{ size: string | null }> = await this.dataSource.query(
|
||||
`SELECT bc.container_size AS "size"
|
||||
FROM freight.booking_container_units bcu
|
||||
JOIN freight.booking_container bc
|
||||
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
|
||||
WHERE bc.booking_id = $1
|
||||
AND UPPER(bcu.container_number) = ANY($2)
|
||||
AND bcu.deleted_at IS NULL`,
|
||||
[bookingId, numbers],
|
||||
);
|
||||
return rows.map((r) => (r.size ?? '').trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk drawdown: how much of the booking's tonnage is still to be hauled —
|
||||
* the booking VGM total minus the net weighed off every EDR truck that has
|
||||
* already left. Both sides are tonnes, so no conversion.
|
||||
*/
|
||||
async remainingTonsForBooking(bookingId: string): Promise<{
|
||||
totalTons: number;
|
||||
hauledTons: number;
|
||||
remainingTons: number;
|
||||
complete: boolean;
|
||||
}> {
|
||||
const [row]: Array<{ totalTons: string | null; hauledTons: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT COALESCE(b.cargo_total_weight_vgm, 0) AS "totalTons",
|
||||
COALESCE((
|
||||
SELECT SUM(va.net_weight_tons)
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm
|
||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
WHERE lm.booking_id = b.id
|
||||
AND va.deleted_at IS NULL
|
||||
AND va.departed_at IS NOT NULL
|
||||
), 0) AS "hauledTons"
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
const totalTons = Number(row?.totalTons ?? 0);
|
||||
const hauledTons = Number(row?.hauledTons ?? 0);
|
||||
const remainingTons = Math.max(0, Math.round((totalTons - hauledTons) * 1000) / 1000);
|
||||
return { totalTons, hauledTons, remainingTons, complete: totalTons > 0 && remainingTons <= 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Truck capacity rules for a last-mile delivery.
|
||||
* - CONTAINER: a truck carries ONE 40ft or up to TWO 20ft; every container
|
||||
* must belong to the booking and ride exactly one truck; never more trucks
|
||||
* than containers.
|
||||
* - BULK: no containers — trucks haul loose tonnage, so the only limit is
|
||||
* that there is tonnage left to haul.
|
||||
*/
|
||||
private async assertVehicleLoads(
|
||||
bookingId: string,
|
||||
desired: string[],
|
||||
loads: Map<string, string[]>,
|
||||
): Promise<void> {
|
||||
if (!desired.length) return;
|
||||
|
||||
const [booking]: Array<{ freightType: string | null }> = await this.dataSource.query(
|
||||
`SELECT freight_type AS "freightType"
|
||||
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if ((booking?.freightType ?? '').toUpperCase() === 'BULK') {
|
||||
const { remainingTons, totalTons } = await this.remainingTonsForBooking(bookingId);
|
||||
if (totalTons > 0 && remainingTons <= 0) {
|
||||
throw new BadRequestException(
|
||||
'This bulk booking is fully hauled — no tonnage left to assign trucks for',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
|
||||
if (!bookingNumbers.length) return; // nothing to validate against
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const vehicleId of desired) {
|
||||
const load = loads.get(vehicleId) ?? [];
|
||||
if (load.length > 2) {
|
||||
throw new BadRequestException('A truck carries at most 2 containers');
|
||||
}
|
||||
for (const n of load) {
|
||||
if (!bookingNumbers.includes(n)) {
|
||||
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
|
||||
}
|
||||
if (seen.has(n)) {
|
||||
throw new ConflictException(`Container ${n} is already assigned to another truck`);
|
||||
}
|
||||
seen.add(n);
|
||||
}
|
||||
// A 40ft container fills the truck; only two 20ft share one.
|
||||
if (load.length > 1) {
|
||||
const sizes = await this.containerSizes(bookingId, load);
|
||||
if (sizes.some((s) => s.includes('40'))) {
|
||||
throw new BadRequestException(
|
||||
'A 40ft container fills the truck — assign only 1 container to this truck',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (desired.length > bookingNumbers.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot assign more trucks than containers — this booking has ${bookingNumbers.length} container(s) and ${desired.length} truck(s) requested.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async setVehicles(
|
||||
id: string,
|
||||
inputs: Array<{ vehicleId: string; containerNumber?: string | null }>,
|
||||
inputs: Array<{
|
||||
vehicleId: string;
|
||||
containerNumbers?: string[] | null;
|
||||
containerNumber?: string | null;
|
||||
}>,
|
||||
): Promise<LastMile> {
|
||||
const existing = await this.findById(id);
|
||||
// Dedupe by vehicleId, keeping the container number; preserve order.
|
||||
const desiredMap = new Map<string, string | null>();
|
||||
// Dedupe by vehicleId, keeping the container load; preserve order. Accepts
|
||||
// the legacy single `containerNumber` as a one-element load.
|
||||
const desiredMap = new Map<string, string[]>();
|
||||
for (const inp of inputs) {
|
||||
if (inp.vehicleId) desiredMap.set(inp.vehicleId, inp.containerNumber ?? null);
|
||||
if (!inp.vehicleId) continue;
|
||||
const load = (inp.containerNumbers ?? (inp.containerNumber ? [inp.containerNumber] : []))
|
||||
.map((n) => String(n).trim().toUpperCase())
|
||||
.filter(Boolean);
|
||||
desiredMap.set(inp.vehicleId, load);
|
||||
}
|
||||
const desired = [...desiredMap.keys()];
|
||||
const desiredSet = new Set(desired);
|
||||
|
||||
// Capacity + membership rules (a truck holds one 40ft or two 20ft; bulk
|
||||
// hauls tonnage until the booking is drawn down).
|
||||
await this.assertVehicleLoads(existing.bookingId, desired, desiredMap);
|
||||
|
||||
const manager = this.dataSource.manager;
|
||||
const current = await manager.find(LastMileVehicleAssignment, {
|
||||
where: { lastMileId: id },
|
||||
relations: { containers: true },
|
||||
});
|
||||
const junctionSet = new Set(current.map((a) => a.vehicleId));
|
||||
// Fold the legacy vehicleId into the release set — a vehicle assigned via the
|
||||
@@ -588,33 +737,58 @@ export class LastMileService {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Vehicles that stay but whose container number changed.
|
||||
// Vehicles that stay but whose container load changed (order-insensitive).
|
||||
const loadKey = (list: string[]) => [...list].sort().join('|');
|
||||
const currentLoad = (a: LastMileVehicleAssignment) =>
|
||||
(a.containers ?? []).map((c) => c.containerNumber.trim().toUpperCase());
|
||||
const changed = current.filter(
|
||||
(a) =>
|
||||
desiredMap.has(a.vehicleId) &&
|
||||
(a.containerNumber ?? null) !== (desiredMap.get(a.vehicleId) ?? null),
|
||||
loadKey(desiredMap.get(a.vehicleId) ?? []) !== loadKey(currentLoad(a)),
|
||||
);
|
||||
|
||||
await this.dataSource.transaction(async (tx) => {
|
||||
if (removed.length) {
|
||||
// Child containers cascade on delete.
|
||||
await tx.delete(LastMileVehicleAssignment, {
|
||||
lastMileId: id,
|
||||
vehicleId: In(removed),
|
||||
});
|
||||
}
|
||||
for (const vehicleId of added) {
|
||||
await tx.insert(LastMileVehicleAssignment, {
|
||||
const load = desiredMap.get(vehicleId) ?? [];
|
||||
const inserted = await tx.insert(LastMileVehicleAssignment, {
|
||||
lastMileId: id,
|
||||
vehicleId,
|
||||
containerNumber: desiredMap.get(vehicleId) ?? null,
|
||||
// Legacy scalar stays in sync with the first container.
|
||||
containerNumber: load[0] ?? null,
|
||||
});
|
||||
const assignmentId = inserted.identifiers[0]?.id as string | undefined;
|
||||
if (assignmentId && load.length) {
|
||||
await tx.insert(
|
||||
LastMileVehicleContainer,
|
||||
load.map((containerNumber) => ({ assignmentId, lastMileId: id, containerNumber })),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const row of changed) {
|
||||
const load = desiredMap.get(row.vehicleId) ?? [];
|
||||
await tx.update(
|
||||
LastMileVehicleAssignment,
|
||||
{ lastMileId: id, vehicleId: row.vehicleId },
|
||||
{ containerNumber: desiredMap.get(row.vehicleId) ?? null },
|
||||
{ containerNumber: load[0] ?? null },
|
||||
);
|
||||
await tx.delete(LastMileVehicleContainer, { assignmentId: row.id });
|
||||
if (load.length) {
|
||||
await tx.insert(
|
||||
LastMileVehicleContainer,
|
||||
load.map((containerNumber) => ({
|
||||
assignmentId: row.id,
|
||||
lastMileId: id,
|
||||
containerNumber,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/**
|
||||
* Admin maintenance reschedule: move a train's departure to a new date/time.
|
||||
* Every allocated booking rides along (links and wagon assignments untouched);
|
||||
* only the dates move — the schedule's train set, route, and window rule
|
||||
* snapshot all stay exactly as they were.
|
||||
*/
|
||||
export class MaintenanceRescheduleDto {
|
||||
@ApiProperty({
|
||||
example: '2026-07-20T05:00:00.000Z',
|
||||
description: 'New scheduled departure date/time (ISO 8601)',
|
||||
})
|
||||
@IsISO8601()
|
||||
newDepartureDate!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Why the train is being moved (logged)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Client-side trigger tag (e.g. TRAIN_MAINTENANCE) — logged only',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trigger?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
"The bookings the client believes are aboard — informational; the server moves the schedule's actual bookings",
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID('4', { each: true })
|
||||
incomingBookingIds?: string[];
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
import { BookingJourneyService } from "./booking-journey.service";
|
||||
@@ -711,6 +712,20 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/maintenance")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Maintenance reschedule: move the train to a new departure with every allocated booking aboard — links, wagons and window settings unchanged",
|
||||
})
|
||||
async maintenanceReschedule(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: MaintenanceRescheduleDto,
|
||||
) {
|
||||
await this.trainSchedulingService.maintenanceReschedule(id, dto);
|
||||
return this.trainSchedulingService.getContainerTrainScheduleById(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/doc-review-complete")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
@@ -864,6 +865,121 @@ export class TrainSchedulingService {
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintenance reschedule: the admin moves a train (with everything aboard) to
|
||||
* a new departure. Unlike {@link updateScheduleDate} this runs at ANY window
|
||||
* phase and inside the booking lead window — a maintenance move is an
|
||||
* operational fact, not a planning choice. What moves and what stays:
|
||||
*
|
||||
* - MOVES: scheduledDepartureDate; scheduledArrivalDate (same delta); every
|
||||
* aboard/targeted booking's scheduledDate (the day-pool queries key on it,
|
||||
* so a booking left on the old day would fall out of its own train's pool).
|
||||
* - STAYS: train set, wagon assignments, schedule↔booking links, route,
|
||||
* maxWagons, and the window RULE snapshot. Stamped window times are only
|
||||
* re-derived for PRE_WINDOW schedules (their window hasn't run yet); a
|
||||
* schedule mid- or post-window keeps its timeline untouched.
|
||||
*
|
||||
* Customers of every moved booking are notified (maintenanceMoved).
|
||||
*/
|
||||
async maintenanceReschedule(
|
||||
id: string,
|
||||
dto: MaintenanceRescheduleDto,
|
||||
): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${id} not found`);
|
||||
}
|
||||
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
|
||||
throw new BadRequestException(
|
||||
`Cannot reschedule a ${schedule.status.toLowerCase()} train`,
|
||||
);
|
||||
}
|
||||
|
||||
const departure = new Date(dto.newDepartureDate);
|
||||
if (Number.isNaN(departure.getTime())) {
|
||||
throw new BadRequestException('Invalid departure date.');
|
||||
}
|
||||
if (departure.getTime() <= Date.now()) {
|
||||
throw new BadRequestException('New departure must be in the future.');
|
||||
}
|
||||
|
||||
const deltaMs =
|
||||
departure.getTime() - new Date(schedule.scheduledDepartureDate).getTime();
|
||||
const scheduledArrivalDate = schedule.scheduledArrivalDate
|
||||
? new Date(new Date(schedule.scheduledArrivalDate).getTime() + deltaMs)
|
||||
: undefined;
|
||||
|
||||
// PRE_WINDOW only: the stamped open/close were derived from the old
|
||||
// departure and the window hasn't opened yet, so re-derive them from the
|
||||
// schedule's own rule snapshot against the new date (joining the target
|
||||
// day's route group timeline when one exists, exactly like
|
||||
// updateScheduleDate). Mid/post-window schedules keep their timeline.
|
||||
const windowFields =
|
||||
schedule.windowPhase === 'PRE_WINDOW'
|
||||
? await (async () => {
|
||||
const merged = effectiveWindowConfig(
|
||||
schedule,
|
||||
await this.getWindowConfig(),
|
||||
);
|
||||
const times =
|
||||
schedule.direction === 'EXPORT'
|
||||
? computeExportWindowTimes(departure, merged)
|
||||
: computeImportWindowTimes(departure, merged, new Date());
|
||||
const anchor =
|
||||
schedule.direction === 'EXPORT'
|
||||
? null
|
||||
: await this.findGroupWindowAnchor(
|
||||
this.dataSource.manager,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
departure,
|
||||
);
|
||||
return anchor
|
||||
? this.groupWindowFieldsFrom(anchor, departure)
|
||||
: {
|
||||
windowOpensAt: times.windowOpensAt,
|
||||
windowClosesAt: times.windowClosesAt,
|
||||
};
|
||||
})()
|
||||
: {};
|
||||
|
||||
await this.dataSource.getRepository(TrainSchedule).update(id, {
|
||||
scheduledDepartureDate: departure,
|
||||
...(scheduledArrivalDate ? { scheduledArrivalDate } : {}),
|
||||
...windowFields,
|
||||
});
|
||||
|
||||
// Everything aboard or targeted rides along: bookings linked on the train
|
||||
// (schedule_bookings) plus reservations still pointing at it via
|
||||
// train_schedule_id (paid-but-unlinked, awaiting payment, …).
|
||||
const linkedIds = (schedule.scheduleBookings ?? []).map((sb) => sb.bookingId);
|
||||
const targeted = await this.dataSource.getRepository(Booking).find({
|
||||
where: [{ trainScheduleId: id }, ...(linkedIds.length ? [{ id: In(linkedIds) }] : [])],
|
||||
relations: { company: true },
|
||||
});
|
||||
const aboard = targeted.filter(
|
||||
(b) => !['CANCELLED', 'EXPIRED', 'REJECTED'].includes(b.status),
|
||||
);
|
||||
if (aboard.length) {
|
||||
await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.update(aboard.map((b) => b.id), { scheduledDate: departure } as never);
|
||||
for (const booking of aboard) {
|
||||
this.bookingNotifier.maintenanceMoved(booking, departure);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`[MAINTENANCE] Schedule ${schedule.reference ?? id} moved to ${departure.toISOString()} ` +
|
||||
`(${dto.trigger ?? 'TRAIN_MAINTENANCE'}${dto.reason ? `: ${dto.reason}` : ''}); ` +
|
||||
`${aboard.length} booking(s) moved with the train.`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
const fresh = await this.trainSchedulesRepository.findById(id);
|
||||
return fresh ?? schedule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
|
||||
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
|
||||
@@ -4717,6 +4833,7 @@ export class TrainSchedulingService {
|
||||
createdAt: schedule.createdAt ?? null,
|
||||
scheduleDate: schedule.scheduledDepartureDate,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
routeName: schedule.route ? formatRouteLabel(schedule.route) : null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
@@ -5942,6 +6059,64 @@ export class TrainSchedulingService {
|
||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||
);
|
||||
|
||||
// The trainSet slots below are the PLANNED wagons (one per allocation). A
|
||||
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
|
||||
// included (the pull-limit check already counts their tare) — so append the
|
||||
// train's remaining wagons as consist-only entries and the composition views
|
||||
// (scheduling-v2 finalize, batch-board composition tab) draw the train as it
|
||||
// really is: loaded slots first, then the empty consist. Skipped for frozen
|
||||
// (dispatched/arrived) schedules: their wagons are released and re-pinned to
|
||||
// later trains, so the live consist no longer describes THIS departure.
|
||||
const coveredPhysicalIds = new Set<string>();
|
||||
for (const slot of schedule.trainSet?.wagons ?? []) {
|
||||
const frozenSlot = isWagonAllocationFrozen
|
||||
? snapshotSlotByTrainSetWagonId.get(slot.id)
|
||||
: undefined;
|
||||
const physicalId = frozenSlot
|
||||
? frozenSlot.physicalWagonId
|
||||
: slot.physicalWagonId ?? null;
|
||||
if (physicalId) coveredPhysicalIds.add(physicalId);
|
||||
}
|
||||
const maxSlotSequenceNo = Math.max(
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
);
|
||||
const emptyConsistWagons =
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? (
|
||||
await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
})
|
||||
)
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -6109,7 +6284,8 @@ export class TrainSchedulingService {
|
||||
: null,
|
||||
})) ?? [],
|
||||
};
|
||||
}),
|
||||
})
|
||||
.concat(emptyConsistWagons),
|
||||
}
|
||||
: null,
|
||||
bookings:
|
||||
|
||||
@@ -421,6 +421,20 @@ export class WarehouseInventoryController {
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get('edr-truck-exit-paper/:assignmentId')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'Per-truck exit paper PDF for an EDR last-mile truck' })
|
||||
async edrTruckExitPaper(
|
||||
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.inventoryService.edrTruckExitPaper(assignmentId);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
|
||||
res.setHeader('Content-Length', buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/grn-document')
|
||||
@BookingStaff(FREIGHT_PERMS.warehouseInventory.view)
|
||||
@ApiOperation({ summary: 'View goods received note PDF' })
|
||||
|
||||
@@ -2827,6 +2827,16 @@ export class WarehouseInventoryService {
|
||||
: dto;
|
||||
const exitInspectionNote = this.buildExitInspectionNote(exitInspectionDto);
|
||||
|
||||
// The load actually leaving on this truck, in TONNES (the weighing UI is in
|
||||
// t). Null when the operator skipped weighing — containers may skip, bulk
|
||||
// never does.
|
||||
const grossTons = exitInspectionDto.grossWeight ?? null;
|
||||
const tareTons = exitInspectionDto.tareWeight ?? null;
|
||||
const netTons =
|
||||
grossTons != null && tareTons != null
|
||||
? Math.round((grossTons - tareTons) * 1000) / 1000
|
||||
: (exitInspectionDto.netWeight ?? null);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
@@ -2854,6 +2864,23 @@ export class WarehouseInventoryService {
|
||||
// an EXPORT concept (set when a truck delivers into the port). Import
|
||||
// load + weight are captured on truck departure, not arrival.
|
||||
}
|
||||
// EDR last-mile: stamp THIS truck's arrival. Matched by plate rather than
|
||||
// container so it works for bulk too (bulk trucks carry no container).
|
||||
if (dto.truckPlateNumber?.trim()) {
|
||||
await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET arrived_at = COALESCE(va.arrived_at, NOW()), updated_at = NOW()
|
||||
FROM freight.last_mile lm, freight.vehicles v
|
||||
WHERE va.last_mile_id = lm.id
|
||||
AND lm.booking_id = $1
|
||||
AND lm.deleted_at IS NULL
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.arrived_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
[item.bookingId, dto.truckPlateNumber.trim()],
|
||||
);
|
||||
}
|
||||
// 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.
|
||||
@@ -2874,6 +2901,34 @@ export class WarehouseInventoryService {
|
||||
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
|
||||
}
|
||||
}
|
||||
if (isTruckLeaving && item.bookingId && dto.truckPlateNumber?.trim()) {
|
||||
// EDR last-mile: this truck is leaving — record its exit and the load it
|
||||
// actually took. net_weight_tons drives the bulk drawdown (booking VGM
|
||||
// minus everything already hauled away).
|
||||
await manager.query(
|
||||
`UPDATE freight.last_mile_vehicle_assignments va
|
||||
SET departed_at = COALESCE($3::timestamptz, NOW()),
|
||||
arrived_at = COALESCE(va.arrived_at, NOW()),
|
||||
gross_weight_tons = $4,
|
||||
net_weight_tons = $5,
|
||||
updated_at = NOW()
|
||||
FROM freight.last_mile lm, freight.vehicles v
|
||||
WHERE va.last_mile_id = lm.id
|
||||
AND lm.booking_id = $1
|
||||
AND lm.deleted_at IS NULL
|
||||
AND v.id = va.vehicle_id
|
||||
AND (UPPER(v.power_plate_no) = UPPER($2) OR UPPER(v.plate_number) = UPPER($2))
|
||||
AND va.departed_at IS NULL
|
||||
AND va.deleted_at IS NULL`,
|
||||
[
|
||||
item.bookingId,
|
||||
dto.truckPlateNumber.trim(),
|
||||
dto.gateOutTime ?? null,
|
||||
grossTons,
|
||||
netTons,
|
||||
],
|
||||
);
|
||||
}
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
@@ -2892,9 +2947,56 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
});
|
||||
|
||||
// Tell the customer their truck has left — one hook covers BOTH self-haul and
|
||||
// EDR last-mile, since release() is the single exit path for either. Outside
|
||||
// the transaction and fire-and-forget: notifying must never fail the exit.
|
||||
if (isTruckLeaving && item.bookingId) {
|
||||
void this.notifyTruckDeparture(item.bookingId, dto.truckPlateNumber?.trim() ?? null, netTons);
|
||||
}
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort truck-departure notification to the booking's company across
|
||||
* every channel: in-app (portal inbox) + SMS + email. Never throws — a missing
|
||||
* provider or contact must not break the exit flow.
|
||||
*/
|
||||
private async notifyTruckDeparture(
|
||||
bookingId: string,
|
||||
plateNumber: string | null,
|
||||
netTons: number | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const [booking]: Array<{ companyId: string | null; reference: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT company_id AS "companyId", reference
|
||||
FROM freight.bookings
|
||||
WHERE id = $1 AND deleted_at IS NULL`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking?.companyId) return;
|
||||
const ref = booking.reference ?? bookingId;
|
||||
const truck = plateNumber ? `Truck ${plateNumber}` : 'A truck';
|
||||
const load = netTons != null && netTons > 0 ? ` carrying ${netTons} t` : '';
|
||||
const body = `${truck} has left the warehouse for booking ${ref}${load}.`;
|
||||
await this.inbox.notify({
|
||||
recipients: { companyId: booking.companyId },
|
||||
audience: NotificationAudience.PORTAL,
|
||||
type: NotificationType.BOOKING_STATUS,
|
||||
title: 'Truck left the warehouse',
|
||||
body,
|
||||
link: `/bookings/${bookingId}`,
|
||||
data: { bookingId, plateNumber, netTons, action: 'TRUCK_LEFT' },
|
||||
});
|
||||
await sendCompanyChannels(this.dataSource, this.notifications, booking.companyId, body);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Truck-departure notify failed for ${bookingId}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async releaseDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
@@ -3219,6 +3321,75 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit paper for an EDR last-mile truck (one per truck, keyed on the vehicle
|
||||
* assignment). Deliberately NOT gated on the handover: EDR handovers are
|
||||
* generated at delivery — i.e. after the truck has already left — so there is
|
||||
* nothing to sign at exit time. Warehouse-fee clearance still applies.
|
||||
*/
|
||||
async edrTruckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const [truck] = await this.dataSource.query(
|
||||
`SELECT lm.booking_id AS "bookingId",
|
||||
COALESCE(v.power_plate_no, v.plate_number) AS "plateNumber",
|
||||
COALESCE(
|
||||
v.assigned_driver_name,
|
||||
NULLIF(TRIM(CONCAT(d.first_name, ' ', d.last_name)), '')
|
||||
) AS "driverName",
|
||||
v.vehicle_type AS "truckType",
|
||||
va.gross_weight_tons AS "grossWeightKg",
|
||||
va.departed_at AS "departedAt",
|
||||
b.reference AS "bookingReference",
|
||||
company.name AS "customerName"
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = lm.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
LEFT JOIN freight.drivers d ON d.id = v.assigned_driver_id AND d.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
WHERE va.id = $1 AND va.deleted_at IS NULL`,
|
||||
[assignmentId],
|
||||
);
|
||||
if (!truck) throw new NotFoundException(`EDR truck assignment ${assignmentId} not found`);
|
||||
|
||||
const [inv]: Array<{ id: string }> = await this.dataSource.query(
|
||||
`SELECT id FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
|
||||
[truck.bookingId],
|
||||
);
|
||||
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
|
||||
|
||||
// Bulk trucks carry no containers — the table is then empty and the paper
|
||||
// stands on the weighed gross alone.
|
||||
const containers: Array<{ containerNumber: string; goods: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT vc.container_number AS "containerNumber",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
|
||||
FROM freight.last_mile_vehicle_containers vc
|
||||
JOIN freight.last_mile lm ON lm.id = vc.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.bookings b ON b.id = lm.booking_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
WHERE vc.assignment_id = $1 AND vc.deleted_at IS NULL
|
||||
ORDER BY vc.container_number`,
|
||||
[assignmentId],
|
||||
);
|
||||
|
||||
const html = this.buildTruckExitPaperHtml({
|
||||
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
|
||||
bookingReference: truck.bookingReference,
|
||||
customerName: truck.customerName,
|
||||
plateNumber: truck.plateNumber,
|
||||
driverName: truck.driverName ?? '-',
|
||||
truckType: truck.truckType ?? '-',
|
||||
grossWeightKg: Number(truck.grossWeightKg ?? 0),
|
||||
gateOut: truck.departedAt,
|
||||
containers,
|
||||
});
|
||||
return {
|
||||
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
|
||||
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
|
||||
};
|
||||
}
|
||||
|
||||
private buildTruckExitPaperHtml(data: {
|
||||
reference: string;
|
||||
bookingReference: string;
|
||||
@@ -3734,20 +3905,30 @@ export class WarehouseInventoryService {
|
||||
);
|
||||
} else {
|
||||
// EDR last-mile: the handover is per delivering truck. Resolve the
|
||||
// vehicle that carried this item's container so each truck gets its own
|
||||
// handover (falls back to a booking-level one when unresolvable).
|
||||
// vehicle from the truck's own container list (the earlier lookup went
|
||||
// through last_mile_container_allocations, which nothing ever writes —
|
||||
// so truckPlate was always null and every booking collapsed to a single
|
||||
// booking-level handover). Bulk has no container, so fall back to the
|
||||
// delivery's single truck; a booking-level handover when unresolvable.
|
||||
let truckPlate: string | null = null;
|
||||
if (item.containerId) {
|
||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||
FROM freight.last_mile_container_allocations lca
|
||||
JOIN freight.vehicles v ON v.id = lca.vehicle_id
|
||||
WHERE lca.container_id = $1 AND lca.vehicle_id IS NOT NULL
|
||||
LIMIT 1`,
|
||||
[item.containerId],
|
||||
);
|
||||
truckPlate = veh?.plate ?? null;
|
||||
}
|
||||
const [veh]: Array<{ plate: string | null }> = await manager.query(
|
||||
`SELECT COALESCE(v.power_plate_no, v.plate_number) AS plate
|
||||
FROM freight.last_mile_vehicle_assignments va
|
||||
JOIN freight.last_mile lm
|
||||
ON lm.id = va.last_mile_id AND lm.deleted_at IS NULL
|
||||
JOIN freight.vehicles v ON v.id = va.vehicle_id
|
||||
LEFT JOIN freight.last_mile_vehicle_containers vc
|
||||
ON vc.assignment_id = va.id AND vc.deleted_at IS NULL
|
||||
LEFT JOIN freight.containers cont
|
||||
ON cont.container_number = vc.container_number AND cont.deleted_at IS NULL
|
||||
WHERE lm.booking_id = $1
|
||||
AND va.deleted_at IS NULL
|
||||
AND ($2::uuid IS NULL OR cont.id = $2::uuid)
|
||||
ORDER BY (cont.id IS NOT NULL) DESC, va.created_at ASC
|
||||
LIMIT 1`,
|
||||
[item.bookingId, item.containerId ?? null],
|
||||
);
|
||||
truckPlate = veh?.plate ?? null;
|
||||
await this.handover.ensureAtDelivery(item.bookingId, { truckPlate }, manager);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { SeedEdRWagonFleet1750400000000 } from '../migrations/1750400000000-SeedEdRWagonFleet';
|
||||
import { SeedEdrWagonFleetErNumbering2260000000000 } from '../migrations/2260000000000-SeedEdrWagonFleetErNumbering';
|
||||
|
||||
async function seedEdRWagons() {
|
||||
await AppDataSource.initialize();
|
||||
@@ -10,28 +10,32 @@ async function seedEdRWagons() {
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
|
||||
await new SeedEdRWagonFleet1750400000000().up(queryRunner);
|
||||
await new SeedEdrWagonFleetErNumbering2260000000000().up(queryRunner);
|
||||
|
||||
const [summary] = await queryRunner.query(`
|
||||
const summary = await queryRunner.query(`
|
||||
SELECT
|
||||
COUNT(*)::int AS total,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'PW2')::int AS pw2,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'CW4')::int AS cw4,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'CW3')::int AS cw3,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'KW2')::int AS kw2,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'KW3')::int AS kw3,
|
||||
COUNT(*) FILTER (WHERE wt.code = 'NW5')::int AS nw5,
|
||||
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['CONTAINER'])::int AS container_ready,
|
||||
COUNT(*) FILTER (WHERE wt.supported_load_types @> ARRAY['BULK'])::int AS bulk_ready,
|
||||
COUNT(*) FILTER (WHERE w.status = 'IMPORT_READY')::int AS import_ready
|
||||
wt.code,
|
||||
wt.name,
|
||||
COUNT(*)::int AS wagons,
|
||||
MIN(w.wagon_number) AS first_wagon,
|
||||
MAX(w.wagon_number) AS last_wagon,
|
||||
COUNT(*) FILTER (WHERE w.status = 'AVAILABLE')::int AS available,
|
||||
COUNT(*) FILTER (WHERE w.current_yard_id IS NULL)::int AS unassigned_yard
|
||||
FROM freight.wagons w
|
||||
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id
|
||||
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER0940';
|
||||
WHERE w.wagon_number BETWEEN 'ER0001' AND 'ER1100'
|
||||
GROUP BY wt.code, wt.name
|
||||
ORDER BY MIN(w.wagon_number);
|
||||
`);
|
||||
|
||||
const [totals] = await queryRunner.query(`
|
||||
SELECT COUNT(*)::int AS total FROM freight.wagons;
|
||||
`);
|
||||
|
||||
await queryRunner.commitTransaction();
|
||||
|
||||
console.log('Seeded EDR wagon fleet:', summary);
|
||||
console.table(summary);
|
||||
console.log(`Seeded EDR wagon fleet — ${totals.total} wagons total (expected 1100).`);
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
|
||||
Reference in New Issue
Block a user