mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 21:15:41 +00:00
feat: enhance booking and audit log functionalities
- Implemented read-only locking for customer-requested container sizes and billing currency in the GlCreateBookingForm component. - Added functionality to lock partner quantities based on shipment requests in the ConsolidationPartnerPanel. - Introduced a new Leave action in the LogPassYardWorkModal to unassign bookings from trains. - Enhanced the AuditLogsPage to support filtering by action and added a Go button for direct navigation to entity detail pages. - Updated WagonCancellationsPage to handle odd-20ft credits requiring partner selection during rebooking. - Improved TrainScheduleV2DetailPage to allow manual loading of cargo and display warnings for unassigned bookings. - Added a new reference field to the audit logs for better searchability and tracking of actions. - Created a migration to add the reference column to the audit logs table and established an index for efficient querying. - Defined a registry for audit reference sources to streamline the retrieval of human identifiers for various entities.
This commit is contained in:
@@ -40,3 +40,70 @@ describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
expect(cut.weightTons).toBeCloseTo(62.625, 3);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Odd-20ft credit rebook: the rebooked booking shares a wagon again, so GL
|
||||
* must pick the consolidation partner — no partner, no rebook; a partner
|
||||
* already paired elsewhere is refused.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () => {
|
||||
const units = Array.from({ length: 3 }, (_, i) => ({
|
||||
containerSize: '20ft',
|
||||
containerNumber: `CONT${i}`,
|
||||
sealNumber: null,
|
||||
vgmTons: 10,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
}));
|
||||
const row = {
|
||||
id: 'wc1',
|
||||
bookingId: 'b1',
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
creditAmount: 100,
|
||||
cancelledQuantities: { bySize: { '20ft': 3 }, units },
|
||||
};
|
||||
const source = {
|
||||
id: 'b1',
|
||||
contractId: 'c1',
|
||||
paymentCurrency: 'USD',
|
||||
originYardId: 'y1',
|
||||
destinationYardId: 'y2',
|
||||
tradeDirection: 'IMPORT',
|
||||
};
|
||||
|
||||
const makeSvc = (partner?: unknown) => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as Record<
|
||||
string,
|
||||
unknown
|
||||
> & {
|
||||
rebook(id: string, dto: unknown): Promise<unknown>;
|
||||
};
|
||||
svc.repo = { findById: async () => row };
|
||||
svc.bookingsRepository = {
|
||||
findById: async () => source,
|
||||
findByIdWithFiles: async () => partner ?? null,
|
||||
};
|
||||
return svc;
|
||||
};
|
||||
|
||||
it('refuses an odd-20ft rebook without a GL-picked partner', async () => {
|
||||
await expect(
|
||||
makeSvc().rebook('wc1', { scheduledDate: '2026-09-01' }),
|
||||
).rejects.toThrow(/pick a consolidation partner/i);
|
||||
});
|
||||
|
||||
it('refuses a partner that already shares a wagon', async () => {
|
||||
const paired = {
|
||||
id: 'p1',
|
||||
reference: 'BK-1',
|
||||
status: 'SUBMITTED',
|
||||
consolidationPartnerId: 'someone-else',
|
||||
};
|
||||
await expect(
|
||||
makeSvc(paired).rebook('wc1', {
|
||||
scheduledDate: '2026-09-01',
|
||||
partnerBookingId: 'p1',
|
||||
}),
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
@@ -126,6 +126,7 @@ export class BookingWagonCancellationService {
|
||||
@Inject(forwardRef(() => FirstMileService))
|
||||
private readonly firstMile: FirstMileService,
|
||||
private readonly inbox: NotificationInboxService,
|
||||
private readonly events: EventEmitter2,
|
||||
) {}
|
||||
|
||||
// ── T1: request ────────────────────────────────────────────────────────────
|
||||
@@ -782,6 +783,26 @@ export class BookingWagonCancellationService {
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate, dto.containers);
|
||||
// Same currency as the source booking — the credit is in it.
|
||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
||||
|
||||
// An odd-20ft credit shares a wagon again on rebook. GL picks who — never
|
||||
// the auto-matcher (it could claim a partner behind GL's back), so the
|
||||
// create below runs with auto-consolidation off and the chosen partner is
|
||||
// linked once the booking exists and is PAID.
|
||||
const oddFt20 = this.creditFt20(row) % 2 === 1;
|
||||
let partner: Booking | null = null;
|
||||
if (oddFt20) {
|
||||
createDto.skipAutoConsolidation = true;
|
||||
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).',
|
||||
);
|
||||
}
|
||||
partner = await this.loadRebookPartner(
|
||||
source,
|
||||
dto.partnerBookingId,
|
||||
dto.scheduledDate,
|
||||
);
|
||||
}
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
source.contractId,
|
||||
createDto,
|
||||
@@ -814,12 +835,19 @@ export class BookingWagonCancellationService {
|
||||
`First-mile accept failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
if (partner) {
|
||||
// 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.
|
||||
await this.pairRebookedBooking(newBookingId, partner);
|
||||
} else {
|
||||
try {
|
||||
await this.bookingBatch.ensurePaidBookingAllocated(newBookingId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Allocation failed for rebooked ${newBookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = (await this.repo.update(row.id, {
|
||||
@@ -837,6 +865,142 @@ export class BookingWagonCancellationService {
|
||||
return { cancellation: updated, bookingId: newBookingId };
|
||||
}
|
||||
|
||||
/** Total 20ft units the credit carries (odd ⇒ the rebook shares a wagon again). */
|
||||
private creditFt20(row: BookingWagonCancellation): number {
|
||||
return Object.entries(row.cancelledQuantities?.bySize ?? {})
|
||||
.filter(([size]) => sizeFtOf(size) === 20)
|
||||
.reduce((sum, [, qty]) => sum + Number(qty || 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Partner candidates for rebooking an odd-20ft credit — what the GL rebook
|
||||
* form lists. Empty when the credit is even (no shared wagon) or spent.
|
||||
*/
|
||||
async rebookPartnerCandidates(
|
||||
cancellationId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<
|
||||
Array<{
|
||||
id: string;
|
||||
reference: string;
|
||||
companyName: string | null;
|
||||
status: string;
|
||||
scheduledDate: string | null;
|
||||
ft20Quantity: number;
|
||||
}>
|
||||
> {
|
||||
const row = await this.mustFind(cancellationId);
|
||||
if (row.status !== 'CREDIT_AVAILABLE') return [];
|
||||
if (this.creditFt20(row) % 2 === 0) return [];
|
||||
const source = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (!source) return [];
|
||||
const rows = await this.bookingsRepository.findRebookConsolidationCandidates(
|
||||
source,
|
||||
new Date(scheduledDate),
|
||||
);
|
||||
return rows.map((b) => ({
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
companyName: b.company?.name ?? null,
|
||||
status: b.status,
|
||||
scheduledDate: b.scheduledDate ? b.scheduledDate.toISOString() : null,
|
||||
ft20Quantity: (b.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0),
|
||||
}));
|
||||
}
|
||||
|
||||
/** The GL-picked partner, validated to actually fit the rebooked shared wagon. */
|
||||
private async loadRebookPartner(
|
||||
source: Booking,
|
||||
partnerId: string,
|
||||
scheduledDate: string,
|
||||
): Promise<Booking> {
|
||||
const partner = await this.bookingsRepository.findByIdWithFiles(partnerId);
|
||||
if (!partner) {
|
||||
throw new NotFoundException(`Partner booking ${partnerId} not found.`);
|
||||
}
|
||||
if (partner.consolidationPartnerId) {
|
||||
throw new ConflictException(
|
||||
`Booking ${partner.reference} already shares a wagon with another booking.`,
|
||||
);
|
||||
}
|
||||
if (!['SUBMITTED', 'PENDING_CONSOLIDATION'].includes(partner.status)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} cannot be consolidated (status ${partner.status}).`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
partner.originYardId !== source.originYardId ||
|
||||
partner.destinationYardId !== source.destinationYardId ||
|
||||
partner.tradeDirection !== source.tradeDirection
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} rides a different route/direction — it cannot share a wagon with this rebooking.`,
|
||||
);
|
||||
}
|
||||
const eatDay = (d: Date | string) =>
|
||||
new Date(d).toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
|
||||
if (!partner.scheduledDate || eatDay(partner.scheduledDate) !== eatDay(scheduledDate)) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} is not booked for ${eatDay(scheduledDate)} — a shared wagon must board one train.`,
|
||||
);
|
||||
}
|
||||
const ft20 = (partner.bookingContainers ?? [])
|
||||
.filter((line) => Number(line.containerType?.sizeFt) === 20)
|
||||
.reduce((sum, line) => sum + Number(line.quantity || 0), 0);
|
||||
if (ft20 % 2 !== 1) {
|
||||
throw new BadRequestException(
|
||||
`Booking ${partner.reference} has no odd 20ft container — nothing to consolidate.`,
|
||||
);
|
||||
}
|
||||
return partner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link the rebooked (already PAID) booking with the GL-picked partner. A
|
||||
* parked partner is resumed the way pairConsolidation would resume it —
|
||||
* but only the partner: the rebooked side's PAID status must survive, so
|
||||
* the link is written directly. The paired event then runs the partner's
|
||||
* deferred contract finalize (invoice → pay window); the shared wagon
|
||||
* boards once that invoice is paid.
|
||||
*/
|
||||
private async pairRebookedBooking(
|
||||
newBookingId: string,
|
||||
partner: Booking,
|
||||
): Promise<void> {
|
||||
// ponytail: validate-then-link without a row lock — a concurrent claim in
|
||||
// this window loses silently; move to pairConsolidationIfUnpaired-style
|
||||
// locking if it ever bites.
|
||||
const fresh = await this.dataSource.getRepository(Booking).findOne({
|
||||
where: { id: partner.id },
|
||||
select: { id: true, consolidationPartnerId: true, status: true },
|
||||
});
|
||||
if (!fresh || fresh.consolidationPartnerId) {
|
||||
throw new ConflictException(
|
||||
`Booking ${partner.reference} was claimed by another consolidation while rebooking — pick another partner.`,
|
||||
);
|
||||
}
|
||||
if (fresh.status === 'PENDING_CONSOLIDATION') {
|
||||
await this.dataSource.getRepository(Booking).update(partner.id, {
|
||||
status: partner.consolidationResumeStatus ?? 'SUBMITTED',
|
||||
consolidationResumeStatus: null,
|
||||
});
|
||||
}
|
||||
await this.bookingsRepository.linkConsolidationPartners(
|
||||
newBookingId,
|
||||
partner.id,
|
||||
);
|
||||
this.events.emit('booking.consolidation.paired', {
|
||||
bookingIds: [partner.id],
|
||||
});
|
||||
this.notifyCustomer(
|
||||
partner,
|
||||
'Consolidation partner found',
|
||||
`${partner.reference} now shares a wagon with a rebooked shipment. Pay your booking to board — the shared wagon ships once both halves are paid.`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── History ────────────────────────────────────────────────────────────────
|
||||
|
||||
list(filter: WagonCancellationListFilter) {
|
||||
|
||||
@@ -725,6 +725,30 @@ export class BookingsController {
|
||||
return this.wagonCancellationService.withdraw(cancellationId);
|
||||
}
|
||||
|
||||
@Get("wagon-cancellations/:cancellationId/rebook-partners")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Consolidation partner candidates for rebooking an odd-20ft credit on the given day (GL picks who shares the rebooked wagon)",
|
||||
})
|
||||
async listRebookPartners(
|
||||
@Param("cancellationId", ParseUUIDPipe) cancellationId: string,
|
||||
@Query("scheduledDate") scheduledDate: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
if (!scheduledDate) {
|
||||
throw new BadRequestException("scheduledDate is required.");
|
||||
}
|
||||
return this.wagonCancellationService.rebookPartnerCandidates(
|
||||
cancellationId,
|
||||
scheduledDate,
|
||||
);
|
||||
}
|
||||
|
||||
@Post("wagon-cancellations/:cancellationId/rebook")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
|
||||
@@ -377,6 +377,58 @@ 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.
|
||||
* Unlike {@link findManualConsolidationCandidates} this is not customs-only:
|
||||
* GL picks who shares the rebooked wagon whatever the contract kind.
|
||||
*/
|
||||
async findRebookConsolidationCandidates(
|
||||
booking: Booking,
|
||||
scheduledDate: Date,
|
||||
limit = 50,
|
||||
): Promise<Booking[]> {
|
||||
const rows = await this.repository
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.bookingContainers', 'bc')
|
||||
.leftJoinAndSelect('bc.containerType', 'ct')
|
||||
.leftJoinAndSelect('b.company', 'company')
|
||||
.where('b.id != :bookingId', { bookingId: booking.id })
|
||||
.andWhere('b.consolidationPartnerId IS NULL')
|
||||
.andWhere('b.originYardId = :originYardId', {
|
||||
originYardId: booking.originYardId,
|
||||
})
|
||||
.andWhere('b.destinationYardId = :destinationYardId', {
|
||||
destinationYardId: booking.destinationYardId,
|
||||
})
|
||||
.andWhere('b.tradeDirection = :tradeDirection', {
|
||||
tradeDirection: booking.tradeDirection,
|
||||
})
|
||||
.andWhere('b.status IN (:...statuses)', {
|
||||
statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'],
|
||||
})
|
||||
// Same EAT booking day as the rebook — the pair shares one physical
|
||||
// wagon, so it must board one train.
|
||||
.andWhere(
|
||||
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
|
||||
{ bookingDate: scheduledDate },
|
||||
)
|
||||
.orderBy('b.createdAt', 'ASC')
|
||||
.take(limit)
|
||||
.getMany();
|
||||
|
||||
// Odd-20ft test in memory (two 20ft per wagon: odd + odd = whole wagons).
|
||||
return rows.filter((row) => {
|
||||
const lines = row.bookingContainers ?? [];
|
||||
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);
|
||||
return ft20 % 2 === 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find another booking whose container quantity complements this one to fill whole wagon(s)
|
||||
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
|
||||
|
||||
@@ -121,6 +121,15 @@ export class RebookCancelledWagonsDto {
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => RebookContainerLineDto)
|
||||
containers?: RebookContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Required when the credit carries an odd 20ft count: the odd-20ft booking ' +
|
||||
'GL picked to share the rebooked wagon (see the rebook-partners endpoint).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partnerBookingId?: string;
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
|
||||
Reference in New Issue
Block a user