mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
update the Consolidation and wagon filling on booking and Add GET /bookings/reference-data endpoint for booking form catalog (yards, containers, services, shipping lines, cargo types)
This commit is contained in:
@@ -42,7 +42,7 @@ export class BookingsController {
|
||||
summary: "Create a new freight booking",
|
||||
description:
|
||||
"Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
|
||||
"Auto-enables consolidation when containerType=20FT and odd quantity.",
|
||||
"Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.",
|
||||
})
|
||||
@ApiBody({
|
||||
description:
|
||||
@@ -150,8 +150,8 @@ export class BookingsController {
|
||||
@ApiOperation({
|
||||
summary: "Request freight consolidation",
|
||||
description:
|
||||
"Searches for a compatible 20FT partner (same origin, destination, tradeDirection). " +
|
||||
"If a partner is found, both bookings are paired. If not, the booking enters the consolidation queue.",
|
||||
"Searches for a partner whose container quantity complements yours to fill whole wagon(s) " +
|
||||
"(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.",
|
||||
})
|
||||
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.requestConsolidation(id);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
import { BookingsController } from './bookings.controller';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { BookingsService } from './bookings.service';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
|
||||
@@ -29,7 +30,7 @@ import { Booking } from './entities/booking.entity';
|
||||
RuleEngineModule,
|
||||
],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, BookingsRepository],
|
||||
providers: [BookingsService, BookingsRepository, ConsolidationService],
|
||||
exports: [BookingsService],
|
||||
})
|
||||
export class BookingsModule {}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, In, IsNull, Not, Repository } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
|
||||
@@ -128,20 +128,63 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return Number(result?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Find a compatible consolidation partner. */
|
||||
async findConsolidationPartner(booking: Booking): Promise<Booking | null> {
|
||||
return this.repository.findOne({
|
||||
where: {
|
||||
allowConsolidation: true,
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides).
|
||||
*/
|
||||
async findComplementaryConsolidationPartner(
|
||||
booking: Booking,
|
||||
slot: {
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
},
|
||||
): Promise<Booking | null> {
|
||||
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
|
||||
|
||||
return this.repository
|
||||
.createQueryBuilder('b')
|
||||
.innerJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.innerJoin('bc.containerType', 'ct')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.allowConsolidation = true')
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
})
|
||||
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||
destinationYardId: booking.destinationYardId,
|
||||
})
|
||||
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
consolidationPartnerId: IsNull(),
|
||||
status: In(['DRAFT', 'PENDING_CONSOLIDATION']),
|
||||
id: Not(booking.id),
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
})
|
||||
.andWhere('bc.containerTypeId = :containerTypeId', { containerTypeId })
|
||||
.andWhere('(bc.quantity % :perWagon) > 0', { perWagon })
|
||||
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
|
||||
quantity,
|
||||
perWagon,
|
||||
})
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.getOne();
|
||||
}
|
||||
|
||||
/** Try each partial-wagon line until a complementary partner booking is found. */
|
||||
async findConsolidationPartner(
|
||||
booking: Booking,
|
||||
slots: Array<{
|
||||
containerTypeId: string;
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
}>,
|
||||
): Promise<Booking | null> {
|
||||
for (const slot of slots) {
|
||||
const partner = await this.findComplementaryConsolidationPartner(booking, slot);
|
||||
if (partner) return partner;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Pair two bookings for consolidation. */
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
RuleEngineService,
|
||||
} from '../rule-engine/rule-engine.service';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import { ConsolidationService } from './consolidation.service';
|
||||
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
|
||||
import { FilterBookingDto } from './dto/filter-booking.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
@@ -31,6 +32,7 @@ export class BookingsService {
|
||||
private readonly customersService: CustomersService,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
private readonly consolidationService: ConsolidationService,
|
||||
) {}
|
||||
|
||||
/** Generate a unique booking reference number. */
|
||||
@@ -79,19 +81,66 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve auto-consolidation for odd-quantity 20ft 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;
|
||||
for (const c of containers) {
|
||||
const ct = await this.containerTypesService.findById(c.containerTypeId);
|
||||
if (ct.sizeFt === 20 && c.quantity % 2 !== 0) return true;
|
||||
}
|
||||
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,
|
||||
@@ -175,7 +224,14 @@ export class BookingsService {
|
||||
}
|
||||
}
|
||||
|
||||
const full = await this.findById(booking.id);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -246,7 +302,14 @@ export class BookingsService {
|
||||
await this.filesService.uploadMany(id, 'bookings', files);
|
||||
}
|
||||
|
||||
const booking = await this.findById(id);
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -528,6 +591,7 @@ export class BookingsService {
|
||||
booking: Booking;
|
||||
partner: Booking | null;
|
||||
paired: boolean;
|
||||
message: string;
|
||||
}> {
|
||||
const booking = await this.findById(id);
|
||||
|
||||
@@ -535,10 +599,12 @@ export class BookingsService {
|
||||
throw new BadRequestException('Booking is not eligible for consolidation');
|
||||
}
|
||||
|
||||
const hasOdd20Ft = await this.hasOdd20FtContainer(booking);
|
||||
if (!hasOdd20Ft) {
|
||||
const needs = await this.consolidationService.needsConsolidationFromBooking(
|
||||
booking,
|
||||
);
|
||||
if (!needs) {
|
||||
throw new BadRequestException(
|
||||
'Only bookings with odd-quantity 20ft containers need consolidation',
|
||||
'Booking already fills whole wagon(s) for all container lines; consolidation is not required',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -546,32 +612,17 @@ export class BookingsService {
|
||||
throw new ConflictException('Booking is already paired for consolidation');
|
||||
}
|
||||
|
||||
const partner = await this.bookingsRepository.findConsolidationPartner(booking);
|
||||
const result = await this.tryAutoConsolidate(booking);
|
||||
const partner = result.booking.consolidationPartnerId
|
||||
? await this.findById(result.booking.consolidationPartnerId)
|
||||
: null;
|
||||
|
||||
if (partner) {
|
||||
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
|
||||
return {
|
||||
booking: await this.findById(id),
|
||||
partner: await this.findById(partner.id),
|
||||
paired: true,
|
||||
};
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'PENDING_CONSOLIDATION',
|
||||
} as never);
|
||||
return { booking: await this.findById(id), partner: null, paired: false };
|
||||
}
|
||||
|
||||
private async hasOdd20FtContainer(booking: Booking): Promise<boolean> {
|
||||
const containers = booking.bookingContainers ?? [];
|
||||
for (const bc of containers) {
|
||||
const ct =
|
||||
bc.containerType ??
|
||||
(await this.containerTypesService.findById(bc.containerTypeId));
|
||||
if (ct.sizeFt === 20 && bc.quantity % 2 !== 0) return true;
|
||||
}
|
||||
return false;
|
||||
return {
|
||||
booking: result.booking,
|
||||
partner,
|
||||
paired: partner !== null,
|
||||
message: result.messages[0] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async removeConsolidation(id: string): Promise<{ booking: Booking; partner: Booking }> {
|
||||
@@ -593,11 +644,26 @@ export class BookingsService {
|
||||
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) {
|
||||
return { booking, partner: null, splitBilling: null };
|
||||
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);
|
||||
@@ -608,6 +674,11 @@ export class BookingsService {
|
||||
bookingShare: Number(booking.totalAmount),
|
||||
partnerShare: Number(partner.totalAmount),
|
||||
},
|
||||
wagonSlots,
|
||||
statusMessage: this.consolidationService.describePaired(
|
||||
partner.reference,
|
||||
wagonSlots,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
|
||||
export interface ConsolidationSlot {
|
||||
containerTypeId: string;
|
||||
containerTypeCode: string;
|
||||
quantity: number;
|
||||
containersPerWagon: number;
|
||||
remainder: number;
|
||||
slotsNeeded: number;
|
||||
}
|
||||
|
||||
export interface ConsolidationAttemptResult {
|
||||
booking: Booking;
|
||||
partner: Booking | null;
|
||||
paired: boolean;
|
||||
messages: string[];
|
||||
}
|
||||
|
||||
/** Containers that fit on one wagon for a given container type (inverse of wagons_per_unit). */
|
||||
export function containersPerWagon(wagonsPerUnit: number): number {
|
||||
const wpu = Number(wagonsPerUnit);
|
||||
if (!wpu || wpu <= 0) return 1;
|
||||
return Math.max(1, Math.round(1 / wpu));
|
||||
}
|
||||
|
||||
export function wagonRemainder(quantity: number, perWagon: number): number {
|
||||
const r = quantity % perWagon;
|
||||
return r;
|
||||
}
|
||||
|
||||
export function slotsNeededToFillWagon(quantity: number, perWagon: number): number {
|
||||
const remainder = wagonRemainder(quantity, perWagon);
|
||||
if (remainder === 0) return 0;
|
||||
return perWagon - remainder;
|
||||
}
|
||||
|
||||
/** Two bookings' quantities for the same type complete whole wagon(s). */
|
||||
export function quantitiesComplementWagon(
|
||||
q1: number,
|
||||
q2: number,
|
||||
perWagon: number,
|
||||
): boolean {
|
||||
return (
|
||||
wagonRemainder(q1, perWagon) > 0 &&
|
||||
wagonRemainder(q2, perWagon) > 0 &&
|
||||
(q1 + q2) % perWagon === 0
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ConsolidationService {
|
||||
constructor(private readonly containerTypesService: ContainerTypesService) {}
|
||||
|
||||
async slotsFromContainerLines(
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
): Promise<ConsolidationSlot[]> {
|
||||
const slots: ConsolidationSlot[] = [];
|
||||
for (const line of lines) {
|
||||
const ct = await this.containerTypesService.findById(line.containerTypeId);
|
||||
const perWagon = containersPerWagon(Number(ct.wagonsPerUnit));
|
||||
const remainder = wagonRemainder(line.quantity, perWagon);
|
||||
if (remainder === 0) continue;
|
||||
slots.push({
|
||||
containerTypeId: line.containerTypeId,
|
||||
containerTypeCode: ct.code,
|
||||
quantity: line.quantity,
|
||||
containersPerWagon: perWagon,
|
||||
remainder,
|
||||
slotsNeeded: perWagon - remainder,
|
||||
});
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
|
||||
const lines =
|
||||
booking.bookingContainers?.map((bc) => ({
|
||||
containerTypeId: bc.containerTypeId,
|
||||
quantity: bc.quantity,
|
||||
})) ?? [];
|
||||
return this.slotsFromContainerLines(lines);
|
||||
}
|
||||
|
||||
async needsConsolidation(
|
||||
lines: Array<{ containerTypeId: string; quantity: number }>,
|
||||
): Promise<boolean> {
|
||||
const slots = await this.slotsFromContainerLines(lines);
|
||||
return slots.length > 0;
|
||||
}
|
||||
|
||||
async needsConsolidationFromBooking(booking: Booking): Promise<boolean> {
|
||||
const slots = await this.slotsFromBooking(booking);
|
||||
return slots.length > 0;
|
||||
}
|
||||
|
||||
describePending(_booking: Booking, slots: ConsolidationSlot[]): string {
|
||||
if (slots.length === 0) {
|
||||
return 'Booking does not require wagon consolidation.';
|
||||
}
|
||||
const parts = slots.map(
|
||||
(s) =>
|
||||
`${s.slotsNeeded} more × ${s.containerTypeCode} (${s.containersPerWagon} per wagon; you have ${s.quantity})`,
|
||||
);
|
||||
return (
|
||||
`No compatible partner found yet. Your booking is queued (PENDING_CONSOLIDATION). ` +
|
||||
`Waiting for: ${parts.join('; ')}. You will be notified when another customer fills the wagon.`
|
||||
);
|
||||
}
|
||||
|
||||
describePaired(partnerReference: string, slots: ConsolidationSlot[]): string {
|
||||
const parts = slots.map(
|
||||
(s) =>
|
||||
`${s.containerTypeCode}: ${s.quantity} + partner fills wagon (${s.containersPerWagon} per wagon)`,
|
||||
);
|
||||
return (
|
||||
`Consolidation partner found (${partnerReference}). ` +
|
||||
`Shared wagon confirmed: ${parts.join('; ')}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user