Merge pull request #1484 from Tria-plc/freight_feature/usermanagement

feat: add BookingWagonsPanel component for displaying allocated wagon…
This commit is contained in:
marshal
2026-09-03 15:54:00 +03:00
committed by GitHub
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);
});
});

View File

@@ -0,0 +1,255 @@
import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Badge,
Button,
Center,
Group,
Loader,
SimpleGrid,
Stack,
Table,
Text,
} from "@mantine/core";
import { Container, FileSpreadsheet, Train } from "lucide-react";
import toast from "react-hot-toast";
import { api } from "@/services/api";
import { bookingsService } from "@/services/bookings.service";
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
import { formatDate } from "@/lib/format";
import type { BookingWagonRow } from "@/types/trainScheduling";
import { SectionCard } from "./SectionCard";
import { MetricTile } from "./MetricTile";
/** pg returns numerics as strings; everything here is arithmetic on tons/metres. */
const num = (value: number | string | null | undefined): number => {
const parsed = Number(value ?? 0);
return Number.isFinite(parsed) ? parsed : 0;
};
const tons = (value: number | string | null | undefined): string =>
`${num(value).toLocaleString(undefined, { maximumFractionDigits: 3 })} t`;
/** Allocation status → badge colour. PLANNED is the pre-loading default. */
const STATUS_COLORS: Record<string, string> = {
PLANNED: "blue",
LOADED: "edr-green",
UNLOADED: "gray",
CANCELLED: "red",
};
/**
* The booking detail page's "Wagons" tab: every wagon allocated to this booking,
* with its containers or bulk load, plus an Excel export of the same list.
*
* A booking has no wagons until it is paid and placed on a train, so the empty
* state is the normal case for most of a booking's life — it explains the
* precondition rather than reading as an error.
*/
export function BookingWagonsPanel({
bookingId,
bookingReference,
}: {
bookingId: string;
bookingReference: string;
}) {
const [exporting, setExporting] = useState(false);
const { data, isLoading, isError } = useQuery(
api.trainScheduling.bookingWagons.queryOptions({ input: { bookingId } }),
);
const wagons = useMemo<BookingWagonRow[]>(() => data ?? [], [data]);
const totals = useMemo(() => {
const containerCount = wagons.reduce(
(sum, w) => sum + (w.containers?.length ?? 0),
0,
);
const allocated = wagons.reduce(
(sum, w) => sum + num(w.allocatedWeightTons),
0,
);
const capacity = wagons.reduce((sum, w) => sum + num(w.capacityTons), 0);
return { containerCount, allocated, capacity };
}, [wagons]);
// The train is a property of the allocation, so every wagon on this booking
// carries the same one — read it off the first row rather than per row.
const train = wagons[0];
const handleExport = async () => {
setExporting(true);
try {
const blob = await bookingsService.downloadWagonsWorkbook(bookingId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `wagons-${bookingReference}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} catch (error) {
// Blob response: the JSON reason is inside the Blob, so the sync path
// would surface only "Request failed with status code 400".
toast.error(await extractDownloadErrorMessage(error));
} finally {
setExporting(false);
}
};
if (isLoading) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
return (
<SectionCard
icon={Train}
title="Allocated wagons"
subtitle={
wagons.length
? `${wagons.length} wagon${wagons.length === 1 ? "" : "s"}${
train?.trainNumber ? ` on train ${train.trainNumber}` : ""
}`
: "No wagons allocated yet"
}
extra={
<Button
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<FileSpreadsheet size={15} />}
loading={exporting}
// The sheet would be headers with no rows — nothing to hand over.
disabled={wagons.length === 0}
onClick={() => void handleExport()}
>
Export Excel
</Button>
}
>
{isError ? (
<Text size="sm" c="red">
Could not load the wagon allocations for this booking.
</Text>
) : wagons.length === 0 ? (
<Text size="sm" c="dimmed">
Wagons appear here once the booking is paid and allocated onto a train.
</Text>
) : (
<Stack gap="lg">
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
<MetricTile label="Wagons" value={String(wagons.length)} />
<MetricTile
label="Containers"
value={String(totals.containerCount)}
/>
<MetricTile label="Allocated" value={tons(totals.allocated)} />
<MetricTile label="Capacity" value={tons(totals.capacity)} />
</SimpleGrid>
{train?.departureAt ? (
<Group gap="xs">
<Text size="xs" c="dimmed">
Departs {formatDate(train.departureAt)}
</Text>
{train.originStation && train.destinationStation ? (
<Text size="xs" c="dimmed">
· {train.originStation} {train.destinationStation}
</Text>
) : null}
</Group>
) : null}
<Table.ScrollContainer minWidth={720}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Seq</Table.Th>
<Table.Th>Wagon</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th ta="right">Allocated</Table.Th>
<Table.Th ta="right">Capacity</Table.Th>
<Table.Th>Load</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{wagons.map((w) => (
<Table.Tr key={w.allocationId}>
<Table.Td>
<Text size="sm" c="dimmed">
{w.sequenceNo ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" fw={600}>
{w.wagonNumber ?? "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{w.wagonType ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={STATUS_COLORS[w.status] ?? "gray"}
>
{w.status}
</Badge>
</Table.Td>
<Table.Td ta="right">
<Text size="sm">{tons(w.allocatedWeightTons)}</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{tons(w.capacityTons)}
</Text>
</Table.Td>
<Table.Td>
{w.containers?.length ? (
<Stack gap={2}>
{w.containers.map((c, i) => (
<Group
key={`${w.allocationId}-${c.containerNumber ?? i}`}
gap={6}
wrap="nowrap"
>
<Container
size={13}
style={{ opacity: 0.5, flexShrink: 0 }}
/>
<Text size="xs">
{c.containerNumber ?? "—"}
{c.sizeFt ? ` · ${c.sizeFt}ft` : ""}
</Text>
</Group>
))}
</Stack>
) : w.bulkCargoDescription || w.loadType === "BULK" ? (
<Text size="xs">
{w.bulkCargoDescription ?? "Bulk"}
{w.bulkQuantity ? ` · ${num(w.bulkQuantity)}` : ""}
</Text>
) : (
<Text size="xs" c="dimmed">
</Text>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Stack>
)}
</SectionCard>
);
}

View File

@@ -3,6 +3,7 @@ export * from "./SectionCard";
export * from "./ClearanceReviewSection";
export * from "./BookingDocumentsPanel";
export * from "./BookingTrucksPanel";
export * from "./BookingWagonsPanel";
export * from "./ContractOrdersPanel";
export * from "./MetricTile";
export * from "./BookingDetailToolbar";

View File

@@ -1,11 +1,11 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button, Group, Modal, Select, Stack, Text, TextInput } from "@mantine/core";
import { DatePickerInput } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query";
import toast from "react-hot-toast";
import { OperationDatePicker } from "@edr/ui-common";
import { api } from "@/auth/http";
import { toDayString } from "@/hooks/useListControls";
import { api as rpc } from "@/services/api";
import { formatMoney } from "@/lib/format";
import {
hasOddFt20,
@@ -13,6 +13,7 @@ import {
type WagonCancellation,
} from "./types";
/** Editable rebook unit — prefilled from the cancelled snapshot. */
interface RebookUnitDraft {
containerSize: string;
@@ -21,6 +22,46 @@ interface RebookUnitDraft {
vgmTons: number | "";
}
/** Editable unit on the consolidation partner — prefilled from its own cargo. */
interface PartnerUnitDraft {
id: string;
containerSize: string;
containerNumber: string;
sealNumber: string;
vgmTons: number | "";
}
const partnerDraftsFrom = (c: RebookPartnerCandidate | undefined) =>
(c?.units ?? []).map((u) => ({
id: u.id,
containerSize: u.containerSize,
containerNumber: u.containerNumber,
sealNumber: u.sealNumber ?? "",
vgmTons: Number(u.vgmTons) || ("" as const),
}));
/** Only the units GL actually changed are sent. */
const partnerUnitsPayload = (
drafts: PartnerUnitDraft[],
original: PartnerUnitDraft[],
) =>
drafts
.filter((d, i) => {
const o = original[i];
return (
!o ||
d.containerNumber !== o.containerNumber ||
d.sealNumber !== o.sealNumber ||
d.vgmTons !== o.vgmTons
);
})
.map((d) => ({
id: d.id,
containerNumber: d.containerNumber.trim(),
sealNumber: d.sealNumber.trim(),
...(d.vgmTons !== "" ? { vgmTons: Number(d.vgmTons) } : {}),
}));
const draftsFrom = (r: WagonCancellation): RebookUnitDraft[] =>
(r.cancelledQuantities?.units ?? []).map((u) => ({
containerSize: u.containerSize,
@@ -62,30 +103,75 @@ export function RebookWagonCancellationModal({
/** Called after a successful rebook with the new booking id (when the API returns it). */
onRebooked?: (result: { bookingId?: string }) => void;
}) {
const [date, setDate] = useState<Date | null>(null);
// Held as the picker's own `yyyy-MM-dd` string, never a Date: converting a
// local-midnight Date back with toISOString() shifts it into the previous day
// in any timezone east of UTC (EAT is +03), which both mis-rendered the
// selection and submitted the wrong shipment day.
const [date, setDate] = useState<string | null>(null);
const [partnerId, setPartnerId] = useState<string | null>(null);
const [partnerDrafts, setPartnerDrafts] = useState<PartnerUnitDraft[]>([]);
const [drafts, setDrafts] = useState<RebookUnitDraft[]>([]);
// Fresh form per row: the modal instance is long-lived on the host page.
useEffect(() => {
setDate(null);
setPartnerId(null);
setPartnerDrafts([]);
setDrafts(cancellation ? draftsFrom(cancellation) : []);
}, [cancellation]);
const needsPartner = cancellation ? hasOddFt20(cancellation) : false;
// The rebook rides the same lane with the same cargo as the cancelled
// shipment, so the shipment day must come from the days that lane actually
// runs — an arbitrary calendar day has no train and no wagon capacity.
const daysQuery = useMemo(() => {
const b = cancellation?.booking;
if (!b?.originYardId || !b?.destinationYardId) return null;
const containers = Object.entries(
cancellation?.cancelledQuantities?.bySize ?? {},
)
.map(([containerSize, quantity]) => ({
containerSize,
quantity: Number(quantity || 0),
}))
.filter((c) => c.quantity >= 1);
if (containers.length > 0) {
return {
originYardId: b.originYardId,
destinationYardId: b.destinationYardId,
freightType: "CONTAINER" as const,
containers,
};
}
const tons = Number(cancellation?.weightTons || 0);
if (tons <= 0) return null;
return {
originYardId: b.originYardId,
destinationYardId: b.destinationYardId,
freightType: "BULK" as const,
totalWeightTons: tons,
};
}, [cancellation]);
const { data: availableDays, isLoading: daysLoading } = useQuery({
...rpc.trainScheduling.availableDaysForCargo.queryOptions({
input: daysQuery ?? { freightType: "BULK" as const },
}),
enabled: Boolean(cancellation) && daysQuery !== null,
});
const partners = useQuery({
queryKey: [
"wagon-cancellations",
cancellation?.id,
"rebook-partners",
date ? toDayString(date) : null,
date,
],
enabled: Boolean(cancellation && needsPartner && date),
queryFn: async () => {
const res = await api.get<RebookPartnerCandidate[]>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook-partners`,
{ params: { scheduledDate: toDayString(date!) } },
{ params: { scheduledDate: date } },
);
return res.data;
},
@@ -96,15 +182,28 @@ export function RebookWagonCancellationModal({
const res = await api.post<{ bookingId?: string }>(
`/bookings/wagon-cancellations/${cancellation!.id}/rebook`,
{
scheduledDate: toDayString(date!),
scheduledDate: date,
...(drafts.length ? { containers: containersPayload(drafts) } : {}),
...(partnerId ? { partnerBookingId: partnerId } : {}),
...(() => {
if (!partnerId) return {};
const original = partnerDraftsFrom(
(partners.data ?? []).find((c) => c.id === partnerId),
);
const changed = partnerUnitsPayload(partnerDrafts, original);
return changed.length ? { partnerUnits: changed } : {};
})(),
},
);
return res.data ?? {};
},
});
const patchPartnerDraft = (i: number, patch: Partial<PartnerUnitDraft>) =>
setPartnerDrafts((prev) =>
prev.map((d, idx) => (idx === i ? { ...d, ...patch } : d)),
);
const patchDraft = (i: number, patch: Partial<RebookUnitDraft>) =>
setDrafts((prev) => prev.map((x, idx) => (idx === i ? { ...x, ...patch } : x)));
@@ -115,6 +214,9 @@ export function RebookWagonCancellationModal({
title="Rebook cancelled wagons"
centered
radius="md"
// Wide enough for the calendar plus two container-unit editors side by
// side without the number / seal / VGM fields cramping.
size="xl"
>
{cancellation && (
<Stack gap="sm">
@@ -123,21 +225,30 @@ export function RebookWagonCancellationModal({
{cancellation.wagonsCancelled} wagon(s) · credit{" "}
{formatMoney(cancellation.creditAmount, cancellation.feeCurrency, 2)}
</Text>
<DatePickerInput
label="Shipment day"
placeholder="Pick the day"
value={date}
onChange={(v) => {
setDate(v ? new Date(v) : null);
<Text size="sm" fw={600}>
Shipment day
</Text>
<OperationDatePicker
fullWidth
availableDays={daysQuery === null ? [] : (availableDays ?? [])}
isLoading={daysQuery !== null && daysLoading}
emptyMessage={
daysQuery === null
? "This cancellation has no route or cargo on record — the available shipment days cannot be worked out."
: "No train day on this route can take this cargo right now."
}
value={date ?? ""}
onChange={(d) => {
setDate(d || null);
setPartnerId(null);
setPartnerDrafts([]);
}}
minDate={new Date()}
radius="md"
/>
{needsPartner && (
<Select
label="Consolidation partner"
description="This credit has an odd 20ft container — pick the odd booking that shares its wagon. The rebooked booking is paid; it ships once the partner pays."
withAsterisk
placeholder={
!date
? "Pick the day first"
@@ -150,7 +261,14 @@ export function RebookWagonCancellationModal({
label: `${c.reference} · ${c.companyName ?? "—"} · ${c.ft20Quantity}×20ft`,
}))}
value={partnerId}
onChange={setPartnerId}
onChange={(v) => {
setPartnerId(v);
setPartnerDrafts(
partnerDraftsFrom(
(partners.data ?? []).find((c) => c.id === v),
),
);
}}
disabled={!date}
searchable
radius="md"
@@ -161,12 +279,66 @@ export function RebookWagonCancellationModal({
!partners.isLoading &&
(partners.data ?? []).length === 0 && (
<Text size="xs" c="orange">
No odd-20ft booking rides that day pick another day or wait for
a partner booking.
No odd-20ft booking rides that day pick another day, or wait
for a booking that can share this wagon.
</Text>
)}
{partnerId && partnerDrafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" fw={600}>
Partner containers
</Text>
<Text size="xs" c="dimmed">
Correct the partner booking's own container details if they
changed — sizes and quantities stay as booked.
</Text>
{partnerDrafts.map((d, i) => (
<Group key={d.id} gap={8} wrap="nowrap" align="flex-end">
<TextInput
label={`${d.containerSize || "Container"}`}
value={d.containerNumber}
onChange={(e) =>
patchPartnerDraft(i, {
containerNumber: e.currentTarget.value,
})
}
size="xs"
radius="md"
style={{ flex: 1.4 }}
/>
<TextInput
label="Seal no."
value={d.sealNumber}
onChange={(e) =>
patchPartnerDraft(i, { sealNumber: e.currentTarget.value })
}
size="xs"
radius="md"
style={{ flex: 1 }}
/>
<TextInput
label="VGM (t)"
type="number"
value={d.vgmTons === "" ? "" : String(d.vgmTons)}
onChange={(e) => {
const raw = e.currentTarget.value;
patchPartnerDraft(i, {
vgmTons: raw === "" ? "" : Number(raw),
});
}}
size="xs"
radius="md"
style={{ width: 90 }}
/>
</Group>
))}
</Stack>
)}
{drafts.length > 0 && (
<Stack gap={6}>
<Text size="xs" fw={600}>
This booking's containers
</Text>
<Text size="xs" c="dimmed">
Correct the container details if they changed sizes and
quantities stay as cancelled.

View File

@@ -29,6 +29,11 @@ export interface WagonCancellation {
reference: string;
customsClearingEnabled?: boolean;
company?: { name: string };
/** Route of the cancelled shipment — the rebook rides the same lane, so the
* shipment-day picker offers only days that lane actually runs. */
originYardId?: string | null;
destinationYardId?: string | null;
freightType?: string | null;
};
rebookedBooking?: { id: string; reference: string };
feeInvoice?: { invoiceNumber: string; status: string };
@@ -48,6 +53,14 @@ export interface WagonCancellationListResponse {
total: number;
}
export interface RebookPartnerUnit {
id: string;
containerSize: string;
containerNumber: string;
sealNumber: string | null;
vgmTons: number;
}
export interface RebookPartnerCandidate {
id: string;
reference: string;
@@ -55,6 +68,8 @@ export interface RebookPartnerCandidate {
status: string;
scheduledDate: string | null;
ft20Quantity: number;
/** The partner's own container units — editable while pairing. */
units?: RebookPartnerUnit[];
}
export const WAGON_CANCELLATION_STATUS_CHIP: Record<

View File

@@ -18,8 +18,8 @@ import type { ConsolidationCandidate } from "@/services/contracts.service";
/**
* Picker for the booking that shares this booking's wagon. The server has
* already narrowed the list to bookings that can legally pair — same route and
* direction, customs clearing, an odd 20ft count of their own and not already
* linked to someone else — so every row here is a valid choice.
* direction, same booking day, customs clearing, an odd 20ft count of their own
* and not already linked to someone else — so every row here is a valid choice.
*/
interface Props {
opened: boolean;
@@ -55,8 +55,8 @@ export function ConsolidationPartnerPicker({
Pick the parent booking
</Text>
<Text fz="xs" c="dimmed">
Customs bookings on the same route that also carry an odd number of
20ft containers.
Customs bookings on the same route and booking day that also carry
an odd number of 20ft containers.
</Text>
</Box>
</Group>
@@ -84,9 +84,10 @@ export function ConsolidationPartnerPicker({
title="No booking available to share this wagon"
>
<Text fz="sm">
No other customs booking on this route currently carries an odd
number of 20ft containers. Either wait for one, or switch the
shared-wagon option off and book an even number of 20ft containers.
No other customs booking on this route and booking day currently
carries an odd number of 20ft containers. Either wait for one, or
switch the shared-wagon option off and book an even number of 20ft
containers.
</Text>
</Alert>
) : (
@@ -113,9 +114,7 @@ export function ConsolidationPartnerPicker({
? ` · ${candidate.tradeDirection}`
: ""}
{" · "}
{candidate.hasCargo
? `${candidate.ft20Quantity} × 20ft`
: "cargo not entered yet"}
{`${candidate.ft20Quantity} × 20ft`}
</Text>
</Box>
<Button

View File

@@ -226,6 +226,7 @@ export const URL_CONSTANTS = {
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
`/bookings/${id}/carriage-acceptance-sheet`,
WAGONS_EXPORT: (id: string) => `/bookings/${id}/wagons/export`,
EXPORT_HANDOVER_MODE: (id: string) =>
`/bookings/${id}/export-handover-mode`,
SUMMARY: (id: string) => `/bookings/${id}/summary`,

View File

@@ -44,6 +44,18 @@ export function toBookingListRow(booking: BookingDetail): BookingListRow {
booking.serviceType?.name ??
booking.serviceType?.code,
trainScheduleId: booking.trainScheduleId ?? null,
// List rows carry the flat departure date; detail responses carry the fuller
// summary object instead — fall back to it so a row mapped from either shape
// shows the same date.
trainScheduleDepartureDate:
booking.trainScheduleDepartureDate ??
booking.trainScheduleSummary?.scheduledDepartureDate ??
null,
trainScheduleReference:
booking.trainScheduleReference ??
booking.trainScheduleSummary?.reference ??
booking.trainScheduleSummary?.trainNumber ??
null,
isGovernment: booking.isGovernment ?? false,
governmentInstitution: booking.governmentInstitution ?? null,
consolidationPartnerId: booking.consolidationPartnerId ?? null,

View File

@@ -16,6 +16,7 @@ import {
Receipt,
RefreshCw,
Ship,
Train,
Truck,
Wallet,
Weight,
@@ -63,6 +64,7 @@ import {
BookingSchedulingWindowCard,
BookingDocumentsPanel,
BookingTrucksPanel,
BookingWagonsPanel,
ContractOrdersPanel,
} from "@/components/bookings/detail";
import { WarehouseInfoCard } from "@/components/warehouses";
@@ -219,9 +221,11 @@ export default function BookingRequestDetailPage() {
? "documents"
: requestedTab === "trucks"
? "trucks"
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
: requestedTab === "wagons"
? "wagons"
: requestedTab === "additional-charges"
? "additional-charges"
: "overview";
const setActiveTab = (tab: string | null) => {
const next = new URLSearchParams(searchParams);
if (tab && tab !== "overview") next.set("tab", tab);
@@ -522,6 +526,9 @@ export default function BookingRequestDetailPage() {
<Tabs.Tab value="trucks" leftSection={<Truck size={16} />}>
Trucks
</Tabs.Tab>
<Tabs.Tab value="wagons" leftSection={<Train size={16} />}>
Wagons
</Tabs.Tab>
{canSeeAdditionalCharges && (
<Tabs.Tab
value="additional-charges"
@@ -549,6 +556,12 @@ export default function BookingRequestDetailPage() {
<Tabs.Panel value="trucks">
<BookingTrucksPanel bookingId={booking.id} />
</Tabs.Panel>
<Tabs.Panel value="wagons">
<BookingWagonsPanel
bookingId={booking.id}
bookingReference={booking.reference}
/>
</Tabs.Panel>
{canSeeAdditionalCharges && (
<Tabs.Panel value="additional-charges">
<AdditionalPaymentsTab bookingId={booking.id} onViewFile={view} />

View File

@@ -13,6 +13,7 @@ import {
Plus,
RefreshCw,
Ship,
Train,
User,
} from "lucide-react";
import { useCallback, useMemo, useRef, useState } from "react";
@@ -609,7 +610,7 @@ export default function BookingRequestsPage() {
},
{
id: "scheduled",
header: () => <span className={bookingTable.headerCell}>Scheduled</span>,
header: () => <span className={bookingTable.headerCell}>Requested</span>,
cell: ({ row }) => (
<span className="inline-flex items-center gap-1.5 text-sm text-muted-foreground">
<Calendar className="size-3.5" />
@@ -617,6 +618,50 @@ export default function BookingRequestsPage() {
</span>
),
},
{
// The date of the train the booking is actually allocated to. Empty until
// allocation, which is why it is separate from the requested date above —
// the two differ whenever staff move a booking to another day.
id: "scheduledDate",
header: () => (
<span className={bookingTable.headerCell}>Scheduled date</span>
),
cell: ({ row }) => {
const b = row.original;
if (!b.trainScheduleDepartureDate) {
return (
<span className="text-sm text-muted-foreground">
Not scheduled
</span>
);
}
const movedFromRequest =
b.scheduledDate &&
new Date(b.trainScheduleDepartureDate).toDateString() !==
new Date(b.scheduledDate).toDateString();
return (
<div className="space-y-0.5 py-1">
<span className="inline-flex items-center gap-1.5 text-sm font-medium text-foreground">
<Train className="size-3.5 text-muted-foreground" />
{formatDate(b.trainScheduleDepartureDate)}
</span>
{b.trainScheduleReference ? (
<p className="truncate text-xs text-muted-foreground">
{b.trainScheduleReference}
</p>
) : null}
{movedFromRequest ? (
<Badge
variant="outline"
className="h-4 px-1 text-[9px] font-medium"
>
Date changed
</Badge>
) : null}
</div>
);
},
},
{
id: "priority",
header: () => <span className={bookingTable.headerCell}>Priority</span>,

View File

@@ -58,6 +58,12 @@ import {
summarizeRequestedCargo,
} from "@/features/clearance/requestedCargo";
import { contractsService } from "@/services/contracts.service";
import { api } from "@/auth/http";
import {
RebookWagonCancellationModal,
canRebookWagonCancellations,
type WagonCancellation,
} from "@/components/bookings/wagon-cancellation";
import "./contract-clearance-table.css";
/** Yards carry `label` (API) — older shapes used `name`/`code`. */
@@ -214,6 +220,7 @@ export default function ContractClearanceListPage() {
const canCreateBooking =
hasPermission(user, FREIGHT_PERMS.contracts.createBooking) &&
!isDjiboutiGl(user);
const canRebookCredit = canRebookWagonCancellations(user);
const [query, setQuery] = useState("");
const [tab, setTab] = useState<TabKey>("all");
@@ -234,6 +241,21 @@ export default function ContractClearanceListPage() {
refetch,
} = useBookingEtClearanceQueue(true);
// Credit rebook opens the shared modal, which needs the full cancellation
// row — the queue only carries its id, so fetch it on demand.
const [creditRebook, setCreditRebook] = useState<WagonCancellation | null>(
null,
);
const openCreditRebook = useCallback(async (row: ShipmentBookingRow) => {
const res = await api.get<
{ items?: WagonCancellation[] } | WagonCancellation[]
>(`/bookings/${row.id}/wagon-cancellations`);
const body = res.data;
const list = Array.isArray(body) ? body : (body?.items ?? []);
const match = list.find((c) => c.id === row.rebookableCancellationId);
if (match) setCreditRebook(match);
}, []);
// Shipment requests carry the requested quantities (per container type, or
// bulk weight/items). Map them onto the booking rows by createdBookingId so
// the queue shows what each shipment was requested for.
@@ -272,6 +294,7 @@ export default function ContractClearanceListPage() {
// A bare initiated instance has no cargo/price yet — GL still has to
// create (complete) the booking.
bookingCreated: Number(b.totalAmount ?? 0) > 0,
rebookableCancellationId: b.rebookableCancellationId ?? null,
})) as ShipmentBookingRow[];
}, [bookingQueue, requestedByBooking]);
@@ -602,12 +625,14 @@ export default function ContractClearanceListPage() {
hasFilters={hasFilters}
onClearFilters={clearFilters}
canCreateBooking={canCreateBooking}
canRebookCredit={canRebookCredit}
onOpen={openBooking}
onCreateBooking={(row) =>
navigate(
`/dashboard/contracts/${row.contractId}/bookings/${row.id}/complete`,
)
}
onRebookCredit={openCreditRebook}
onRebook={(row) =>
// Re-complete the SAME expired booking (new day, same finished
// per-booking clearance) — a fresh instance would force the
@@ -623,6 +648,15 @@ export default function ContractClearanceListPage() {
</Stack>
</Card>
</Stack>
<RebookWagonCancellationModal
cancellation={creditRebook}
onClose={() => setCreditRebook(null)}
onRebooked={() => {
setCreditRebook(null);
// The credit is spent and a new booking exists — both change the queue.
void refetch();
}}
/>
</PageContainer>
);
}
@@ -650,6 +684,8 @@ interface ShipmentBookingRow {
createdAt: string | null;
/** true once GL has actually created (completed) the booking. */
bookingCreated: boolean;
/** Unspent wagon-cancellation credit on this booking, if any. */
rebookableCancellationId: string | null;
}
type PaginationState = ReturnType<typeof usePagination>["pagination"];
@@ -666,9 +702,11 @@ function ShipmentBookingsTable({
hasFilters,
onClearFilters,
canCreateBooking,
canRebookCredit,
onOpen,
onCreateBooking,
onRebook,
onRebookCredit,
onViewContract,
}: {
rows: ShipmentBookingRow[];
@@ -681,9 +719,11 @@ function ShipmentBookingsTable({
hasFilters: boolean;
onClearFilters: () => void;
canCreateBooking: boolean;
canRebookCredit: boolean;
onOpen: (id: string) => void;
onCreateBooking: (row: ShipmentBookingRow) => void;
onRebook: (row: ShipmentBookingRow) => void;
onRebookCredit: (row: ShipmentBookingRow) => void;
onViewContract: (contractId: string) => void;
}) {
// A bare initiated instance that has cleared but not yet been created by GL.
@@ -701,6 +741,14 @@ function ShipmentBookingsTable({
r.customs &&
r.status === "EXPIRED";
// A cancelled booking whose wagon-cancellation credit is paid for and unspent.
// Redeeming it is a different action from re-completing an expired booking —
// it opens the credit rebook modal rather than the completion form. Gated on
// the rebook permission (not booking-creation) so the button matches exactly
// who the API lets through.
const hasRebookableCredit = (r: ShipmentBookingRow) =>
canRebookCredit && Boolean(r.rebookableCancellationId);
const columns = useMemo<ColumnDef<ShipmentBookingRow>[]>(
() => [
{
@@ -840,6 +888,7 @@ function ShipmentBookingsTable({
const r = row.original;
const bookable = isBookable(r);
const rebookable = isRebookable(r);
const creditRebookable = hasRebookableCredit(r);
return (
<Group
justify="flex-end"
@@ -870,6 +919,17 @@ function ShipmentBookingsTable({
Rebook
</Button>
) : null}
{creditRebookable ? (
<Button
size="compact-sm"
color="teal"
radius="md"
leftSection={<RefreshCw size={14} />}
onClick={() => onRebookCredit(r)}
>
Rebook credit
</Button>
) : null}
<Menu shadow="md" radius="md" position="bottom-end" withinPortal>
<Menu.Target>
<ActionIcon
@@ -904,6 +964,14 @@ function ShipmentBookingsTable({
Rebook (GL)
</Menu.Item>
) : null}
{creditRebookable ? (
<Menu.Item
leftSection={<RefreshCw size={14} />}
onClick={() => onRebookCredit(r)}
>
Rebook cancellation credit
</Menu.Item>
) : null}
{r.contractId ? (
<Menu.Item
leftSection={<ExternalLink size={14} />}

View File

@@ -810,6 +810,14 @@ export const bookingsService = {
return ensurePdfBlob(response.data as Blob);
},
/** The Wagons tab's Excel export — customer name plus one row per wagon. */
downloadWagonsWorkbook: async (id: string): Promise<Blob> => {
const response = await client.get(B.WAGONS_EXPORT(id), {
responseType: "blob",
});
return response.data as Blob;
},
getDjClearanceQueue: async (): Promise<BookingDetail[]> => {
const response = await client.get(B.CLEARANCE_DJ_QUEUE);
return (unwrap(response.data) ?? []) as BookingDetail[];

View File

@@ -216,6 +216,14 @@ export interface BookingDetail {
wagonsRequired?: number | null;
scheduledAt?: string | null;
trainScheduleId?: string | null;
/**
* The allocated train's departure date, attached by the LIST endpoint (the
* detail endpoint carries the fuller `trainScheduleSummary` instead). This is
* the operational date, as opposed to the customer-requested `scheduledDate`.
*/
trainScheduleDepartureDate?: string | null;
/** The allocated train's reference (S-YYYY-NNNNN) or train number. */
trainScheduleReference?: string | null;
/** Operational status of the allocated train (null until scheduled). */
trainScheduleStatus?: string | null;
/** The allocated train's identity + clock, attached by the detail endpoint. */
@@ -244,6 +252,12 @@ export interface BookingDetail {
allDocsApproved?: boolean;
/** ET clearance queue: a customer document is PENDING or QUERIED. */
hasDocumentsAwaitingReview?: boolean;
/**
* ET clearance queue: id of an unspent wagon-cancellation credit on this
* booking (CREDIT_AVAILABLE, worth > 0, not yet rebooked). Null when there is
* none — GL rebooks the credit straight from the queue row.
*/
rebookableCancellationId?: string | null;
contractKind?: "ONE_TIME" | "GENERAL" | null;
contractId?: string | null;
/** Reference of the contract this booking was created under (list column + search). */
@@ -304,6 +318,10 @@ export interface BookingListRow {
schedulingStatus?: string;
serviceTypeLabel?: string;
trainScheduleId?: string | null;
/** Departure date of the train this booking is allocated to; null until scheduled. */
trainScheduleDepartureDate?: string | null;
/** Reference of the train this booking is allocated to. */
trainScheduleReference?: string | null;
isGovernment?: boolean;
governmentInstitution?: string | null;
consolidationPartnerId?: string | null;

View File

@@ -1258,10 +1258,24 @@ export interface BookingWagonRow {
allocatedWeightTons: number | string | null;
loadType: string | null;
status: string;
/** Numeric columns arrive as strings from pg — parse before arithmetic. */
tareWeightTons?: number | string | null;
capacityTons?: number | string | null;
lengthMeters?: number | string | null;
/** The train this wagon rides on, and where it runs. */
trainNumber?: string | null;
departureAt?: string | null;
originStation?: string | null;
destinationStation?: string | null;
/** Set only when the wagon carries bulk rather than containers. */
bulkCargoDescription?: string | null;
bulkQuantity?: number | string | null;
containers: Array<{
containerNumber: string | null;
sizeFt: number | null;
grossWeightTons: number | string | null;
sealNumber?: string | null;
positionOnWagon?: number | null;
}>;
}