Merge branch 'dev'

This commit is contained in:
Marshal
2026-08-13 14:14:31 +00:00
219 changed files with 13585 additions and 5559 deletions

View File

@@ -0,0 +1,106 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import {
IsArray,
IsDateString,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from "class-validator";
import { PAYMENT_CURRENCIES } from "../../contracts/dto/create-contract.dto";
/**
* One container line the shipping line ships — container type + count, the
* same shape the customer one-time form collects (no per-unit ISO numbers;
* those are captured downstream at yard operations, as for customers).
*/
export class CompleteShippingLineContainerLineDto {
@ApiProperty({ format: "uuid", description: "Container type being shipped." })
@IsUUID()
containerTypeId!: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
quantity!: number;
@ApiPropertyOptional({ minimum: 0, description: "VGM per container, tons." })
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
vgmPerUnitTons?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
hazardousQuantity?: number;
@ApiPropertyOptional({ minimum: 0 })
@IsOptional()
@IsInt()
@Min(0)
@Transform(({ value }) => Number(value))
reeferQuantity?: number;
}
/**
* Completion payload for a shipping-line booking whose documents Operations
* has approved (CLEARANCE_READY): the cargo and the binding shipment day —
* the two things `initiate` deliberately left empty.
*/
export class CompleteShippingLineBookingDto {
@ApiProperty({
description: "Binding shipment day (train departure day).",
example: "2026-09-01",
})
@IsDateString()
scheduledDate!: string;
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({
type: [CompleteShippingLineContainerLineDto],
description: "Container freight: what ships. Required for CONTAINER bookings.",
})
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => CompleteShippingLineContainerLineDto)
containers?: CompleteShippingLineContainerLineDto[];
@ApiPropertyOptional({
format: "uuid",
description: "Bulk freight: the cargo type. Required for BULK bookings.",
})
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({
minimum: 0,
description: "Bulk freight: total weight in tons. Required for BULK bookings.",
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoWeightTons?: number;
@ApiPropertyOptional({ description: "What the containers carry." })
@IsOptional()
@IsString()
cargoFreeText?: string;
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsOptional, IsUUID } from "class-validator";
import { IsDateString, IsIn, IsOptional, IsUUID } from "class-validator";
import { FREIGHT_TYPES } from "../../bookings/entities/booking.entity";
@@ -35,4 +35,13 @@ export class InitiateShippingLineBookingDto {
@IsOptional()
@IsIn(FREIGHT_TYPES)
freightType?: string;
@ApiProperty({
required: false,
description:
"Intended shipment day (YYYY-MM-DD). Unlike the customer flow it is picked up front — a shipping line has no later operation-request step to choose it at.",
})
@IsOptional()
@IsDateString()
scheduledDate?: string;
}

View File

@@ -11,6 +11,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import { PortalCustomer } from "../../common/booking-guards";
import { CancelShippingLineBookingDto } from "./dto/cancel-shipping-line-booking.dto";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
import { ShippingLineBookingsService } from "./shipping-line-bookings.service";
@@ -66,6 +67,17 @@ export class ShippingLineBookingsController {
return this.shippingLineBookingsService.listMine(user.id);
}
// Declared before @Get(":id") so the path isn't captured as a booking id.
@Get("my-trains")
@PortalCustomer()
@ApiOperation({
summary:
"Train departures dedicated to the signed-in shipping line. These trains are hidden from customers; this is the only portal read that surfaces them.",
})
async listMyTrains(@CurrentUser() user: CurrentIamUser) {
return this.shippingLineBookingsService.listMyTrains(user.id);
}
@Get(":id")
@PortalCustomer()
@ApiOperation({ summary: "Get one of the signed-in shipping line's bookings." })
@@ -76,6 +88,33 @@ export class ShippingLineBookingsController {
return this.shippingLineBookingsService.findMine(user.id, id);
}
@Get(":id/available-days")
@PortalCustomer()
@ApiOperation({
summary:
"Days with an open departure that can carry this booking's cargo — for the completion form's day picker.",
})
async availableDays(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.shippingLineBookingsService.availableDaysMine(user.id, id);
}
@Post(":id/complete")
@PortalCustomer()
@ApiOperation({
summary:
"Complete an approved (CLEARANCE_READY) booking: cargo + binding shipment day. Prices off the line's rates, records the charge on the credit ledger and requests operation.",
})
async completeMine(
@CurrentUser() user: CurrentIamUser,
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: CompleteShippingLineBookingDto,
) {
return this.shippingLineBookingsService.completeMine(user.id, id, dto);
}
@Post(":id/cancel")
@PortalCustomer()
@ApiOperation({

View File

@@ -6,15 +6,30 @@ import {
NotFoundException,
} from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { In, Repository } from "typeorm";
import { In, MoreThanOrEqual, Repository } from "typeorm";
import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingDocumentReview } from "../bookings/entities/booking-document-review.entity";
import { BookingReviewNote } from "../bookings/entities/booking-review-note.entity";
import { Booking } from "../bookings/entities/booking.entity";
import { formatRouteLabel, Route } from "../routes/entities/route.entity";
import { wagonsPerUnitForSize } from "../rule-engine/container-type.util";
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import { ServiceType } from "../rule-engine/entities/service-type.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
import { CompleteShippingLineBookingDto } from "./dto/complete-shipping-line-booking.dto";
import { InitiateShippingLineBookingDto } from "./dto/initiate-shipping-line-booking.dto";
import {
ShippingLineCredit,
ShippingLineCreditStatus,
} from "./entities/shipping-line-credit.entity";
import { ShippingLineCompaniesService } from "./shipping-line-companies.service";
import { ShippingLineCreditsService } from "./shipping-line-credits.service";
/**
* The only trade direction a shipping line books.
@@ -62,6 +77,11 @@ export class ShippingLineBookingsService {
@InjectRepository(Booking)
private readonly bookingsRepository: Repository<Booking>,
private readonly shippingLineCompaniesService: ShippingLineCompaniesService,
private readonly bookingsService: BookingsService,
private readonly bookingPricingService: BookingPricingService,
private readonly bookingTransitionService: BookingTransitionService,
private readonly trainSchedulingService: TrainSchedulingService,
private readonly creditsService: ShippingLineCreditsService,
) {}
/**
@@ -98,28 +118,38 @@ export class ShippingLineBookingsService {
async referenceData(userId: string) {
await this.requireShippingLine(userId);
const [routes, serviceTypes] = await Promise.all([
this.bookingsRepository.manager.getRepository(Route).find({
// Shipping lines only move inbound cargo: it lands at the Djibouti port
// and runs inland to Ethiopia. Filtering here rather than in the portal
// means an export or intercity lane is never offered AND never
// accepted — `initiate` re-checks the same rule below.
where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION },
relations: { originYard: true, destinationYard: true },
}),
// Customs-bundled services are excluded: those run the phased ET/DJ
// customs workflow, which is a contract-backed flow a shipping line has
// no part in. Their clearance is the single document set Operations
// reviews on the booking itself.
this.bookingsRepository.manager.getRepository(ServiceType).find({
where: {
canBeBookedAlone: true,
includesCustoms: false,
isActive: true,
},
order: { displayOrder: "ASC" },
}),
]);
const [routes, serviceTypes, containerTypes, cargoTypes] =
await Promise.all([
this.bookingsRepository.manager.getRepository(Route).find({
// Shipping lines only move inbound cargo: it lands at the Djibouti port
// and runs inland to Ethiopia. Filtering here rather than in the portal
// means an export or intercity lane is never offered AND never
// accepted — `initiate` re-checks the same rule below.
where: { status: "AVAILABLE", direction: SHIPPING_LINE_DIRECTION },
relations: { originYard: true, destinationYard: true },
}),
// Customs-bundled services are excluded: those run the phased ET/DJ
// customs workflow, which is a contract-backed flow a shipping line has
// no part in. Their clearance is the single document set Operations
// reviews on the booking itself.
this.bookingsRepository.manager.getRepository(ServiceType).find({
where: {
canBeBookedAlone: true,
includesCustoms: false,
isActive: true,
},
order: { displayOrder: "ASC" },
}),
// For the completion form: what ships. Container types for CONTAINER
// bookings, cargo types for BULK ones.
this.bookingsRepository.manager.getRepository(ContainerType).find({
where: { isActive: true },
}),
this.bookingsRepository.manager.getRepository(CargoType).find({
where: { isActive: true },
order: { displayOrder: "ASC" },
}),
]);
return {
routes: routes.map((route) => ({
@@ -142,6 +172,19 @@ export class ShippingLineBookingsService {
id: service.id,
name: service.serviceName,
})),
containerTypes: containerTypes.map((ct) => ({
id: ct.id,
label: ct.label ?? ct.code,
sizeFt: ct.sizeFt,
isReefer: ct.isReefer,
})),
// parentGroupId lets the portal tell leaf types from grouping rows.
cargoTypes: cargoTypes.map((cargo) => ({
id: cargo.id,
name: cargo.cargoTypeName,
parentGroupId: cargo.parentGroupId ?? null,
unitOfMeasure: cargo.unitOfMeasure ?? null,
})),
};
}
@@ -177,6 +220,20 @@ export class ShippingLineBookingsService {
);
}
// Only a forward-looking day makes sense; train validation happens later
// when Operations schedules it, so only the past is rejected here.
let scheduledDate: Date | null = null;
if (dto.scheduledDate) {
scheduledDate = new Date(dto.scheduledDate);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (scheduledDate < today) {
throw new BadRequestException(
"The scheduled date cannot be in the past.",
);
}
}
// Same reasoning as the picker filter: a customs-bundled service would put
// the booking into the phased customs workflow, which has no contract to
// hang off here. Checked server-side because the id comes from the request.
@@ -222,8 +279,10 @@ export class ShippingLineBookingsService {
tradeDirection: route.direction,
serviceTypeId: dto.serviceTypeId ?? null,
freightType: dto.freightType ?? "CONTAINER",
// Bare instance — filled in when the booking is completed.
scheduledDate: null,
// The shipping line picks its shipment day up front (no later
// operation-request step exists for them); cargo is still filled in
// when the booking is completed.
scheduledDate,
cargoTypeId: null,
cargoTotalWeightVgm: 0,
} as never),
@@ -291,6 +350,310 @@ export class ShippingLineBookingsService {
return { ...booking, hasQueriedDocuments: queriedCount > 0 };
}
/**
* Train departures dedicated to the signed-in shipping line: schedules whose
* `shippingLineCompanyId` is this line's. These trains are hidden from every
* customer-facing read, so this endpoint is the ONLY place they surface in
* the portal — the home page lists them and the booking detail matches them
* to a booking by lane + day.
*/
async listMyTrains(userId: string) {
const shippingLine = await this.requireShippingLine(userId);
// Recent past kept (48h) so a just-departed train is still visible while
// its cargo is on the rails; CANCELLED never shows.
const horizon = new Date(Date.now() - 48 * 60 * 60 * 1000);
const schedules = await this.bookingsRepository.manager
.getRepository(TrainSchedule)
.find({
where: {
shippingLineCompanyId: shippingLine.id,
status: In(["DRAFT", "SCHEDULED", "DISPATCHED"]),
scheduledDepartureDate: MoreThanOrEqual(horizon),
},
relations: { originStation: true, destinationStation: true },
order: { scheduledDepartureDate: "ASC" },
});
return schedules.map((s) => ({
id: s.id,
reference: s.reference,
trainNumber: s.trainNumber,
status: s.status,
direction: s.direction,
scheduledDepartureDate: s.scheduledDepartureDate,
scheduledArrivalDate: s.scheduledArrivalDate,
originYardId: s.originStationId,
originLabel: s.originStation?.label ?? s.originStation?.code ?? "Origin",
destinationYardId: s.destinationStationId,
destinationLabel:
s.destinationStation?.label ??
s.destinationStation?.code ??
"Destination",
}));
}
/**
* Days the shipping line may pick as the shipment day — cargo-aware when the
* booking already carries cargo, departure-only before that. Same helper the
* customer day picker uses; ownership is checked first so one line cannot
* probe another's booking.
*/
async availableDaysMine(userId: string, bookingId: string) {
const shippingLine = await this.requireShippingLine(userId);
const owned = await this.bookingsRepository.exists({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
});
if (!owned) throw new NotFoundException(`Booking ${bookingId} not found`);
return this.bookingsService.availableDaysForBooking(bookingId);
}
/**
* Complete a bare shipping-line booking once Operations has approved its
* documents (CLEARANCE_READY), or after Operations returned the request
* (OPERATION_CHANGES_REQUESTED). This is the deferred half of
* {@link initiate}, mirroring what a customer does at this point: the cargo
* and the binding shipment day go in, the booking is priced off the line's
* negotiated rates, and the request lands with Operations
* (OPERATION_REQUEST_PENDING) through the same transition customers use.
*
* Payment differs from customers by design: no invoice is issued here.
* Shipping lines run on the credit ledger — the priced amount is recorded as
* an UNBILLED credit and Finance bills a batch later, so the booking
* proceeds without a payment gate.
*/
async completeMine(
userId: string,
bookingId: string,
dto: CompleteShippingLineBookingDto,
) {
const shippingLine = await this.requireShippingLine(userId);
const booking = await this.bookingsRepository.findOne({
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
relations: { bookingContainers: true },
});
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
booking.status,
)
) {
throw new BadRequestException(
"Your documents must be approved before the booking can be completed.",
);
}
// Completion is booking time: the route's booking window must be open —
// the same config-driven gate a customer booking passes.
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: booking.originYardId ?? null,
destinationYardId: booking.destinationYardId ?? null,
scheduledDate: dto.scheduledDate,
direction: booking.tradeDirection ?? null,
});
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const restatesCargo = Boolean(
dto.containers?.length || dto.cargoTypeId || dto.cargoWeightTons,
);
// Operations may return the request asking for the CARGO to change, not
// just the day. A resubmit that restates cargo starts completion over:
// the recorded (unbilled) credit is written off and the persisted cargo
// wiped, so the fresh path below re-persists, re-prices and re-records.
// Once the credit is on an issued invoice the cargo is frozen — the
// invoice total must keep matching what it bills.
if (hasCargo && restatesCargo) {
const credit = await this.bookingsRepository.manager
.getRepository(ShippingLineCredit)
.findOne({ where: { bookingId } });
if (credit && credit.status === ShippingLineCreditStatus.Unbilled) {
await this.creditsService.cancelCredit(
credit.id,
"Cargo changed before billing — booking re-priced on completion.",
);
} else if (
credit &&
credit.status !== ShippingLineCreditStatus.Cancelled
) {
throw new BadRequestException(
"This booking's charge has already been invoiced — contact Operations to change its cargo.",
);
}
await this.wipeCargo(bookingId);
hasCargo = false;
}
// First completion persists cargo and prices the booking; a day-only
// resubmit after OPERATION_CHANGES_REQUESTED skips straight to the
// operation request with the cargo (and price) it already carries.
if (!hasCargo) {
if (booking.freightType === "CONTAINER") {
await this.persistContainerLines(booking, dto);
} else {
if (!dto.cargoTypeId || !(Number(dto.cargoWeightTons) > 0)) {
throw new BadRequestException(
"Bulk bookings need a cargo type and a total weight in tons.",
);
}
const cargoType = await this.bookingsRepository.manager
.getRepository(CargoType)
.findOne({ where: { id: dto.cargoTypeId, isActive: true } });
if (!cargoType) {
throw new NotFoundException(
`Cargo type ${dto.cargoTypeId} not found`,
);
}
}
await this.bookingsRepository.update(bookingId, {
cargoTypeId:
booking.freightType === "BULK" ? (dto.cargoTypeId ?? null) : null,
cargoFreeText: dto.cargoFreeText?.trim() || null,
cargoTotalWeightVgm:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : 0,
bulkTotalWeightTons:
booking.freightType === "BULK" ? Number(dto.cargoWeightTons) : null,
// Hazard is per-line for containers; the booking-level flag is what
// pricing bills the surcharge from.
isHazardous: (dto.containers ?? []).some(
(line) => Number(line.hazardousQuantity ?? 0) > 0,
),
// Completion fixes the cargo — and therefore the price — so it is also
// where the billing currency is chosen.
paymentCurrency: dto.paymentCurrency ?? booking.paymentCurrency,
} as never);
const loaded = await this.bookingsRepository.findOne({
where: { id: bookingId },
relations: { bookingContainers: true, serviceType: true },
});
const computed = await this.bookingPricingService.computePriceForBooking(
loaded ?? booking,
);
// A zero price or hard block means no rate is configured for this line
// on this lane. Roll the cargo back so the booking stays completable —
// the approved clearance is not lost — and surface why.
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
await this.wipeCargo(bookingId);
throw new BadRequestException(
computed.hardBlocked.length > 0
? computed.hardBlocked.join("; ")
: "No rate is configured for your shipping line on this route/cargo — please contact Operations.",
);
}
await this.bookingsRepository.update(bookingId, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
await this.bookingPricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
// The charge goes on the line's credit ledger ("use now, pay later") —
// idempotent per booking, so a retried completion cannot double the debt.
await this.creditsService.recordCredit({
bookingId,
amount: computed.totalAmount,
currency: computed.currency,
description: `Freight service — booking ${booking.reference}`,
});
}
// Binding day + open-departure validation, OPERATION_REQUEST_PENDING and
// the staff notification — the exact machine a customer booking uses.
await this.bookingTransitionService.requestOperation(
bookingId,
dto.scheduledDate,
null,
);
return this.findMine(userId, bookingId);
}
/**
* Persist the container lines of a CONTAINER completion. Same row shape the
* customer paths write (quantity per type, VGM totals, wagon share) — the
* per-unit ISO numbers customers also skip at booking time arrive later at
* yard operations.
*/
private async persistContainerLines(
booking: Booking,
dto: CompleteShippingLineBookingDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) {
throw new BadRequestException(
"At least one container line is required.",
);
}
const containerTypeRepo =
this.bookingsRepository.manager.getRepository(ContainerType);
const containerRepo =
this.bookingsRepository.manager.getRepository(BookingContainer);
for (const line of lines) {
const containerType = await containerTypeRepo.findOne({
where: { id: line.containerTypeId, isActive: true },
});
if (!containerType) {
throw new NotFoundException(
`Container type ${line.containerTypeId} not found`,
);
}
const hazardous = Math.min(
Number(line.hazardousQuantity ?? 0),
line.quantity,
);
const reefer = Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
await containerRepo.save(
containerRepo.create({
bookingId: booking.id,
containerTypeId: containerType.id,
containerSize: containerType.sizeFt
? `${containerType.sizeFt}ft`
: null,
quantity: line.quantity,
hazardousQuantity: hazardous,
reeferQuantity: reefer,
returnQuantity: 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: vgmPerUnit * line.quantity,
wagonsRequired: Math.ceil(
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
),
}),
);
}
}
/** Roll a failed/superseded completion back to the bare-booking shape. */
private async wipeCargo(bookingId: string): Promise<void> {
await this.bookingsRepository.manager
.getRepository(BookingContainer)
.softDelete({ bookingId });
await this.bookingsRepository.update(bookingId, {
cargoTypeId: null,
cargoTotalWeightVgm: 0,
bulkTotalWeightTons: null,
totalAmount: 0,
pricingBreakdown: null,
} as never);
}
/**
* Cancel one of the signed-in shipping line's own bookings.
*

View File

@@ -5,8 +5,10 @@ import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { FreightAuthModule } from "../auth/freight-auth.module";
import { BillingModule } from "../billing/billing.module";
import { BookingsModule } from "../bookings/bookings.module";
import { Booking } from "../bookings/entities/booking.entity";
import { OtpModule } from "../otp/otp.module";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { ShippingLineCompany } from "./entities/shipping-line-company.entity";
import { ShippingLineCredit } from "./entities/shipping-line-credit.entity";
import { ShippingLineBookingsController } from "./shipping-line-bookings.controller";
@@ -36,6 +38,12 @@ import { ShippingLineCreditsService } from "./shipping-line-credits.service";
// `shipping_line_credit.invoice.paid` event, but the module graph now cycles
// (billing -> companies -> here -> billing), so this edge needs forwardRef.
forwardRef(() => BillingModule),
// Booking completion reuses the customer machinery: pricing, the
// operation-request transition and the day picker. Both edges cycle back
// here (bookings -> rule-engine -> shipping-lines, train-scheduling ->
// bookings -> …), so both need forwardRef.
forwardRef(() => BookingsModule),
forwardRef(() => TrainSchedulingModule),
],
controllers: [
ShippingLineCompaniesController,