mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 12:25:02 +00:00
feat: add BookingWagonsPanel component for displaying allocated wagons in booking details
- Implemented BookingWagonsPanel to show allocated wagons, their containers, and export functionality. - Integrated the new panel into BookingRequestDetailPage and BookingRequestsPage. - Enhanced wagon cancellation modal to support rebooking of wagon cancellations with partner units. - Updated API service to include a method for downloading wagons workbook. - Modified types and constants to accommodate new features related to wagons. - Adjusted various components and pages to ensure compatibility with the new wagon-related functionality.
This commit is contained in:
@@ -124,6 +124,8 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
};
|
||||
|
||||
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
|
||||
// An odd credit always leaves a half-empty wagon, so GL must name who fills
|
||||
// it — the rebook is refused rather than shipping a half-empty wagon.
|
||||
await expect(
|
||||
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.toThrow(/pick a consolidation partner/i);
|
||||
@@ -143,6 +145,74 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
}),
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
|
||||
/**
|
||||
* An EXPIRED partner has no pay window left, so pairing the PAID rebook
|
||||
* straight onto it strands the shared wagon: neither half can board and
|
||||
* nothing ever breaks the pair (BK-2026-001114). Its cargo must move to a
|
||||
* fresh booking that carries its own invoice.
|
||||
*/
|
||||
it('clones an EXPIRED partner into a new booking instead of pairing the dead one', async () => {
|
||||
const dead = {
|
||||
id: 'p1',
|
||||
reference: 'BK-2026-001114',
|
||||
status: 'EXPIRED',
|
||||
contractId: 'c1',
|
||||
consolidationPartnerId: null,
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
scheduledDate: '2026-09-01',
|
||||
bookingContainers: [
|
||||
{
|
||||
containerSize: '20ft',
|
||||
quantity: 1,
|
||||
hazardousQuantity: 0,
|
||||
reeferQuantity: 0,
|
||||
containerType: { sizeFt: 20 },
|
||||
units: [
|
||||
{
|
||||
containerNumber: 'PCONT0',
|
||||
sealNumber: null,
|
||||
vgmTons: 9,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const clone = { ...dead, id: 'p1-clone', reference: 'BK-2026-001116', status: 'SUBMITTED' };
|
||||
|
||||
const svc = makeSvc(dead) as Record<string, unknown>;
|
||||
let createdUnderContract: string | null = null;
|
||||
let pairedWith: string | null = null;
|
||||
(svc as { bookingsRepository: Record<string, unknown> }).bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async (id: string) => (id === 'p1-clone' ? clone : dead),
|
||||
hasSpentCancellationCredit: async () => false,
|
||||
};
|
||||
(svc as { contractBooking: unknown }).contractBooking = {
|
||||
createUnderContract: async (contractId: string) => {
|
||||
createdUnderContract = contractId;
|
||||
return { booking: { id: 'p1-clone' } };
|
||||
},
|
||||
};
|
||||
(svc as { notifyCustomer: unknown }).notifyCustomer = () => undefined;
|
||||
|
||||
const cloned = await (
|
||||
svc as unknown as {
|
||||
cloneDeadPartner(p: unknown, d: string): Promise<{ id: string; reference: string }>;
|
||||
}
|
||||
).cloneDeadPartner(dead, '2026-09-01');
|
||||
|
||||
// The dead booking is left dead; the clone is what gets paired and paid.
|
||||
expect(cloned.id).toBe('p1-clone');
|
||||
expect(cloned.reference).toBe('BK-2026-001116');
|
||||
expect(createdUnderContract).toBe('c1');
|
||||
expect(pairedWith).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -1035,6 +1035,9 @@ export class BookingWagonCancellationService {
|
||||
let partner: Booking | null = null;
|
||||
if (oddFt20) {
|
||||
createDto.skipAutoConsolidation = true;
|
||||
// An odd credit always leaves a half-empty wagon, so GL names who fills
|
||||
// it. The candidate list is wide enough (any unpaired, unspent booking on
|
||||
// the day) that a partner is expected to exist.
|
||||
if (!dto.partnerBookingId) {
|
||||
throw new BadRequestException(
|
||||
'This credit carries an odd 20ft container — pick a consolidation partner booking to share its wagon (see the rebook-partners list).',
|
||||
@@ -1045,6 +1048,15 @@ export class BookingWagonCancellationService {
|
||||
dto.partnerBookingId,
|
||||
dto.scheduledDate,
|
||||
);
|
||||
// A dead partner cannot be paid where it stands — its cargo moves to a
|
||||
// fresh booking that can carry its own invoice and pay window.
|
||||
if (['EXPIRED', 'CANCELLED'].includes(partner.status)) {
|
||||
partner = await this.cloneDeadPartner(
|
||||
partner,
|
||||
dto.scheduledDate,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
source.contractId,
|
||||
@@ -1079,6 +1091,15 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
if (partner) {
|
||||
// Corrections GL made to the partner's own containers while pairing —
|
||||
// scoped to that booking by the repository, so a stray id cannot touch
|
||||
// another booking's cargo.
|
||||
if (dto.partnerUnits?.length) {
|
||||
await this.bookingsRepository.patchContainerUnitsForBooking(
|
||||
partner.id,
|
||||
dto.partnerUnits,
|
||||
);
|
||||
}
|
||||
// Consolidated rebook: never allocate the half-wagon booking alone. It
|
||||
// rides PAID and the batch engine settles the pair atomically once the
|
||||
// partner's own invoice is paid.
|
||||
@@ -1130,6 +1151,13 @@ export class BookingWagonCancellationService {
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
units: Array<{
|
||||
id: string;
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber: string | null;
|
||||
vgmTons: number;
|
||||
}>;
|
||||
}>
|
||||
> {
|
||||
const row = await this.mustFind(cancellationId);
|
||||
@@ -1150,6 +1178,18 @@ export class BookingWagonCancellationService {
|
||||
ft20Quantity: (b.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||
// Editable while pairing — GL corrects these on the rebook form.
|
||||
units: (b.bookingContainers ?? []).flatMap((line) =>
|
||||
(line.units ?? []).map((u) => ({
|
||||
id: u.id,
|
||||
containerSize: line.containerType?.sizeFt
|
||||
? `${line.containerType.sizeFt}ft`
|
||||
: '',
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? null,
|
||||
vgmTons: Number(u.vgmTons ?? 0),
|
||||
})),
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1168,11 +1208,29 @@ export class BookingWagonCancellationService {
|
||||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||
);
|
||||
}
|
||||
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
|
||||
// Mirrors findRebookConsolidationCandidates: a partner need not be a live
|
||||
// committed shipment. One that lost its slot or was called off still has
|
||||
// cargo to move, and the rebooked wagon is how it moves.
|
||||
if (
|
||||
![
|
||||
'SUBMITTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
].includes(partner.status)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
|
||||
);
|
||||
}
|
||||
// A booking whose own credit was already rebooked elsewhere is spent.
|
||||
if (await this.bookingsRepository.hasSpentCancellationCredit(partner.id)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has already been rebooked from its cancellation credit.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
partner.originYardId !== source.originYardId ||
|
||||
partner.destinationYardId !== source.destinationYardId ||
|
||||
@@ -1200,6 +1258,74 @@ export class BookingWagonCancellationService {
|
||||
return partner;
|
||||
}
|
||||
|
||||
/**
|
||||
* A dead (EXPIRED/CANCELLED) partner still has cargo to move, but it can no
|
||||
* longer be paid: its pay window is gone and finalizing it issues nothing a
|
||||
* customer can settle, so pairing the PAID rebook with it strands the shared
|
||||
* wagon forever (BK-2026-001114: EXPIRED/PENDING, paired to a PAID rebook,
|
||||
* no payment_deadline — neither half could ever board). So the cargo is
|
||||
* cloned into a fresh booking under the same contract, which finalizes
|
||||
* normally into its own invoice and pay window; the dead booking stays dead.
|
||||
*/
|
||||
private async cloneDeadPartner(
|
||||
partner: Booking,
|
||||
scheduledDate: string,
|
||||
userId?: string,
|
||||
): Promise<Booking> {
|
||||
if (!partner.contractId) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has no contract to rebook its cargo under — pick a live partner instead.`,
|
||||
);
|
||||
}
|
||||
const dto: CreateBookingUnderContractDto = {
|
||||
scheduledDate,
|
||||
paymentCurrency: partner.paymentCurrency ?? undefined,
|
||||
// GL already chose this pairing — the auto-matcher must not re-home the
|
||||
// clone behind their back (same reasoning as the rebooked side).
|
||||
skipAutoConsolidation: true,
|
||||
containers: (partner.bookingContainers ?? []).map((line) => {
|
||||
const units = line.units ?? [];
|
||||
return {
|
||||
containerSize: line.containerSize ?? undefined,
|
||||
quantity: Number(line.quantity),
|
||||
units: units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? '',
|
||||
vgmTons: u.vgmTons,
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
})),
|
||||
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(line.reeferQuantity ?? 0),
|
||||
};
|
||||
}) as CreateBookingUnderContractDto['containers'],
|
||||
};
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
partner.contractId,
|
||||
dto,
|
||||
{ id: userId ?? partner.createdByUserId ?? undefined },
|
||||
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
||||
// The dead partner's own contract may have lapsed while it sat expired;
|
||||
// its cargo is still the cargo GL picked to fill the shared wagon.
|
||||
{ allowExpiredContract: true },
|
||||
);
|
||||
const clone = await this.bookingsRepository.findByIdWithFiles(
|
||||
created.booking.id,
|
||||
);
|
||||
if (!clone) {
|
||||
throw new NotFoundException(
|
||||
`Replacement booking for ${partner.reference} could not be loaded.`,
|
||||
);
|
||||
}
|
||||
this.notifyCustomer(
|
||||
partner,
|
||||
'Replacement booking created',
|
||||
`${partner.reference} had expired, so its cargo moved to ${clone.reference} to share a wagon with a rebooked shipment. Pay ${clone.reference} to board.`,
|
||||
clone.id,
|
||||
);
|
||||
return clone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the rebooked (already PAID) booking with the GL-picked partner. A
|
||||
* parked partner is resumed the way pairConsolidation would resume it —
|
||||
|
||||
@@ -599,6 +599,34 @@ export class BookingsController {
|
||||
return this.bookingsService.wagonAllocations(id);
|
||||
}
|
||||
|
||||
@Get(":id/wagons/export")
|
||||
@MixedAudience(FREIGHT_PERMS.bookings.view)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Download the booking's allocated wagons as an Excel workbook (customer name + one row per wagon)",
|
||||
})
|
||||
async wagonAllocationsExport(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(
|
||||
user?.id,
|
||||
booking,
|
||||
);
|
||||
}
|
||||
const { filename, buffer } =
|
||||
await this.bookingsService.wagonAllocationsWorkbook(id);
|
||||
res.setHeader(
|
||||
"Content-Type",
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
|
||||
// Customer endpoints are ownership-scoped (no portal permission keys); the
|
||||
// staff history/void/rebook variants are permission-gated below.
|
||||
|
||||
@@ -6,6 +6,7 @@ import { registerExchangeModule } from "../exchange-settings/exchange-module-opt
|
||||
|
||||
// import { CustomersModule } from '../customers/customers.module';
|
||||
import { CompaniesModule } from '../companies/companies.module';
|
||||
import { ExportsModule } from '../exports/exports.module';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { MinioModule } from '../minio/minio.module';
|
||||
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
|
||||
@@ -105,6 +106,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
// CustomersModule,
|
||||
RuleEngineModule,
|
||||
FileUploadSettingsModule,
|
||||
ExportsModule,
|
||||
SignaturesModule,
|
||||
registerExchangeModule(),
|
||||
],
|
||||
|
||||
@@ -34,10 +34,12 @@ import {
|
||||
DocumentReviewStatus,
|
||||
} from './entities/booking-document-review.entity';
|
||||
import { BookingContainer } from './entities/booking-container.entity';
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
|
||||
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import {
|
||||
BookingContractSignature,
|
||||
@@ -332,21 +334,22 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
* Bookings a GL operator may manually link to `booking` as its odd-20ft
|
||||
* consolidation partner (Path B customs flow). Unlike
|
||||
* {@link findComplementaryConsolidationPartner} — which auto-pairs on an exact
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, so
|
||||
* the filter is deliberately looser: any other customs booking on the same
|
||||
* route/direction that is itself carrying an odd 20ft count. Two odd counts
|
||||
* always sum to even, so any pick fills the shared wagon.
|
||||
* quantity complement — this lists CANDIDATES for a human to choose from, but
|
||||
* every row must still be a legal pick: another customs booking on the same
|
||||
* route/direction, riding the same booking day, that is itself carrying an odd
|
||||
* 20ft count. Two odd counts always sum to even, so any pick fills the shared
|
||||
* wagon.
|
||||
*
|
||||
* Bare instances awaiting completion have no persisted containers yet, so the
|
||||
* odd-count test runs on the requested container lines when they exist and the
|
||||
* booking is offered as a candidate when they do not (GL enters its cargo on
|
||||
* the split form).
|
||||
* A booking whose cargo is not entered yet is NOT a candidate: with no
|
||||
* container lines its 20ft count is unknown, so pairing with it cannot be
|
||||
* shown to fill the wagon. Same rule as
|
||||
* {@link findRebookConsolidationCandidates}.
|
||||
*/
|
||||
async findManualConsolidationCandidates(
|
||||
booking: Booking,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
const rows = await this.repository
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
@@ -376,17 +379,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
],
|
||||
})
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
});
|
||||
|
||||
// Odd-20ft test in memory: a bare instance has no containers yet (GL fills
|
||||
// them on the split form) and stays a candidate; one that already carries
|
||||
// cargo qualifies only when its 20ft total is odd.
|
||||
// Same EAT booking day — the pair shares one physical wagon, so it must
|
||||
// board one train. Applied only when this booking has a date of its own;
|
||||
// without one there is no day to match against and route/direction stand
|
||||
// alone, mirroring findComplementaryConsolidationPartner.
|
||||
if (booking.scheduledDate) {
|
||||
qb.andWhere(
|
||||
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||
{ bookingDate: booking.scheduledDate },
|
||||
);
|
||||
}
|
||||
|
||||
const rows = await qb.orderBy('b.createdAt', 'ASC').take(limit).getMany();
|
||||
|
||||
// Odd-20ft test in memory. A booking with no container lines has an unknown
|
||||
// 20ft count, so it cannot be shown to complete the wagon and is not
|
||||
// offered.
|
||||
return rows.filter((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
@@ -396,10 +409,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
|
||||
/**
|
||||
* Candidate partners for rebooking an odd-20ft cancellation credit: unpaired
|
||||
* odd-20ft bookings on the same route/direction riding the requested day —
|
||||
* SUBMITTED (committed direct booking) or parked PENDING_CONSOLIDATION.
|
||||
* odd-20ft bookings on the same route/direction riding the requested day.
|
||||
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
|
||||
* GL picks who shares the rebooked wagon whatever the contract kind.
|
||||
*
|
||||
* The status set is deliberately wide. A partner here is not required to be a
|
||||
* live, committed shipment — a booking that lost its slot (EXPIRED) or was
|
||||
* cancelled still has cargo that GL can put back on a train, and pairing it
|
||||
* with the rebooked credit is how both halves get moving again. What it must
|
||||
* not be is already spoken for: a booking whose own cancellation credit has
|
||||
* been rebooked elsewhere is excluded, as is one already paired.
|
||||
*/
|
||||
async findRebookConsolidationCandidates(
|
||||
booking: Booking,
|
||||
@@ -410,6 +429,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
// Units come back so GL can correct the partner's container numbers,
|
||||
// seals and VGMs while pairing.
|
||||
.leftJoinAndSelect('bc.units', 'unit')
|
||||
.leftJoinAndSelect('b.company', 'company')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
@@ -423,8 +445,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
statuses: [
|
||||
'SUBMITTED',
|
||||
'PENDING_CONSOLIDATION',
|
||||
'CLEARANCE_READY',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
// Lost its slot or was called off — its cargo is still real and can
|
||||
// ride the rebooked wagon.
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
],
|
||||
})
|
||||
// A cancelled booking whose own credit was already spent on a rebook is
|
||||
// gone — pairing with it would hand the same cargo out twice.
|
||||
.andWhere(
|
||||
`NOT EXISTS (
|
||||
SELECT 1 FROM freight.booking_wagon_cancellations c
|
||||
WHERE c.booking_id = b.id
|
||||
AND c.rebooked_booking_id IS NOT NULL
|
||||
AND c.deleted_at IS NULL
|
||||
)`,
|
||||
)
|
||||
// Same EAT booking day as the rebook — the pair shares one physical
|
||||
// wagon, so it must board one train.
|
||||
.andWhere(
|
||||
@@ -729,6 +770,89 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return new Set(rows.map((r) => r.bookingId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Bookings among `bookingIds` that hold a redeemable wagon-cancellation
|
||||
* credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth
|
||||
* something, and it has not been spent on a rebook yet. Surfaced on the GL
|
||||
* clearance queue so a paid-for credit is visibly rebookable from the list
|
||||
* rather than only from the booking's own page.
|
||||
*/
|
||||
async findBookingsWithRedeemableCredit(
|
||||
bookingIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
if (bookingIds.length === 0) return new Map();
|
||||
const rows = (await this.dataSource
|
||||
.getRepository(BookingWagonCancellation)
|
||||
.createQueryBuilder('c')
|
||||
.select('c.booking_id', 'bookingId')
|
||||
.addSelect('c.id', 'cancellationId')
|
||||
.where('c.booking_id IN (:...bookingIds)', { bookingIds })
|
||||
.andWhere('c.status = :status', { status: 'CREDIT_AVAILABLE' })
|
||||
.andWhere('c.credit_amount > 0')
|
||||
.andWhere('c.rebooked_booking_id IS NULL')
|
||||
.andWhere('c.deleted_at IS NULL')
|
||||
.getRawMany()) as Array<{ bookingId: string; cancellationId: string }>;
|
||||
return new Map(rows.map((r) => [r.bookingId, r.cancellationId]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply container-unit corrections (number / seal / VGM) to units that belong
|
||||
* to `bookingId`. The ownership join is the point: a unit id from another
|
||||
* booking silently matches nothing rather than editing a stranger's cargo.
|
||||
* Sizes and quantities are never touched — only the identifying details.
|
||||
* Returns how many units were actually updated.
|
||||
*/
|
||||
async patchContainerUnitsForBooking(
|
||||
bookingId: string,
|
||||
patches: Array<{
|
||||
id: string;
|
||||
containerNumber?: string;
|
||||
sealNumber?: string;
|
||||
vgmTons?: number;
|
||||
}>,
|
||||
): Promise<number> {
|
||||
if (patches.length === 0) return 0;
|
||||
const unitRepo = this.dataSource.getRepository(BookingContainerUnit);
|
||||
const owned = await unitRepo
|
||||
.createQueryBuilder('u')
|
||||
.innerJoin('u.bookingContainer', 'bc')
|
||||
.where('bc.booking_id = :bookingId', { bookingId })
|
||||
.andWhere('u.id IN (:...ids)', { ids: patches.map((p) => p.id) })
|
||||
.select('u.id', 'id')
|
||||
.getRawMany<{ id: string }>();
|
||||
const ownedIds = new Set(owned.map((r) => r.id));
|
||||
|
||||
let updated = 0;
|
||||
for (const patch of patches) {
|
||||
if (!ownedIds.has(patch.id)) continue;
|
||||
const set: Record<string, unknown> = {};
|
||||
if (patch.containerNumber !== undefined)
|
||||
set.containerNumber = patch.containerNumber;
|
||||
if (patch.sealNumber !== undefined) set.sealNumber = patch.sealNumber;
|
||||
if (patch.vgmTons !== undefined) set.vgmTons = patch.vgmTons;
|
||||
if (Object.keys(set).length === 0) continue;
|
||||
await unitRepo.update(patch.id, set as never);
|
||||
updated += 1;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Has this booking's own wagon-cancellation credit already been spent on a
|
||||
* rebook? Such a booking must not be offered or accepted as a consolidation
|
||||
* partner — its cargo has already moved to the rebooked booking.
|
||||
*/
|
||||
async hasSpentCancellationCredit(bookingId: string): Promise<boolean> {
|
||||
const count = await this.dataSource
|
||||
.getRepository(BookingWagonCancellation)
|
||||
.createQueryBuilder('c')
|
||||
.where('c.booking_id = :bookingId', { bookingId })
|
||||
.andWhere('c.rebooked_booking_id IS NOT NULL')
|
||||
.andWhere('c.deleted_at IS NULL')
|
||||
.getCount();
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
findDocumentReview(
|
||||
bookingId: string,
|
||||
settingCode: string,
|
||||
@@ -1043,9 +1167,38 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
select: { bookingId: true, trainScheduleId: true },
|
||||
});
|
||||
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
|
||||
|
||||
// The allocated train's own departure date — distinct from the customer's
|
||||
// requested `booking.scheduledDate`. The list column shows this once a
|
||||
// booking is on a train, so fetch it alongside the link ids.
|
||||
const scheduleIds = [...new Set([...scheduleByBooking.values()].filter(Boolean))] as string[];
|
||||
const schedules = scheduleIds.length
|
||||
? await this.dataSource.getRepository(TrainSchedule).find({
|
||||
where: { id: In(scheduleIds) },
|
||||
select: {
|
||||
id: true,
|
||||
reference: true,
|
||||
trainNumber: true,
|
||||
status: true,
|
||||
scheduledDepartureDate: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const scheduleById = new Map(schedules.map((schedule) => [schedule.id, schedule]));
|
||||
|
||||
for (const item of items) {
|
||||
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
|
||||
scheduleByBooking.get(item.id) ?? null;
|
||||
const scheduleId = scheduleByBooking.get(item.id) ?? null;
|
||||
const enriched = item as Booking & {
|
||||
trainScheduleId?: string | null;
|
||||
trainScheduleReference?: string | null;
|
||||
trainScheduleDepartureDate?: string | null;
|
||||
};
|
||||
enriched.trainScheduleId = scheduleId;
|
||||
const schedule = scheduleId ? scheduleById.get(scheduleId) : undefined;
|
||||
enriched.trainScheduleReference = schedule?.reference ?? schedule?.trainNumber ?? null;
|
||||
enriched.trainScheduleDepartureDate = schedule?.scheduledDepartureDate
|
||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
import { insertWithGeneratedReference, logCtx } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { TabularExportService } from '../exports/tabular-export.service';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
@@ -104,6 +105,38 @@ export interface PaginatedBookings {
|
||||
};
|
||||
}
|
||||
|
||||
/** One container on an allocated wagon (raw SQL json_agg projection). */
|
||||
export interface WagonAllocationContainer {
|
||||
containerNumber: string | null;
|
||||
sealNumber: string | null;
|
||||
positionOnWagon: number | null;
|
||||
grossWeightTons: number | null;
|
||||
sizeFt: number | null;
|
||||
}
|
||||
|
||||
/** One allocated wagon as returned by `wagonAllocations` (raw SQL projection). */
|
||||
export interface WagonAllocationRow {
|
||||
allocationId: string;
|
||||
sequenceNo: number | null;
|
||||
wagonNumber: string | null;
|
||||
wagonType: string | null;
|
||||
wagonTypeCode: string | null;
|
||||
/** numeric columns arrive as strings from pg. */
|
||||
tareWeightTons: string | null;
|
||||
capacityTons: string | null;
|
||||
lengthMeters: string | null;
|
||||
allocatedWeightTons: string | null;
|
||||
loadType: string | null;
|
||||
status: string | null;
|
||||
trainNumber: string | null;
|
||||
departureAt: string | Date | null;
|
||||
originStation: string | null;
|
||||
destinationStation: string | null;
|
||||
bulkCargoDescription: string | null;
|
||||
bulkQuantity: string | null;
|
||||
containers: WagonAllocationContainer[];
|
||||
}
|
||||
|
||||
/** One wagon line on the carriage acceptance sheet (raw SQL projection). */
|
||||
interface CarriageAcceptanceWagonRow {
|
||||
sequenceNo: number;
|
||||
@@ -172,6 +205,7 @@ export class BookingsService {
|
||||
private readonly bookingContractService: BookingContractService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly tabularExport: TabularExportService,
|
||||
) {}
|
||||
|
||||
async assignCustomerTruck(
|
||||
@@ -447,7 +481,7 @@ export class BookingsService {
|
||||
* an array per wagon, bulk load description when the wagon carries bulk).
|
||||
* Empty array until the booking has been allocated onto a train.
|
||||
*/
|
||||
async wagonAllocations(bookingId: string): Promise<unknown[]> {
|
||||
async wagonAllocations(bookingId: string): Promise<WagonAllocationRow[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
@@ -501,6 +535,103 @@ export class BookingsService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Wagons tab's Excel export: the booking's customer identity in the KPI
|
||||
* header, then one row per allocated wagon.
|
||||
*
|
||||
* Container numbers are flattened into a single cell rather than exploded
|
||||
* into one row per container — the sheet is a wagon manifest, and a reader
|
||||
* counting rows must get the wagon count.
|
||||
*/
|
||||
async wagonAllocationsWorkbook(
|
||||
bookingId: string,
|
||||
): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const booking = await this.findById(bookingId);
|
||||
const wagons = await this.wagonAllocations(bookingId);
|
||||
|
||||
// Same precedence the booking list uses: a shipping line owns its bookings
|
||||
// directly, a government booking names its institution, everyone else is
|
||||
// the customer company.
|
||||
// `shippingLineCompany` is attached by `findById` (attachShippingLineCompanies),
|
||||
// not a declared relation on the entity — hence the cast, matching that helper.
|
||||
const shippingLine = (booking as Booking & { shippingLineCompany?: { name?: string } })
|
||||
.shippingLineCompany;
|
||||
const customerName =
|
||||
shippingLine?.name ??
|
||||
(booking.isGovernment ? booking.governmentInstitution : null) ??
|
||||
booking.company?.name ??
|
||||
'—';
|
||||
|
||||
const rows = wagons.map((w) => ({
|
||||
sequenceNo: w.sequenceNo,
|
||||
wagonNumber: w.wagonNumber ?? '—',
|
||||
wagonType: w.wagonType ?? '—',
|
||||
loadType: w.loadType ?? '—',
|
||||
status: w.status ?? '—',
|
||||
tareWeightTons: w.tareWeightTons === null ? null : Number(w.tareWeightTons),
|
||||
capacityTons: w.capacityTons === null ? null : Number(w.capacityTons),
|
||||
allocatedWeightTons:
|
||||
w.allocatedWeightTons === null ? null : Number(w.allocatedWeightTons),
|
||||
lengthMeters: w.lengthMeters === null ? null : Number(w.lengthMeters),
|
||||
containerCount: w.containers?.length ?? 0,
|
||||
containerNumbers:
|
||||
(w.containers ?? []).map((c) => c.containerNumber).filter(Boolean).join(', ') || '—',
|
||||
sealNumbers:
|
||||
(w.containers ?? []).map((c) => c.sealNumber).filter(Boolean).join(', ') || '—',
|
||||
bulkCargo: w.bulkCargoDescription ?? '—',
|
||||
bulkQuantity: w.bulkQuantity === null ? null : Number(w.bulkQuantity),
|
||||
trainNumber: w.trainNumber ?? '—',
|
||||
departureAt: w.departureAt ? new Date(w.departureAt).toISOString().slice(0, 10) : '—',
|
||||
originStation: w.originStation ?? '—',
|
||||
destinationStation: w.destinationStation ?? '—',
|
||||
// Repeated on every row so the sheet survives being filtered, sorted or
|
||||
// pasted into a combined workbook, where the header block is lost.
|
||||
customerName,
|
||||
bookingReference: booking.reference,
|
||||
}));
|
||||
|
||||
const totalAllocated = rows.reduce(
|
||||
(sum, r) => sum + (r.allocatedWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const buffer = await this.tabularExport.toXlsx({
|
||||
title: `Wagons ${booking.reference}`.slice(0, 31),
|
||||
description: `Wagons allocated to booking ${booking.reference} — ${customerName}`,
|
||||
label: 'booking:wagon-allocations',
|
||||
kpis: [
|
||||
{ label: 'Wagons', value: rows.length },
|
||||
{ label: 'Containers', value: rows.reduce((sum, r) => sum + r.containerCount, 0) },
|
||||
{ label: 'Allocated weight', value: Number(totalAllocated.toFixed(3)), unit: 't' },
|
||||
],
|
||||
columns: [
|
||||
{ key: 'bookingReference', label: 'Booking', type: 'string' },
|
||||
{ key: 'customerName', label: 'Customer', type: 'string' },
|
||||
{ key: 'sequenceNo', label: 'Seq', type: 'number' },
|
||||
{ key: 'wagonNumber', label: 'Wagon number', type: 'string' },
|
||||
{ key: 'wagonType', label: 'Wagon type', type: 'string' },
|
||||
{ key: 'loadType', label: 'Load type', type: 'string' },
|
||||
{ key: 'status', label: 'Status', type: 'string' },
|
||||
{ key: 'tareWeightTons', label: 'Tare', type: 'tons' },
|
||||
{ key: 'capacityTons', label: 'Capacity', type: 'tons' },
|
||||
{ key: 'allocatedWeightTons', label: 'Allocated', type: 'tons' },
|
||||
{ key: 'lengthMeters', label: 'Length (m)', type: 'number' },
|
||||
{ key: 'containerCount', label: 'Containers', type: 'number' },
|
||||
{ key: 'containerNumbers', label: 'Container numbers', type: 'string' },
|
||||
{ key: 'sealNumbers', label: 'Seal numbers', type: 'string' },
|
||||
{ key: 'bulkCargo', label: 'Bulk cargo', type: 'string' },
|
||||
{ key: 'bulkQuantity', label: 'Bulk quantity', type: 'number' },
|
||||
{ key: 'trainNumber', label: 'Train', type: 'string' },
|
||||
{ key: 'departureAt', label: 'Departure', type: 'date' },
|
||||
{ key: 'originStation', label: 'Origin', type: 'string' },
|
||||
{ key: 'destinationStation', label: 'Destination', type: 'string' },
|
||||
],
|
||||
rows,
|
||||
});
|
||||
|
||||
return { filename: `wagons-${booking.reference}.xlsx`, buffer };
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
|
||||
@@ -105,6 +105,31 @@ export class RebookContainerLineDto {
|
||||
units!: RebookUnitDto[];
|
||||
}
|
||||
|
||||
/** One edited container unit on the consolidation partner booking. */
|
||||
export class PartnerUnitPatchDto {
|
||||
@ApiProperty({ description: 'Id of the partner booking container unit being edited' })
|
||||
@IsUUID()
|
||||
id!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Container number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Seal number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
sealNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'VGM (tons) of the unit' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
vgmTons?: number;
|
||||
}
|
||||
|
||||
export class RebookCancelledWagonsDto {
|
||||
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
|
||||
@IsDateString()
|
||||
@@ -132,6 +157,19 @@ export class RebookCancelledWagonsDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partnerBookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Corrections to the partner booking\'s own container units (number / seal ' +
|
||||
'/ VGM). Only the units listed are touched; sizes and quantities are never ' +
|
||||
'changed. Ignored unless partnerBookingId is set.',
|
||||
type: [PartnerUnitPatchDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PartnerUnitPatchDto)
|
||||
partnerUnits?: PartnerUnitPatchDto[];
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
|
||||
@@ -43,6 +43,9 @@ function makeService(overrides?: {
|
||||
findBookingsWithUnreviewedDocuments: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Set<string>()),
|
||||
findBookingsWithRedeemableCredit: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Map<string, string>()),
|
||||
};
|
||||
const bookingsService = {
|
||||
findById: jest.fn().mockResolvedValue(booking),
|
||||
|
||||
@@ -1347,6 +1347,17 @@ export class BookingClearanceService {
|
||||
.hasDocumentsAwaitingReview = pending.has(b.id);
|
||||
}
|
||||
|
||||
// A cancelled booking may still hold a paid-for wagon-cancellation credit.
|
||||
// GL redeems it from this queue, so the row carries the cancellation id the
|
||||
// rebook action needs.
|
||||
const credits = await this.bookingsRepository.findBookingsWithRedeemableCredit(
|
||||
filtered.map((b) => b.id),
|
||||
);
|
||||
for (const b of filtered) {
|
||||
(b as Booking & { rebookableCancellationId?: string | null })
|
||||
.rebookableCancellationId = credits.get(b.id) ?? null;
|
||||
}
|
||||
|
||||
const rows = await this.attachContractSummary(filtered);
|
||||
return this.narrowToYardScope(rows, user);
|
||||
}
|
||||
|
||||
@@ -183,8 +183,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
{ quantity: 4, containerType: { sizeFt: 20 } },
|
||||
],
|
||||
},
|
||||
// A bare instance has no cargo yet — GL enters it on the split form, so it
|
||||
// stays a candidate.
|
||||
// Cargo not entered yet — its 20ft count is unknown, so it cannot be
|
||||
// shown to fill the wagon and is not offered.
|
||||
{ id: 'bare', reference: 'BK-BARE', bookingContainers: [] },
|
||||
];
|
||||
|
||||
@@ -198,7 +198,7 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
rows.filter((row) => {
|
||||
void booking;
|
||||
const lines = row.bookingContainers ?? [];
|
||||
if (lines.length === 0) return true;
|
||||
if (lines.length === 0) return false;
|
||||
const ft20 = lines
|
||||
.filter((l) => Number(l.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, l) => sum + Number(l.quantity || 0), 0);
|
||||
@@ -209,8 +209,8 @@ describe('ContractBookingService — manual odd-20ft consolidation', () => {
|
||||
});
|
||||
|
||||
const candidates = await service.listConsolidationCandidates('c-1', 'b-1');
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD', 'BK-BARE']);
|
||||
expect(candidates.map((c) => c.reference)).toEqual(['BK-ODD']);
|
||||
expect(candidates[0].ft20Quantity).toBe(3);
|
||||
expect(candidates[1].hasCargo).toBe(false);
|
||||
expect(candidates[0].hasCargo).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user