add customs clearing filter and enhance clearance documents page for non-customs contracts

This commit is contained in:
Marshal
2026-07-12 14:14:21 +00:00
parent 84ba56f7d0
commit e7e5b93ffc
10 changed files with 417 additions and 10 deletions

View File

@@ -1211,6 +1211,12 @@ export class BookingsService {
destinationYardId: filter.destinationYardId,
isGovernment: filter.isGovernment,
consolidationPaired: filter.consolidationPaired,
// DTO carries 'true'/'false' strings (query params); the repo option is a
// real boolean — convert, preserving "not filtered" when absent.
customsClearingEnabled:
filter.customsClearingEnabled === undefined
? undefined
: filter.customsClearingEnabled === 'true',
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,

View File

@@ -106,6 +106,14 @@ export class FilterBookingDto {
@IsIn(['true', 'false'])
isGovernment?: 'true' | 'false';
@ApiPropertyOptional({
enum: ['true', 'false'],
description: 'Filter customs vs self-clearance (non-customs) bookings',
})
@IsOptional()
@IsIn(['true', 'false'])
customsClearingEnabled?: 'true' | 'false';
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])

View File

@@ -872,6 +872,7 @@ export class ContractClearanceService {
pageSize: filter.pageSize ?? 100,
statuses: ['CLEARANCE_UNDER_REVIEW'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
@@ -896,6 +897,7 @@ export class ContractClearanceService {
pageSize: filter.pageSize ?? 50,
statuses: ['CLEARANCE_READY_FOR_BOOKING', 'ACTIVE', 'CLOSED', 'CANCELLED'],
customsClearingEnabled: false,
search: filter.search,
sortBy: filter.sortBy ?? 'createdAt',
sortOrder: filter.sortOrder ?? 'DESC',
});

View File

@@ -53,6 +53,18 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
});
}
/**
* Light fetch for human-facing labels (notifications): reference, train
* number, departure and the two station names — none of the composition
* graph {@link findByIdWithFullGraph} drags in.
*/
findByIdWithStations(id: string): Promise<TrainSchedule | null> {
return this.repository.findOne({
where: { id },
relations: { originStation: true, destinationStation: true },
});
}
async updateStatus(
id: string,
status: TrainScheduleStatus,

View File

@@ -2258,7 +2258,7 @@ export class BookingBatchService implements OnModuleInit {
this.logger.log(
`[BATCH] ALLOCATED ${booking.reference} (${reason}) to train on schedule ${scheduleId}`,
);
this.notifier.secured(booking, reason);
this.notifier.secured(booking, reason, scheduleId);
void this.triggerWagonAllocation(scheduleId);
void this.markWagonAllocatedMilestone(booking.id);
// Customer tracking: freight payment settled (commercial pay-window path).

View File

@@ -9,6 +9,7 @@ import {
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BATCH_TIMEZONE } from './booking-batch.constants';
@Injectable()
@@ -18,8 +19,37 @@ export class BookingNotifierService {
constructor(
private readonly notifications: NotificationsService,
private readonly inbox: NotificationInboxService,
private readonly trainSchedules: TrainSchedulesRepository,
) {}
/**
* Human-readable description of a train schedule for customer messages:
* reference (or train number) + route + departure date. Never leaks a UUID —
* falls back to a generic phrase when the schedule can't be loaded.
*/
private async scheduleLabel(scheduleId?: string | null): Promise<string> {
const fallback = 'your selected train';
if (!scheduleId) return fallback;
try {
const s = await this.trainSchedules.findByIdWithStations(scheduleId);
if (!s) return fallback;
const ref = s.reference ?? s.trainNumber ?? null;
const route =
s.originStation?.label && s.destinationStation?.label
? ` (${s.originStation.label}${s.destinationStation.label})`
: '';
const departure = s.scheduledDepartureDate
? `, departing ${new Date(s.scheduledDepartureDate).toLocaleDateString('en-GB', { timeZone: BATCH_TIMEZONE })}`
: '';
return ref ? `train ${ref}${route}${departure}` : `${fallback}${route}${departure}`;
} catch (err) {
this.logger.warn(
`scheduleLabel(${scheduleId}) failed: ${(err as Error).message}`,
);
return fallback;
}
}
private ref(b: Booking): string {
return `${b.reference}${b.isGovernment ? ' (gov)' : ''}`;
}
@@ -127,12 +157,15 @@ export class BookingNotifierService {
});
}
secured(b: Booking, reason: 'paid' | 'gov'): void {
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
void (async () => {
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
const msg = `Booking ${b.reference ?? b.id} allocated on ${label}${
reason === 'gov' ? ' (government)' : ''
}.`;
void this.notifyContact(b, msg, 'ALLOCATED');
this.inApp(b, 'Wagon allocated', msg);
})();
}
expired(b: Booking): void {