mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
652 lines
21 KiB
TypeScript
652 lines
21 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
// import { CustomersService } from '../customers/customers.service';
|
||
import { CompaniesService } from '../companies/companies.service';
|
||
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 { 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(
|
||
private readonly bookingsRepository: BookingsRepository,
|
||
private readonly filesService: FilesService,
|
||
private readonly minioService: MinioService,
|
||
// private readonly customersService: CustomersService,
|
||
private readonly companiesService: CompaniesService,
|
||
private readonly ruleEngineService: RuleEngineService,
|
||
private readonly containerTypesService: ContainerTypesService,
|
||
private readonly consolidationService: ConsolidationService,
|
||
) {}
|
||
|
||
/** 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;
|
||
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,
|
||
};
|
||
}),
|
||
);
|
||
|
||
return {
|
||
freightType: dto.freightType,
|
||
cargoTypeId: dto.cargoTypeId ?? null,
|
||
serviceTypeId: dto.serviceTypeId,
|
||
paymentCurrency: dto.paymentCurrency,
|
||
tradeDirection: dto.tradeDirection,
|
||
isHazardous: dto.isHazardous ?? false,
|
||
allowConsolidation:
|
||
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
|
||
shippingLineId: dto.shippingLineId,
|
||
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), unless opted out.
|
||
*/
|
||
private async resolveConsolidation(
|
||
containers: CreateBookingContainerDto[],
|
||
explicit?: boolean,
|
||
): Promise<boolean> {
|
||
if (explicit === false) return false;
|
||
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 };
|
||
}
|
||
|
||
if (booking.status === 'DRAFT') {
|
||
await this.bookingsRepository.update(booking.id, {
|
||
status: 'PENDING_CONSOLIDATION',
|
||
} as never);
|
||
}
|
||
|
||
const pending = await this.findById(booking.id);
|
||
messages.push(this.consolidationService.describePending(pending, slots));
|
||
return { booking: pending, messages };
|
||
}
|
||
|
||
/** 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;
|
||
// }
|
||
|
||
let companyId = dto.companyId;
|
||
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;
|
||
}
|
||
|
||
const reference = dto.reference || (await this.generateReference());
|
||
const containers = dto.containers ?? [];
|
||
assertFreightShape({
|
||
freightType: dto.freightType,
|
||
cargoTypeId: dto.cargoTypeId,
|
||
containers,
|
||
});
|
||
|
||
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: dto.tradeDirection,
|
||
isHazardous: dto.isHazardous,
|
||
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,
|
||
trainId: dto.trainId,
|
||
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: dto.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,
|
||
scheduledDate: new Date(dto.scheduledDate),
|
||
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?.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 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: dto.tradeDirection ?? existing.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 updates: Record<string, unknown> = {
|
||
...dto,
|
||
freightType,
|
||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||
allowConsolidation,
|
||
priorityScore: ruleResult.priorityScore,
|
||
};
|
||
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 (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 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,
|
||
): Promise<{ items: Booking[]; total: number }> {
|
||
const page = filter.page ?? 1;
|
||
const pageSize = filter.pageSize ?? 20;
|
||
const statusFilter = this.parseStatusFilter(filter);
|
||
|
||
return this.bookingsRepository.findAllPaginated({
|
||
page,
|
||
pageSize,
|
||
...statusFilter,
|
||
companyId: filter.companyId,
|
||
contractType: filter.contractType,
|
||
serviceTypeId: filter.serviceTypeId,
|
||
cargoTypeId: filter.cargoTypeId,
|
||
freightType: filter.freightType,
|
||
tradeDirection: filter.tradeDirection,
|
||
paymentCurrency: filter.paymentCurrency,
|
||
allowConsolidation: filter.allowConsolidation,
|
||
consolidationPaired: filter.consolidationPaired,
|
||
sortBy: filter.sortBy,
|
||
sortOrder: filter.sortOrder,
|
||
});
|
||
}
|
||
|
||
/** 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,
|
||
tradeDirection: filter.tradeDirection,
|
||
paymentCurrency: filter.paymentCurrency,
|
||
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,
|
||
),
|
||
};
|
||
}
|
||
}
|