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:
marshal
2026-05-30 12:45:52 +03:00
parent badeeff345
commit 6b6a1d56d6
5 changed files with 290 additions and 52 deletions

View File

@@ -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);

View File

@@ -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 {}

View File

@@ -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. */

View File

@@ -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,
),
};
}
}

View File

@@ -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('; ')}.`
);
}
}