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:
marshalyordanos
2026-09-03 15:39:27 +03:00
parent 165146c09d
commit 9c57aa1c0c
23 changed files with 1244 additions and 61 deletions

View File

@@ -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();
});
});
/**

View File

@@ -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 —

View File

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

View File

@@ -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(),
],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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