mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 05:18:11 +00:00
817 lines
32 KiB
TypeScript
817 lines
32 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from "@nestjs/common";
|
|
import { InjectRepository } from "@nestjs/typeorm";
|
|
import { In, Repository } from "typeorm";
|
|
|
|
import { BookingPricingService } from "../bookings/booking-pricing.service";
|
|
import { BookingTransitionService } from "../bookings/booking-transition.service";
|
|
import { BookingsService } from "../bookings/bookings.service";
|
|
import type { Container20ftUnit } from "../bookings/container-pairing.util";
|
|
import { ContainerValidationService } from "../bookings/container-validation.service";
|
|
import { BookingContainer } from "../bookings/entities/booking-container.entity";
|
|
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
|
|
import { Booking } from "../bookings/entities/booking.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 { eatDay } from "../train-scheduling/batch-window.util";
|
|
import {
|
|
BookingBatchService,
|
|
type TrainOptionCargoOverrides,
|
|
} from "../train-scheduling/booking-batch.service";
|
|
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
|
|
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
|
|
import { TrainSchedulingService } from "../train-scheduling/services/train-scheduling.service";
|
|
import { CompleteShippingLineBookingDto } from "./dto/complete-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";
|
|
|
|
/**
|
|
* Completion of a shipping-line booking — the step after Operations approves
|
|
* its documents, mirroring what a customer does at that point: cargo + binding
|
|
* shipment day go in, the booking prices off the line's negotiated rates and
|
|
* the request lands with Operations.
|
|
*
|
|
* Its own module (not part of {@link ShippingLineBookingsService}) because it
|
|
* needs BookingsModule (pricing, the operation-request transition) and
|
|
* TrainSchedulingModule — and ShippingLineCompaniesModule is imported by
|
|
* rule-engine/companies, which sit UNDER BookingsModule. Importing bookings
|
|
* from there closes a module cycle Nest cannot construct; a leaf module that
|
|
* nothing imports keeps the graph acyclic.
|
|
*/
|
|
@Injectable()
|
|
export class ShippingLineBookingCompletionService {
|
|
constructor(
|
|
@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 bookingBatchService: BookingBatchService,
|
|
private readonly creditsService: ShippingLineCreditsService,
|
|
private readonly containerValidationService: ContainerValidationService,
|
|
) {}
|
|
|
|
/**
|
|
* 20ft weight-pairing check over the completion payload — the same rule the
|
|
* customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t):
|
|
* two 20ft sharing a wagon must be within the cap. Preview surfaces the
|
|
* messages; completion hard-blocks on them. Runs off the DTO so nothing is
|
|
* persisted before the check passes.
|
|
*/
|
|
private async pairingViolationMessages(
|
|
dto: CompleteShippingLineBookingDto,
|
|
): Promise<string[]> {
|
|
const units: Container20ftUnit[] = [];
|
|
for (const line of dto.containers ?? []) {
|
|
const containerType = await this.resolveContainerType(line);
|
|
if (containerType.sizeFt !== 20) continue;
|
|
(line.units ?? []).forEach((u, idx) =>
|
|
units.push({
|
|
label: u.containerNumber || `20ft-${idx + 1}`,
|
|
grossWeightTons: Number(u.vgmTons ?? 0),
|
|
}),
|
|
);
|
|
}
|
|
const violations =
|
|
await this.containerValidationService.validate20ftPairingUnits(units);
|
|
return violations.map((v) => v.message);
|
|
}
|
|
|
|
/** Same session→owner resolution every shipping-line entry point uses. */
|
|
private async requireShippingLine(userId: string) {
|
|
const shippingLine =
|
|
await this.shippingLineCompaniesService.findByUserId(userId);
|
|
if (!shippingLine) {
|
|
throw new ForbiddenException("This account is not a shipping line.");
|
|
}
|
|
if (shippingLine.status !== "active") {
|
|
throw new ForbiddenException(
|
|
"This shipping-line account is suspended and cannot create bookings.",
|
|
);
|
|
}
|
|
return shippingLine;
|
|
}
|
|
|
|
private async requireOwnBooking(
|
|
userId: string,
|
|
bookingId: string,
|
|
relations?: { bookingContainers?: boolean },
|
|
) {
|
|
const shippingLine = await this.requireShippingLine(userId);
|
|
const booking = await this.bookingsRepository.findOne({
|
|
where: { id: bookingId, shippingLineCompanyId: shippingLine.id },
|
|
relations,
|
|
});
|
|
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
|
return booking;
|
|
}
|
|
|
|
/**
|
|
* The line's dedicated departures on the booking's lane (DRAFT/SCHEDULED,
|
|
* soonest first). These trains run NO booking-window cycle — the line books
|
|
* whenever it wants until the close offset stamped in `windowClosesAt` — and
|
|
* they are excluded from every customer pool, so this is the only source
|
|
* that can offer them.
|
|
*/
|
|
private async dedicatedTrainsForBooking(booking: Booking) {
|
|
if (!booking.shippingLineCompanyId) return [];
|
|
return this.bookingsRepository.manager.getRepository(TrainSchedule).find({
|
|
where: {
|
|
shippingLineCompanyId: booking.shippingLineCompanyId,
|
|
originStationId: booking.originYardId ?? undefined,
|
|
destinationStationId: booking.destinationYardId ?? undefined,
|
|
status: In(["DRAFT", "SCHEDULED"]),
|
|
},
|
|
order: { scheduledDepartureDate: "ASC" },
|
|
});
|
|
}
|
|
|
|
/** Still bookable: the close offset before departure has not passed yet. */
|
|
private isStillOpen(schedule: TrainSchedule): boolean {
|
|
const closesAt =
|
|
schedule.windowClosesAt ?? schedule.scheduledDepartureDate;
|
|
return closesAt.getTime() > Date.now();
|
|
}
|
|
|
|
/**
|
|
* The line's dedicated trains on the booking's lane for one shipment day,
|
|
* each with per-wagon-type free space — the completion form's train picker.
|
|
* A booking rides ONE schedule, so with several departures that day the
|
|
* line picks which; the pick is validated again at complete time.
|
|
*/
|
|
async trainsForDayMine(
|
|
userId: string,
|
|
bookingId: string,
|
|
date: string | undefined,
|
|
overrides?: TrainOptionCargoOverrides,
|
|
) {
|
|
const booking = await this.requireOwnBooking(userId, bookingId);
|
|
if (!booking.shippingLineCompanyId) return [];
|
|
return this.bookingBatchService.dedicatedTrainOptionsForDay(
|
|
booking,
|
|
date ? eatDay(new Date(date)) : null,
|
|
booking.shippingLineCompanyId,
|
|
overrides,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Days the shipping line may pick as the shipment day.
|
|
*
|
|
* Lanes with trains DEDICATED to this line offer exactly those trains' days,
|
|
* open until each train's close offset — no window cycle. Lanes without a
|
|
* dedicated train fall back to the shared customer day pool, exactly as
|
|
* before. Ownership is checked first so one line cannot probe another's
|
|
* booking.
|
|
*/
|
|
async availableDaysMine(userId: string, bookingId: string) {
|
|
const booking = await this.requireOwnBooking(userId, bookingId);
|
|
const dedicated = await this.dedicatedTrainsForBooking(booking);
|
|
if (dedicated.length === 0) {
|
|
return this.bookingsService.availableDaysForBooking(bookingId);
|
|
}
|
|
const days = [
|
|
...new Set(
|
|
dedicated
|
|
.filter((s) => this.isStillOpen(s))
|
|
.map((s) => eatDay(s.scheduledDepartureDate)),
|
|
),
|
|
];
|
|
return { days };
|
|
}
|
|
|
|
/**
|
|
* Complete a bare shipping-line booking once Operations has approved its
|
|
* documents (CLEARANCE_READY), or after Operations returned the request
|
|
* (OPERATION_CHANGES_REQUESTED). 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 booking = await this.requireOwnBooking(userId, bookingId, {
|
|
bookingContainers: true,
|
|
});
|
|
if (
|
|
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
|
|
booking.status,
|
|
)
|
|
) {
|
|
throw new BadRequestException(
|
|
"Your documents must be approved before the booking can be completed.",
|
|
);
|
|
}
|
|
|
|
// Unbalanced 20ft pairs can never be planned onto wagons — refuse before
|
|
// any cargo/credit write below. Same block the contract path applies.
|
|
if (booking.freightType === "CONTAINER") {
|
|
const pairing = await this.pairingViolationMessages(dto);
|
|
if (pairing.length) {
|
|
throw new BadRequestException(
|
|
`Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Completion is booking time. A lane with trains DEDICATED to this line
|
|
// has no window concept at all: the line books whenever it wants until the
|
|
// train's close offset. Only a lane with no dedicated train falls back to
|
|
// the customer window gate, unchanged.
|
|
const dedicated = await this.dedicatedTrainsForBooking(booking);
|
|
const pickedDay = eatDay(new Date(dto.scheduledDate));
|
|
const dedicatedOnDay = dedicated.filter(
|
|
(s) => eatDay(s.scheduledDepartureDate) === pickedDay,
|
|
);
|
|
let bypassDayPool = false;
|
|
let requestedTrainScheduleId: string | null = null;
|
|
if (dedicatedOnDay.length > 0) {
|
|
const openOnDay = dedicatedOnDay.filter((s) => this.isStillOpen(s));
|
|
if (openOnDay.length === 0) {
|
|
throw new BadRequestException(
|
|
"Booking for your train on this day has closed — the cut-off before departure has passed.",
|
|
);
|
|
}
|
|
// A booking rides ONE schedule. Several departures that day → the line
|
|
// must say which; a single one is picked implicitly. The id comes from
|
|
// the request, so it is validated against the day's own trains.
|
|
if (dto.trainScheduleId) {
|
|
const picked = openOnDay.find((s) => s.id === dto.trainScheduleId);
|
|
if (!picked) {
|
|
throw new BadRequestException(
|
|
"The selected train does not run your route on that day (or its booking cut-off has passed) — pick another train.",
|
|
);
|
|
}
|
|
requestedTrainScheduleId = picked.id;
|
|
} else if (openOnDay.length === 1) {
|
|
requestedTrainScheduleId = openOnDay[0].id;
|
|
} else {
|
|
throw new BadRequestException(
|
|
"More than one of your trains departs that day — select which train this booking rides.",
|
|
);
|
|
}
|
|
// The day is backed by the line's own train, which every customer pool
|
|
// deliberately excludes — so the day-pool gate downstream must not run.
|
|
bypassDayPool = true;
|
|
} else if (dedicated.length > 0) {
|
|
throw new BadRequestException(
|
|
"Pick one of your assigned train days for this route.",
|
|
);
|
|
} else {
|
|
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,
|
|
// Bulk handling portions — sized against the cargo, billed by pricing.
|
|
...(booking.freightType === "BULK"
|
|
? {
|
|
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
|
|
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
|
|
}
|
|
: {}),
|
|
// 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 ||
|
|
(line.units ?? []).some((u) => u.isHazardous),
|
|
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
|
|
// Same for reefer: the rule engine's REEFER trigger fires on the
|
|
// booking-level flag (or a reefer container TYPE) — a ticked reefer
|
|
// switch on a standard box only sets the per-line count, so without
|
|
// this flag the surcharge silently never bills.
|
|
isReefer:
|
|
(dto.containers ?? []).some(
|
|
(line) =>
|
|
Number(line.reeferQuantity ?? 0) > 0 ||
|
|
(line.units ?? []).some((u) => u.isReefer),
|
|
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
|
|
// Shipping lines are always billed in ETB: the charge lands on the
|
|
// ETB credit ledger, so the currency is enforced here rather than
|
|
// trusted from the payload.
|
|
paymentCurrency: "ETB",
|
|
} 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,
|
|
);
|
|
|
|
// No credit is recorded here: completion only REQUESTS the operation.
|
|
// The charge lands on the line's ledger when Operations accepts —
|
|
// `shipping_line_booking.accepted` → ShippingLineCreditsService — so a
|
|
// request that is returned or never accepted creates no debt.
|
|
}
|
|
|
|
// Binding day, OPERATION_REQUEST_PENDING and the staff notification — the
|
|
// machine a customer booking uses. When the day is backed by a dedicated
|
|
// train, the customer day-pool gate is skipped (validated above instead).
|
|
return this.bookingTransitionService.requestOperation(
|
|
bookingId,
|
|
dto.scheduledDate,
|
|
requestedTrainScheduleId,
|
|
bypassDayPool ? { bypassDayPool: true } : undefined,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Authoritative price preview for the completion form's confirm step: the
|
|
* SAME compute the completion itself runs, over an in-memory probe shaped
|
|
* exactly like completeMine would persist the booking — so the figure the
|
|
* shipping line confirms is line-for-line what it will owe.
|
|
*
|
|
* The result is not advisory-only: the breakdown is saved on the booking and
|
|
* the rate snapshots are (re)written, so every re-preview refreshes them.
|
|
* Nothing else is persisted — no cargo rows, no credit, no transition.
|
|
*/
|
|
async previewPriceMine(
|
|
userId: string,
|
|
bookingId: string,
|
|
dto: CompleteShippingLineBookingDto,
|
|
) {
|
|
const booking = await this.requireOwnBooking(userId, bookingId, {
|
|
bookingContainers: true,
|
|
});
|
|
if (
|
|
!["CLEARANCE_READY", "OPERATION_CHANGES_REQUESTED"].includes(
|
|
booking.status,
|
|
)
|
|
) {
|
|
throw new BadRequestException(
|
|
"Your documents must be approved before the booking can be priced.",
|
|
);
|
|
}
|
|
|
|
// In-memory cargo, mirroring what completeMine persists.
|
|
let probeContainers: Partial<BookingContainer>[] = [];
|
|
let bulkFields: Record<string, unknown> = {};
|
|
if (booking.freightType === "CONTAINER") {
|
|
const lines = dto.containers ?? [];
|
|
if (!lines.length) {
|
|
throw new BadRequestException(
|
|
"At least one container line is required.",
|
|
);
|
|
}
|
|
for (const line of lines) {
|
|
const containerType = await this.resolveContainerType(line);
|
|
const figures = this.lineFigures(line);
|
|
probeContainers.push({
|
|
containerTypeId: containerType.id,
|
|
containerSize: containerType.sizeFt
|
|
? `${containerType.sizeFt}ft`
|
|
: null,
|
|
quantity: line.quantity,
|
|
hazardousQuantity: figures.hazardous,
|
|
reeferQuantity: figures.reefer,
|
|
returnQuantity: 0,
|
|
vgmPerUnitTons: figures.vgmPerUnit,
|
|
totalVgmTons: figures.totalVgm,
|
|
wagonsRequired: Math.ceil(
|
|
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
|
|
),
|
|
});
|
|
}
|
|
} 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`);
|
|
}
|
|
bulkFields = {
|
|
cargoTypeId: dto.cargoTypeId,
|
|
cargoTotalWeightVgm: Number(dto.cargoWeightTons),
|
|
bulkTotalWeightTons: Number(dto.cargoWeightTons),
|
|
bulkHazardousQuantity: Number(dto.bulkHazardousQuantity ?? 0),
|
|
bulkReeferQuantity: Number(dto.bulkReeferQuantity ?? 0),
|
|
};
|
|
probeContainers = [];
|
|
}
|
|
|
|
// Prototype-preserving clone so entity getters keep working — the same
|
|
// probe trick the contract preview uses.
|
|
const probe = Object.assign(
|
|
Object.create(Object.getPrototypeOf(booking)),
|
|
booking,
|
|
{
|
|
bookingContainers: probeContainers,
|
|
paymentCurrency: "ETB",
|
|
isHazardous:
|
|
(dto.containers ?? []).some(
|
|
(line) =>
|
|
Number(line.hazardousQuantity ?? 0) > 0 ||
|
|
(line.units ?? []).some((u) => u.isHazardous),
|
|
) || Number(dto.bulkHazardousQuantity ?? 0) > 0,
|
|
// Mirrors completeMine: without the booking-level flag the engine's
|
|
// REEFER trigger never fires for reefer opt-ins on standard boxes,
|
|
// and the quote would show base freight only.
|
|
isReefer:
|
|
(dto.containers ?? []).some(
|
|
(line) =>
|
|
Number(line.reeferQuantity ?? 0) > 0 ||
|
|
(line.units ?? []).some((u) => u.isReefer),
|
|
) || Number(dto.bulkReeferQuantity ?? 0) > 0,
|
|
...bulkFields,
|
|
},
|
|
) as Booking;
|
|
|
|
const computed =
|
|
await this.bookingPricingService.computePriceForBooking(probe);
|
|
if (!(computed.totalAmount > 0) || computed.hardBlocked.length > 0) {
|
|
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.",
|
|
);
|
|
}
|
|
|
|
// Persist the quoted figure: breakdown on the booking, snapshots of the
|
|
// rates it was built from. createPricingSnapshots clears the previous
|
|
// artifacts first, so a re-preview replaces the old quote rather than
|
|
// stacking a second one.
|
|
await this.bookingsRepository.update(bookingId, {
|
|
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,
|
|
);
|
|
|
|
// Pairing is reported, not thrown: the confirm modal shows it next to the
|
|
// price (as the customer form does) and disables confirm; /complete
|
|
// hard-blocks the same payload.
|
|
const pairingErrors =
|
|
booking.freightType === "CONTAINER"
|
|
? await this.pairingViolationMessages(dto)
|
|
: [];
|
|
|
|
return {
|
|
totalAmount: computed.totalAmount,
|
|
currency: computed.currency,
|
|
lineItems: computed.lineItems,
|
|
warnings: computed.warnings,
|
|
overweightLines: computed.overweightLines,
|
|
pairingErrors,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* What operations has done with the booking so far: the train it rides
|
|
* (assigned, or the requested one before assignment) and the wagons the
|
|
* batch engine allocated to it, with any container numbers loaded per wagon.
|
|
* Read-only, owner-scoped — feeds the detail page's Wagons & Train tab.
|
|
*/
|
|
async operationsMine(userId: string, bookingId: string) {
|
|
const booking = await this.requireOwnBooking(userId, bookingId);
|
|
const manager = this.bookingsRepository.manager;
|
|
|
|
const scheduleId =
|
|
booking.trainScheduleId ?? booking.requestedTrainScheduleId ?? null;
|
|
let train: Record<string, unknown> | null = null;
|
|
if (scheduleId) {
|
|
const schedule = await manager.getRepository(TrainSchedule).findOne({
|
|
where: { id: scheduleId },
|
|
relations: { originStation: true, destinationStation: true },
|
|
});
|
|
if (schedule) {
|
|
train = {
|
|
id: schedule.id,
|
|
reference: schedule.reference,
|
|
trainNumber: schedule.trainNumber,
|
|
status: schedule.status,
|
|
direction: schedule.direction,
|
|
scheduledDepartureDate: schedule.scheduledDepartureDate,
|
|
scheduledArrivalDate: schedule.scheduledArrivalDate,
|
|
originLabel:
|
|
schedule.originStation?.label ??
|
|
schedule.originStation?.code ??
|
|
"Origin",
|
|
destinationLabel:
|
|
schedule.destinationStation?.label ??
|
|
schedule.destinationStation?.code ??
|
|
"Destination",
|
|
// Whether this is the confirmed assignment or still the request.
|
|
assigned: Boolean(booking.trainScheduleId),
|
|
};
|
|
}
|
|
}
|
|
|
|
const allocations = await manager
|
|
.getRepository(WagonBookingAllocation)
|
|
.find({
|
|
where: { bookingId },
|
|
relations: {
|
|
trainSetWagon: { wagonType: true, physicalWagon: true },
|
|
containerItems: true,
|
|
},
|
|
order: { createdAt: "ASC" },
|
|
});
|
|
|
|
const wagons = allocations.map((allocation) => ({
|
|
id: allocation.id,
|
|
status: allocation.status,
|
|
loadType: allocation.loadType,
|
|
allocatedWeightTons: Number(allocation.allocatedWeightTons),
|
|
sequenceNo: allocation.trainSetWagon?.sequenceNo ?? null,
|
|
wagonNumber: allocation.trainSetWagon?.physicalWagon?.wagonNumber ?? null,
|
|
wagonType:
|
|
allocation.trainSetWagon?.wagonType?.name ??
|
|
allocation.trainSetWagon?.wagonType?.code ??
|
|
null,
|
|
capacityTons: Number(allocation.trainSetWagon?.capacityTons ?? 0),
|
|
containerNumbers: (allocation.containerItems ?? [])
|
|
.map((item) => item.containerNumber)
|
|
.filter((n): n is string => Boolean(n)),
|
|
}));
|
|
|
|
return { train, wagons };
|
|
}
|
|
|
|
/**
|
|
* Resolve a line's container type: by id when the payload carries one, else
|
|
* from the size string ("40ft" → the active 40ft type, preferring the reefer
|
|
* variant when the line ships reefer boxes). Resolution lives HERE, not in
|
|
* the portal, so a slow or failed catalog fetch can never block a booking
|
|
* with a phantom "type not configured" error — mirrors the customer flow's
|
|
* server-side size→type mapping.
|
|
*/
|
|
private async resolveContainerType(line: {
|
|
containerTypeId?: string;
|
|
containerSize?: string;
|
|
reeferQuantity?: number;
|
|
units?: { isReefer?: boolean }[];
|
|
}): Promise<ContainerType> {
|
|
const containerTypeRepo =
|
|
this.bookingsRepository.manager.getRepository(ContainerType);
|
|
|
|
if (line.containerTypeId) {
|
|
const byId = await containerTypeRepo.findOne({
|
|
where: { id: line.containerTypeId, isActive: true },
|
|
});
|
|
if (!byId) {
|
|
throw new NotFoundException(
|
|
`Container type ${line.containerTypeId} not found`,
|
|
);
|
|
}
|
|
return byId;
|
|
}
|
|
|
|
const sizeFt = parseInt(line.containerSize ?? "", 10);
|
|
if (!Number.isFinite(sizeFt)) {
|
|
throw new BadRequestException(
|
|
"Each container line needs a containerTypeId or a containerSize.",
|
|
);
|
|
}
|
|
const candidates = await containerTypeRepo.find({
|
|
where: { isActive: true },
|
|
});
|
|
const ofSize = candidates.filter((ct) => Number(ct.sizeFt) === sizeFt);
|
|
if (!ofSize.length) {
|
|
throw new BadRequestException(
|
|
`No ${sizeFt}ft container type is configured — please contact Operations.`,
|
|
);
|
|
}
|
|
const wantsReefer =
|
|
Number(line.reeferQuantity ?? 0) > 0 ||
|
|
(line.units ?? []).some((u) => u.isReefer);
|
|
if (wantsReefer) {
|
|
const reefer = ofSize.find((ct) => ct.isReefer);
|
|
if (reefer) return reefer;
|
|
}
|
|
return ofSize.find((ct) => !ct.isReefer) ?? ofSize[0];
|
|
}
|
|
|
|
/**
|
|
* A line's derived figures. With per-container rows (the full booking page),
|
|
* counts and VGM come FROM the rows — each container's switches are the
|
|
* source of truth. Without them, the line-level figures stand alone.
|
|
*/
|
|
private lineFigures(line: {
|
|
quantity: number;
|
|
vgmPerUnitTons?: number;
|
|
hazardousQuantity?: number;
|
|
reeferQuantity?: number;
|
|
units?: { vgmTons?: number; isHazardous?: boolean; isReefer?: boolean }[];
|
|
}) {
|
|
const units = line.units ?? [];
|
|
const hazardous = units.length
|
|
? units.filter((u) => u.isHazardous).length
|
|
: Math.min(Number(line.hazardousQuantity ?? 0), line.quantity);
|
|
const reefer = units.length
|
|
? units.filter((u) => u.isReefer).length
|
|
: Math.min(Number(line.reeferQuantity ?? 0), line.quantity);
|
|
const totalVgm = units.length
|
|
? units.reduce((s, u) => s + Number(u.vgmTons ?? 0), 0)
|
|
: Number(line.vgmPerUnitTons ?? 0) * line.quantity;
|
|
const vgmPerUnit = units.length
|
|
? totalVgm / units.length
|
|
: Number(line.vgmPerUnitTons ?? 0);
|
|
return { hazardous, reefer, totalVgm, vgmPerUnit };
|
|
}
|
|
|
|
/**
|
|
* 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 containerRepo =
|
|
this.bookingsRepository.manager.getRepository(BookingContainer);
|
|
const unitRepo =
|
|
this.bookingsRepository.manager.getRepository(BookingContainerUnit);
|
|
|
|
for (const line of lines) {
|
|
const containerType = await this.resolveContainerType(line);
|
|
// Counts and VGM derived by lineFigures — the same math the price
|
|
// preview runs, so the persisted cargo always matches the quote.
|
|
const units = line.units ?? [];
|
|
const figures = this.lineFigures(line);
|
|
const containerRow = await containerRepo.save(
|
|
containerRepo.create({
|
|
bookingId: booking.id,
|
|
containerTypeId: containerType.id,
|
|
containerSize: containerType.sizeFt
|
|
? `${containerType.sizeFt}ft`
|
|
: null,
|
|
quantity: line.quantity,
|
|
hazardousQuantity: figures.hazardous,
|
|
reeferQuantity: figures.reefer,
|
|
returnQuantity: 0,
|
|
vgmPerUnitTons: figures.vgmPerUnit,
|
|
totalVgmTons: figures.totalVgm,
|
|
wagonsRequired: Math.ceil(
|
|
line.quantity * wagonsPerUnitForSize(containerType.sizeFt),
|
|
),
|
|
}),
|
|
);
|
|
let sortOrder = 0;
|
|
for (const unit of units) {
|
|
await unitRepo.save(
|
|
unitRepo.create({
|
|
bookingContainerId: containerRow.id,
|
|
containerNumber: unit.containerNumber.trim().toUpperCase(),
|
|
sealNumber: unit.sealNumber?.trim() || null,
|
|
vgmTons: Number(unit.vgmTons ?? 0),
|
|
isHazardous: unit.isHazardous ?? false,
|
|
isReefer: unit.isReefer ?? false,
|
|
isReturn: false,
|
|
sortOrder: sortOrder++,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 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);
|
|
}
|
|
}
|