mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- 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.
1104 lines
38 KiB
TypeScript
1104 lines
38 KiB
TypeScript
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);
|
||
}
|
||
}
|