|
|
|
|
@@ -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.
|
|
|
|
|
*
|
|
|
|
|
|