Files
edr-platform/apps/edr-freight-api/src/modules/bookings/bookings.service.ts
Marshal b6d5047d27 feat: Implement general contract booking orders functionality
- Add DTOs for creating booking orders and viewing contract quantities.
- Create entities for booking orders and booking order lines.
- Implement service for managing general contract operations, including activation after payment and retrieving quantity lines.
- Develop UI components for contract detail and list pages, including order placement dialog.
- Integrate API service for booking orders, enabling listing and creating orders against contracts.
- Enhance contract status display and quantity pool visualization in the UI.
2026-06-20 19:31:51 +00:00

1104 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
BadRequestException,
ConflictException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
BOOKING_STATUSES,
CUSTOMER_EDITABLE_STATUSES,
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
] as const;
@Injectable()
export class BookingsService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
// private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
) {}
/** Resolve trade direction from yard countries; reject client mismatch. */
private async resolveTradeDirectionForBooking(
originYardId: string,
destinationYardId: string,
provided?: string,
): Promise<string> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
const origin = yards.find((y) => y.id === originYardId);
const destination = yards.find((y) => y.id === destinationYardId);
if (!origin) {
throw new BadRequestException(`Origin yard ${originYardId} not found`);
}
if (!destination) {
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
}
if (originYardId === destinationYardId) {
throw new BadRequestException('Origin and destination yards must differ');
}
const expected = deriveTradeDirection(origin, destination);
if (provided && provided !== expected) {
throw new BadRequestException(
`tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
);
}
return expected;
}
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
/** Build evaluation input from booking freight shape. */
private async buildEvalInput(dto: {
freightType: FreightType;
cargoTypeId?: string | null;
serviceTypeId: string;
paymentCurrency: string;
tradeDirection: string;
isHazardous?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
const containerLines =
dto.freightType === 'CONTAINER' ? dto.containers : [];
const containers = await Promise.all(
containerLines.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
return {
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
};
}),
);
const totalWagons = Math.ceil(
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
);
return {
freightType: dto.freightType,
cargoTypeId: dto.cargoTypeId ?? null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
isGovernment: dto.isGovernment ?? false,
allowConsolidation:
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId,
totalWagons,
containers,
};
}
/**
* Enable consolidation when any container line leaves a wagon partially filled
* (e.g. 1×20ft on a 2-slot wagon, 1×10ft on a 4-slot wagon).
*
* Partial-wagon cargo ALWAYS consolidates — the customer cannot opt out of a
* half-empty wagon, so `explicit === false` is ignored when consolidation is
* actually needed. The opt-in flag only matters for cargo that already fills
* whole wagons (where consolidation is moot anyway).
*/
private async resolveConsolidation(
containers: CreateBookingContainerDto[],
explicit?: boolean,
): Promise<boolean> {
const needs = await this.consolidationService.needsConsolidation(
containers.map((c) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
})),
);
if (needs) return true;
return explicit ?? false;
}
/** Search for a complementary partner; pair or queue as PENDING_CONSOLIDATION. */
private async tryAutoConsolidate(booking: Booking): Promise<{
booking: Booking;
messages: string[];
}> {
const messages: string[] = [];
if (!booking.allowConsolidation || booking.consolidationPartnerId) {
return { booking, messages };
}
const slots = await this.consolidationService.slotsFromBooking(booking);
if (slots.length === 0) {
return { booking, messages };
}
const partner = await this.bookingsRepository.findConsolidationPartner(
booking,
slots,
);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
const paired = await this.findById(booking.id);
messages.push(
this.consolidationService.describePaired(partner.reference, slots),
);
return { booking: paired, messages };
}
// No partner yet — park the booking so it waits. Applies both pre-submit
// (DRAFT) and at submit time (SUBMITTED); accepted/approved bookings never
// reach this method.
if (booking.status === 'DRAFT' || booking.status === 'SUBMITTED') {
await this.bookingsRepository.parkForConsolidation(booking.id);
}
const pending = await this.findById(booking.id);
messages.push(this.consolidationService.describePending(pending, slots));
return { booking: pending, messages };
}
/**
* Run consolidation right after a booking reaches SUBMITTED. If a complementary
* partner already exists, both are paired and moved (back) to SUBMITTED so staff
* can accept them. Otherwise the booking is parked in PENDING_CONSOLIDATION and
* waits for a later complementary booking to complete the wagon.
*
* Returns the re-fetched booking, so callers can reflect the resulting status
* (SUBMITTED when paired/not-needed, PENDING_CONSOLIDATION when waiting).
*/
async runConsolidationOnSubmit(bookingId: string): Promise<Booking> {
const booking = await this.findById(bookingId);
// Already paired (e.g. a partner submitted first) — nothing to do.
if (booking.consolidationPartnerId) {
return booking;
}
const result = await this.tryAutoConsolidate(booking);
return result.booking;
}
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
files: Express.Multer.File[],
userId?: string,
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
// let customerId = dto.customerId;
// if (!customerId) {
// if (!userId) {
// throw new BadRequestException(
// 'customerId is required or must be resolvable from auth token',
// );
// }
// const customer = await this.customersService.findByUserId(userId);
// customerId = customer.id;
// }
const isGovernment = dto.isGovernment === true;
const isGeneralContract = dto.bookingType === 'GENERAL_CONTRACT';
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
if (!dto.governmentInstitution?.trim()) {
throw new BadRequestException('governmentInstitution is required for government bookings');
}
companyId = dto.companyId ?? null;
} else if (!companyId) {
if (!userId) {
throw new BadRequestException(
'companyId is required or must be resolvable from auth token',
);
}
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
companyId = company.id;
}
if (dto.trainScheduleId) {
// Staff manual pin: the schedule must be OPEN and on the same route.
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: dto.trainScheduleId } });
if (!schedule) {
throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
}
if (schedule.bookingWindowStatus !== 'OPEN') {
throw new BadRequestException('Selected schedule is no longer accepting bookings');
}
if (
schedule.originStationId !== dto.originYardId ||
schedule.destinationStationId !== dto.destinationYardId
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
} else if (!isGeneralContract) {
// Day-level pool: the customer picked a DAY — require that the route has at
// least one OPEN departure on that EAT day. The batch engine assigns the
// train later. General contracts skip this — they have no shipment date at
// creation; each drawdown order validates its own day.
const day = eatDay(new Date(dto.scheduledDate!));
const hasDeparture =
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
dto.originYardId,
dto.destinationYardId,
day,
);
if (!hasDeparture) {
throw new BadRequestException(
'No departures available on the selected day for this route',
);
}
}
const reference = dto.reference || (await this.generateReference());
const containers = dto.containers ?? [];
assertFreightShape({
freightType: dto.freightType,
cargoTypeId: dto.cargoTypeId,
containers,
});
const tradeDirection = await this.resolveTradeDirectionForBooking(
dto.originYardId,
dto.destinationYardId,
dto.tradeDirection,
);
// Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
let fallbackType: ProfileType | null = null;
if (userId) {
try {
const { profile } =
await this.companiesService.getCompanyInfoByUserId(userId);
fallbackType = profile.activeProfileType ?? null;
} catch {
// No profile (e.g. staff creating on behalf) — fall back to mapping.
}
}
companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
companyId,
tradeDirection,
fallbackType,
);
}
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
: false;
const evalInput = await this.buildEvalInput({
freightType: dto.freightType as FreightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous,
isGovernment,
allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const booking = await this.bookingsRepository.create({
reference,
companyId: companyId ?? null,
companyProfileId,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
firstMilePickupAddress: dto.firstMilePickupAddress,
lastMileDeliveryAddress: dto.lastMileDeliveryAddress,
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection,
freightType: dto.freightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
isHazardous: dto.isHazardous ?? false,
paymentCurrency: dto.paymentCurrency,
pnrCode: dto.pnrCode,
financialTerms: dto.financialTerms,
bookingType: isGeneralContract ? 'GENERAL_CONTRACT' : 'ONE_TIME',
scheduledDate: dto.scheduledDate ? new Date(dto.scheduledDate) : null,
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: 'DRAFT',
allowConsolidation,
priorityScore: ruleResult.priorityScore,
totalAmount: 0,
paymentStatus: 'PENDING',
});
if (dto.freightType === 'CONTAINER') {
await this.bookingsRepository.createContainers(
booking.id,
containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
if (files.length > 0) {
try {
await this.filesService.uploadMany(booking.id, 'bookings', files);
} catch {
warnings.push('File upload failed — booking was created without attached files.');
}
}
let full = await this.findById(booking.id);
if (allowConsolidation) {
const consolidation = await this.tryAutoConsolidate(full);
full = consolidation.booking;
warnings.push(...consolidation.messages);
}
return { booking: full, warnings };
}
/** Update a draft booking. */
async update(
id: string,
dto: UpdateBookingDto,
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const existing = await this.findById(id);
if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
throw new BadRequestException(
'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
);
}
const warnings: string[] = [];
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
let containers =
dto.containers ??
(existing.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
}));
let cargoTypeId =
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
if (freightType === 'BULK') {
containers = [];
if (dto.containers !== undefined) {
await this.bookingsRepository.deleteContainers(id);
}
} else {
cargoTypeId = null;
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
}
}
assertFreightShape({ freightType, cargoTypeId, containers });
const originYardId = dto.originYardId ?? existing.originYardId;
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
const tradeDirection = await this.resolveTradeDirectionForBooking(
originYardId,
destinationYardId,
dto.tradeDirection,
);
const allowConsolidation =
freightType === 'CONTAINER'
? await this.resolveConsolidation(
containers,
dto.allowConsolidation ?? existing.allowConsolidation,
)
: false;
const evalInput = await this.buildEvalInput({
freightType,
cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
existing,
dto,
freightType,
cargoTypeId,
allowConsolidation,
containers,
);
const updates: Record<string, unknown> = {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
// If the route (hence trade direction) changed, re-stamp the operational
// profile so an edited draft doesn't get stranded under the wrong profile.
if (
tradeDirection !== existing.tradeDirection &&
!existing.isGovernment &&
existing.companyId
) {
updates.companyProfileId =
await this.companiesService.resolveCompanyProfileIdForBooking(
existing.companyId,
tradeDirection,
existing.companyProfileId
? undefined
: (existing.companyProfile?.type as ProfileType | undefined),
);
}
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
delete updates.containers;
await this.bookingsRepository.update(id, updates);
if (freightType === 'CONTAINER' && dto.containers) {
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
}
if (pricingFieldsChanged) {
await this.bookingsRepository.invalidatePricingPreview(id);
}
if (files.length > 0) {
await this.filesService.uploadMany(id, 'bookings', files);
}
let booking = await this.findById(id);
if (allowConsolidation && !booking.consolidationPartnerId) {
const consolidation = await this.tryAutoConsolidate(booking);
booking = consolidation.booking;
warnings.push(...consolidation.messages);
}
return { booking, warnings };
}
/** Parse comma-separated scheduling status query values. */
private parseSchedulingStatusFilter(filter: FilterBookingDto): {
schedulingStatuses?: string[];
} {
const raw = filter.schedulingStatuses;
if (!raw) return {};
const schedulingStatuses = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return schedulingStatuses.length ? { schedulingStatuses } : {};
}
/** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterBookingDto): {
statuses?: string[];
status?: string;
} {
const allowed = new Set<string>(BOOKING_STATUSES);
const raw = filter.statuses;
const statusList = raw
? raw
.split(',')
.map((s) => s.trim())
.filter((s) => allowed.has(s))
: [];
if (statusList.length > 0) {
return { statuses: statusList };
}
if (filter.status && allowed.has(filter.status)) {
return { status: filter.status };
}
return {};
}
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
forceCompanyId?: string,
forceCompanyProfileId?: string,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
// A forced company scope (portal/customer) overrides any caller-provided
// companyId so a customer can only ever see their own company's bookings.
// When an active profile resolves, scope to it; otherwise fall back to the
// company so nothing breaks for not-yet-onboarded customers.
companyId: forceCompanyProfileId ? undefined : forceCompanyId ?? filter.companyId,
companyProfileId: forceCompanyProfileId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
private static readonly PAYABLE_STATUSES = [
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'AWAITING_PAYMENT',
];
/**
* List the current customer's bookings that are ready for payment:
* payable status AND not yet PAID. Company scope is derived from the
* authenticated user and cannot be widened by the caller.
*/
async findMyPayable(
userId: string,
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
// Scope to the active operational profile when one resolves; fall back to
// company-level so not-yet-onboarded customers still see their payables.
const companyProfileId =
await this.companiesService.resolveActiveCompanyProfileId(userId);
return this.bookingsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 20,
statuses: BookingsService.PAYABLE_STATUSES,
excludePaymentStatus: 'PAID',
companyId: companyProfileId ? undefined : company.id,
companyProfileId: companyProfileId ?? undefined,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/**
* Resolve the company a customer user belongs to, for scoping their own
* bookings. Returns null when no profile/company is linked yet.
*/
async resolveCustomerCompanyId(userId: string): Promise<string | null> {
try {
const { company } =
await this.companiesService.getCompanyInfoByUserId(userId);
return company?.id ?? null;
} catch {
return null;
}
}
/**
* Resolve the active company_profile id a customer's bookings should be
* scoped to (importer/exporter mode). Null when not onboarded — callers fall
* back to company-level scoping.
*/
async resolveActiveCompanyProfileId(userId: string): Promise<string | null> {
return this.companiesService.resolveActiveCompanyProfileId(userId);
}
/**
* Authorize a customer's access to a single booking. Staff are scoped at the
* controller (they pass `isStaff`); for a customer, the booking must belong
* to the company the authenticated user is linked to — otherwise it is hidden
* behind a NotFound so booking IDs can't be probed.
*/
async assertCustomerCanAccessBooking(
userId: string | undefined,
booking: Booking,
): Promise<void> {
if (!userId) {
throw new ForbiddenException('Authentication required');
}
const companyId = await this.resolveCustomerCompanyId(userId);
if (!companyId || booking.companyId !== companyId) {
// Don't reveal that the booking exists for another company.
throw new NotFoundException(`Booking ${booking.id} not found`);
}
}
/**
* Build the customer-facing shipment tracking payload for a booking from the
* train schedule it is assigned to and the live checkpoint log. The caller is
* responsible for authorizing access to the booking first.
*
* When the booking has not been assigned to a train yet, returns a valid
* "no schedule" payload so the UI can show a pre-dispatch state.
*/
async getBookingTracking(
bookingId: string,
): Promise<Freight.IBookingTracking> {
const booking = await this.findById(bookingId);
const empty: Freight.IBookingTracking = {
bookingId: booking.id,
bookingReference: booking.reference,
hasSchedule: false,
scheduleId: null,
trainNumber: null,
scheduleStatus: null,
direction: null,
origin: null,
destination: null,
stations: [],
checkpoints: [],
currentSequenceNo: -1,
actualDepartureAt: null,
actualArrivalAt: null,
scheduledDepartureAt: null,
scheduledArrivalAt: null,
};
if (!booking.trainScheduleId) {
return empty;
}
// Pull the live corridor + checkpoints for the assigned schedule. If the
// schedule was removed, fall back to the pre-dispatch state rather than 500.
let track: Awaited<
ReturnType<TrainSchedulingService['getScheduleCheckpoints']>
>;
try {
track = await this.trainSchedulingService.getScheduleCheckpoints(
booking.trainScheduleId,
);
} catch {
return empty;
}
return {
bookingId: booking.id,
bookingReference: booking.reference,
hasSchedule: true,
scheduleId: track.scheduleId,
trainNumber: track.trainNumber,
scheduleStatus: track.status as Freight.TrainScheduleStatus,
direction: track.direction,
origin: track.origin,
destination: track.destination,
stations: track.stations,
checkpoints: track.checkpoints as Freight.ITrackingCheckpoint[],
currentSequenceNo: track.currentSequenceNo,
actualDepartureAt: track.actualDepartureAt,
actualArrivalAt: track.actualArrivalAt,
scheduledDepartureAt: track.scheduledDepartureAt,
scheduledArrivalAt: track.scheduledArrivalAt,
};
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const listFilter = {
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
bookingType: filter.bookingType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
const [statusCounts, metrics] = await Promise.all([
this.bookingsRepository.getStatusCounts(),
this.bookingsRepository.getListSummaryMetrics({
...listFilter,
page,
pageSize,
needsActionStatuses: NEEDS_ACTION_STATUSES,
urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
}),
]);
return {
metrics,
tabs: mapStatusCountsToTabs(statusCounts),
};
}
/** Get a single booking by ID with files. */
async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) {
throw new NotFoundException(`Booking ${id} not found`);
}
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.minioService.getObjectNameFromUrl(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
}),
);
}
return booking;
}
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
if (!booking) {
throw new NotFoundException(`Booking with reference "${reference}" not found`);
}
return this.findById(booking.id);
}
/** Upload documents for a DRAFT booking. */
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);
}
async remove(id: string): Promise<void> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT bookings can be deleted');
}
await this.bookingsRepository.softDelete(id);
}
async findQueue(
queue: string,
filter: FilterBookingDto,
options?: { excludeBulk?: boolean },
): Promise<{ items: Booking[]; total: number }> {
const statusMap: Record<string, string | string[]> = {
intake: 'SUBMITTED',
approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
marketing: 'SIGNED_CUSTOMER',
finance: 'FULLY_EXECUTED',
};
const status = statusMap[queue];
if (!status) {
throw new BadRequestException(`Unknown queue: ${queue}`);
}
return this.bookingsRepository.findQueue({
status,
page: filter.page,
pageSize: filter.pageSize,
excludeBulk: options?.excludeBulk ?? queue === 'approval',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
async requestConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking | null;
paired: boolean;
message: string;
}> {
const booking = await this.findById(id);
if (!booking.allowConsolidation) {
throw new BadRequestException('Booking is not eligible for consolidation');
}
const needs = await this.consolidationService.needsConsolidationFromBooking(
booking,
);
if (!needs) {
throw new BadRequestException(
'Booking already fills whole wagon(s) for all container lines; consolidation is not required',
);
}
if (booking.consolidationPartnerId) {
throw new ConflictException('Booking is already paired for consolidation');
}
const result = await this.tryAutoConsolidate(booking);
const partner = result.booking.consolidationPartnerId
? await this.findById(result.booking.consolidationPartnerId)
: null;
return {
booking: result.booking,
partner,
paired: partner !== null,
message: result.messages[0] ?? '',
};
}
async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> {
const booking = await this.findById(id);
if (!booking.consolidationPartnerId) {
throw new BadRequestException('Booking has no consolidation partner');
}
const partnerId = booking.consolidationPartnerId;
await this.bookingsRepository.unpairConsolidation(id, partnerId);
return {
booking: await this.findById(id),
partner: await this.findById(partnerId),
};
}
async getConsolidationDetails(id: string): Promise<{
booking: Booking;
partner: Booking | null;
splitBilling: { bookingShare: number; partnerShare: number } | null;
wagonSlots: Awaited<ReturnType<ConsolidationService['slotsFromBooking']>>;
statusMessage: string;
}> {
const booking = await this.findById(id);
const wagonSlots = await this.consolidationService.slotsFromBooking(booking);
if (!booking.consolidationPartnerId) {
const statusMessage =
booking.status === 'PENDING_CONSOLIDATION'
? this.consolidationService.describePending(booking, wagonSlots)
: wagonSlots.length > 0
? 'Consolidation may be required; no partner paired yet.'
: 'No wagon consolidation needed.';
return {
booking,
partner: null,
splitBilling: null,
wagonSlots,
statusMessage,
};
}
const partner = await this.findById(booking.consolidationPartnerId);
return {
booking,
partner,
splitBilling: {
bookingShare: Number(booking.totalAmount),
partnerShare: Number(partner.totalAmount),
},
wagonSlots,
statusMessage: this.consolidationService.describePaired(
partner.reference,
wagonSlots,
),
};
}
private pricingRelevantFieldsChanged(
existing: Booking,
dto: UpdateBookingDto,
freightType: FreightType,
cargoTypeId: string | null | undefined,
allowConsolidation: boolean,
containers: CreateBookingContainerDto[],
): boolean {
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
return true;
}
if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) {
return true;
}
if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) {
return true;
}
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
return true;
}
if (
dto.allowConsolidation !== undefined &&
dto.allowConsolidation !== existing.allowConsolidation
) {
return true;
}
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
return true;
}
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
return true;
}
if (dto.containers !== undefined) {
const existingContainers = (existing.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
}));
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
return true;
}
}
if (
freightType !== existing.freightType ||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ||
allowConsolidation !== existing.allowConsolidation
) {
return true;
}
return false;
}
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}
await this.bookingsRepository.update(id, {
status: 'PAID',
paymentStatus: 'PAID',
schedulingStatus: SchedulingStatus.Eligible,
holdStartedAt: null,
holdExpiresAt: null,
});
await this.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
return this.findById(id);
}
}