mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 16:40:56 +00:00
@@ -848,8 +848,10 @@ export class ContractClearanceService {
|
||||
}
|
||||
|
||||
/**
|
||||
* GL ET clearance hub: every customs (Path B) contract in phased clearance,
|
||||
* including after booking is created.
|
||||
* GL ET clearance hub, Contracts tab: ONE_TIME customs (Path B) contracts in
|
||||
* phased clearance that already carry at least one uploaded clearance
|
||||
* document — a contract still waiting for its first document has nothing to
|
||||
* review, and GENERAL contracts clear per booking, not at contract level.
|
||||
*/
|
||||
async queue(filter: FilterContractDto): Promise<PaginatedContracts> {
|
||||
return this.contractsRepository.findAllPaginated({
|
||||
@@ -857,6 +859,8 @@ export class ContractClearanceService {
|
||||
pageSize: filter.pageSize ?? 100,
|
||||
statuses: [...PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES],
|
||||
customsClearingEnabled: true,
|
||||
contractKind: 'ONE_TIME',
|
||||
hasClearanceDocuments: true,
|
||||
sortBy: filter.sortBy,
|
||||
sortOrder: filter.sortOrder,
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface ContractListFilterOptions {
|
||||
tradeDirection?: string;
|
||||
paymentCurrency?: string;
|
||||
customsClearingEnabled?: boolean;
|
||||
/** true → only contracts with at least one uploaded clearance document. */
|
||||
hasClearanceDocuments?: boolean;
|
||||
createdFrom?: string;
|
||||
createdTo?: string;
|
||||
}
|
||||
@@ -284,6 +286,12 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
customsClearingEnabled: options.customsClearingEnabled,
|
||||
});
|
||||
}
|
||||
if (options.hasClearanceDocuments) {
|
||||
qb.andWhere(
|
||||
'EXISTS (SELECT 1 FROM freight.contract_document_review cdr ' +
|
||||
'WHERE cdr.contract_id = contract.id AND cdr.deleted_at IS NULL)',
|
||||
);
|
||||
}
|
||||
if (options.serviceTypeId) {
|
||||
qb.andWhere('contract.service_type_id = :serviceTypeId', {
|
||||
serviceTypeId: options.serviceTypeId,
|
||||
|
||||
@@ -1136,14 +1136,35 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Schedule has no train set');
|
||||
}
|
||||
|
||||
// Batch parity: a schedule may only allocate bookings that targeted it. This mirrors
|
||||
// the automatic fill, which only pulls bookings whose train_schedule_id is this schedule.
|
||||
// Batch parity: a schedule may only allocate bookings from its route-day POOL.
|
||||
// Under day-level pooling (see fillRouteDayInternal) an unreserved booking has
|
||||
// a NULL train_schedule_id and is only pinned by reserve(); a reserved one is
|
||||
// pinned to whichever train in the day's group first held it. Every train
|
||||
// sharing this origin + destination + EAT departure day draws from ONE shared
|
||||
// pool (one shared booking window), so a booking is allocatable here when it is
|
||||
// either unpinned (NULL) or pinned to THIS train or a GROUP SIBLING. A booking
|
||||
// pinned to a train on a DIFFERENT route/day is a real stray. Genuine route/
|
||||
// day/capacity fit is enforced downstream by validateBookingsForScheduling.
|
||||
// EXPORT never groups, so its pool is this schedule alone (plus NULL pool).
|
||||
if (dto.bookingIds.length) {
|
||||
const groupScheduleIds = new Set<string>([scheduleId]);
|
||||
if (schedule.direction !== 'EXPORT') {
|
||||
const siblings = await this.findGroupSiblings(
|
||||
this.dataSource.manager,
|
||||
schedule.originStationId,
|
||||
schedule.destinationStationId,
|
||||
schedule.scheduledDepartureDate,
|
||||
scheduleId,
|
||||
);
|
||||
for (const sib of siblings) groupScheduleIds.add(sib.id);
|
||||
}
|
||||
const targeted = await this.bookingsRepository.findByIdsForScheduling(dto.bookingIds);
|
||||
const stray = targeted.filter((b) => b.trainScheduleId !== scheduleId);
|
||||
const stray = targeted.filter(
|
||||
(b) => b.trainScheduleId != null && !groupScheduleIds.has(b.trainScheduleId),
|
||||
);
|
||||
if (stray.length) {
|
||||
throw new BadRequestException(
|
||||
`These bookings are not assigned to this schedule: ${stray
|
||||
`These bookings are pinned to a train on a different route or day: ${stray
|
||||
.map((b) => b.reference ?? b.id)
|
||||
.join(', ')}`,
|
||||
);
|
||||
|
||||
@@ -149,8 +149,12 @@ export function ExportClearanceStepper({
|
||||
const entityId = contractId ?? bookingId ?? "";
|
||||
// The booking that carries the post-booking steps (gate pass, T1, invoice).
|
||||
const actionBookingId = clearance.linkedBookingId ?? bookingId ?? null;
|
||||
// Per-booking GENERAL clearance runs on a bare instance that only becomes a
|
||||
// real booking once GL completes it — the caller's bookingCreated prop carries
|
||||
// that signal, so a booking-keyed view must NOT count as "created" by itself
|
||||
// (it would lock GL Ethiopia out of the declaration step right after the RO).
|
||||
const effectiveBookingCreated =
|
||||
bookingCreated || Boolean(clearance.linkedBookingId) || isBooking;
|
||||
bookingCreated || Boolean(clearance.linkedBookingId);
|
||||
|
||||
const activeStep = useMemo(
|
||||
() => computeExportActiveStep(clearance, bookingMilestones, effectiveBookingCreated),
|
||||
|
||||
@@ -248,6 +248,7 @@ export default function DocumentClearanceDetailPage() {
|
||||
// stepper's "Create booking" step must read as NOT-yet-created
|
||||
// so it never claims the booking is done before GL completes it.
|
||||
bookingCreated={Number(booking?.totalAmount ?? 0) > 0}
|
||||
bookingMilestones={bookingMilestones ?? []}
|
||||
onChanged={() => void refetch()}
|
||||
onViewFile={view}
|
||||
onDownloadFile={(f) => void downloadBookingFile(f.id, f.name)}
|
||||
|
||||
@@ -303,7 +303,7 @@ export class NotificationsService {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
},
|
||||
});
|
||||
@@ -353,6 +353,27 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the user's actual boarding/alighting stations from the booking's originStationId /
|
||||
* destinationStationId via stopTimes, falling back to the schedule's full-route endpoints when
|
||||
* the booking has no segment override (e.g. older records or packages).
|
||||
*/
|
||||
private resolveSegmentStations(booking: any): { originStation: any; destinationStation: any } {
|
||||
const s = booking?.schedule ?? {};
|
||||
const stopTimes: any[] = s.stopTimes ?? [];
|
||||
const findStation = (stationId: string | null | undefined, fallback: any) => {
|
||||
if (stationId && stopTimes.length > 0) {
|
||||
const stop = stopTimes.find((st: any) => st.stationId === stationId);
|
||||
if (stop?.station) return stop.station;
|
||||
}
|
||||
return fallback ?? null;
|
||||
};
|
||||
return {
|
||||
originStation: findStation(booking?.originStationId, s.originStation),
|
||||
destinationStation: findStation(booking?.destinationStationId, s.destinationStation),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the interpolation context for the `booking.created` template. `trainSeatLines` is a
|
||||
* pre-joined block of one "Train/Seat: …" line per booked seat (multi-passenger bookings get
|
||||
@@ -379,12 +400,13 @@ export class NotificationsService {
|
||||
// Lead passenger (leg-1 seat). Booking has no contactName; the traveller name lives on the seat.
|
||||
const passengerName = seats[0]?.passengerName ?? 'Passenger';
|
||||
const payLink = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/detail?ref=${ref}`;
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
|
||||
return {
|
||||
passengerName,
|
||||
bookingRef: ref,
|
||||
origin: s.originStation?.name ?? '',
|
||||
destination: s.destinationStation?.name ?? '',
|
||||
origin: originSt?.name ?? '',
|
||||
destination: destSt?.name ?? '',
|
||||
trainSeatLines,
|
||||
travelDate: fmtDate(s.departureAt),
|
||||
departureTime: fmtTime(s.departureAt),
|
||||
@@ -406,7 +428,7 @@ export class NotificationsService {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
},
|
||||
});
|
||||
@@ -489,9 +511,10 @@ export class NotificationsService {
|
||||
const s = booking.schedule ?? {};
|
||||
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD';
|
||||
const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
return [
|
||||
`Booking ${booking.bookingRef} confirmed.`,
|
||||
`${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`,
|
||||
`${originSt?.name ?? ''} -> ${destSt?.name ?? ''}`,
|
||||
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
|
||||
`Departs: ${dep}`,
|
||||
passengers ? `Passengers: ${passengers}` : '',
|
||||
@@ -504,6 +527,7 @@ export class NotificationsService {
|
||||
const s = booking.schedule ?? {};
|
||||
const fmt = (d: any) =>
|
||||
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
const seatRows = (booking.seats ?? [])
|
||||
.map((bs: any) => {
|
||||
const coach = bs.seat?.coach?.number ?? '-';
|
||||
@@ -532,11 +556,11 @@ export class NotificationsService {
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">From</td>
|
||||
<td style="padding:8px 0;text-align:right;"><strong>${s.originStation?.name ?? ''}</strong> (${s.originStation?.code ?? ''})</td>
|
||||
<td style="padding:8px 0;text-align:right;"><strong>${originSt?.name ?? ''}</strong> (${originSt?.code ?? ''})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">To</td>
|
||||
<td style="padding:8px 0;text-align:right;"><strong>${s.destinationStation?.name ?? ''}</strong> (${s.destinationStation?.code ?? ''})</td>
|
||||
<td style="padding:8px 0;text-align:right;"><strong>${destSt?.name ?? ''}</strong> (${destSt?.code ?? ''})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">Train</td>
|
||||
@@ -612,8 +636,9 @@ export class NotificationsService {
|
||||
const fmt = (d: any) =>
|
||||
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
||||
const legLabel = leg ? ` (${leg.replace(/_/g, ' ')})` : '';
|
||||
const origin = s.originStation?.name ?? '';
|
||||
const dest = s.destinationStation?.name ?? '';
|
||||
const { originStation: originSt, destinationStation: destSt } = this.resolveSegmentStations(booking);
|
||||
const origin = originSt?.name ?? '';
|
||||
const dest = destSt?.name ?? '';
|
||||
const train = s.train?.name ?? s.train?.number ?? '';
|
||||
const dep = fmt(s.departureAt);
|
||||
const arr = fmt(s.arrivalAt);
|
||||
|
||||
@@ -386,7 +386,7 @@ export class TicketsService {
|
||||
let booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
tickets: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
@@ -400,7 +400,7 @@ export class TicketsService {
|
||||
booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef: ticket.bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
tickets: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
@@ -453,6 +453,18 @@ export class TicketsService {
|
||||
// Send notifications after successful boarding
|
||||
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
|
||||
|
||||
// Resolve user-selected segment rather than the full schedule route
|
||||
const _schedStops = (booking as any).schedule?.stopTimes ?? [];
|
||||
const _resolveStation = (id: string | null | undefined, fallback: any) => {
|
||||
if (id) {
|
||||
const found = _schedStops.find((st: any) => st.stationId === id)?.station;
|
||||
if (found) return found;
|
||||
}
|
||||
return fallback;
|
||||
};
|
||||
const boardingOrigin = _resolveStation((booking as any).originStationId, (booking as any).schedule?.originStation);
|
||||
const boardingDest = _resolveStation((booking as any).destinationStationId, (booking as any).schedule?.destinationStation);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||
@@ -461,7 +473,7 @@ export class TicketsService {
|
||||
ticketNumber: ticket.barcodePayload,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
||||
route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
|
||||
route: `${boardingOrigin?.name || 'N/A'} → ${boardingDest?.name || 'N/A'}`,
|
||||
seat: seatNumber,
|
||||
coach: coachNumber,
|
||||
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
|
||||
|
||||
@@ -81,20 +81,6 @@ export default function AuthCheckPage() {
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<Tooltip content={[
|
||||
'Saved passenger details',
|
||||
'View booking history',
|
||||
'Faster future bookings',
|
||||
]}>
|
||||
<button
|
||||
onClick={() => router.push('/login?redirect=/booking/passengers')}
|
||||
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 bg-primary hover:bg-primary/90 active:scale-[0.98] text-white font-semibold rounded-xl transition-all shadow-md shadow-primary/20"
|
||||
>
|
||||
<LogIn className="w-5 h-5 flex-shrink-0" />
|
||||
Sign in
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={[
|
||||
'No account required',
|
||||
'Quick checkout',
|
||||
@@ -102,12 +88,26 @@ export default function AuthCheckPage() {
|
||||
]}>
|
||||
<button
|
||||
onClick={() => router.push('/booking/passengers')}
|
||||
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 active:scale-[0.98] text-gray-800 dark:text-gray-100 font-semibold rounded-xl border border-gray-200 dark:border-gray-700 transition-all shadow-sm"
|
||||
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 bg-primary hover:bg-primary/90 active:scale-[0.98] text-white font-semibold rounded-xl transition-all shadow-md shadow-primary/20"
|
||||
>
|
||||
<UserPlus className="w-5 h-5 flex-shrink-0" />
|
||||
Continue as guest
|
||||
</button>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content={[
|
||||
'Saved passenger details',
|
||||
'View booking history',
|
||||
'Faster future bookings',
|
||||
]}>
|
||||
<button
|
||||
onClick={() => router.push('/login?redirect=/booking/passengers')}
|
||||
className="w-full flex items-center justify-center gap-2.5 px-5 py-3.5 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 active:scale-[0.98] text-gray-800 dark:text-gray-100 font-semibold rounded-xl border border-gray-200 dark:border-gray-700 transition-all shadow-sm"
|
||||
>
|
||||
<LogIn className="w-5 h-5 flex-shrink-0" />
|
||||
SignIn or Register
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
|
||||
@@ -993,7 +993,8 @@ function PassengersForm() {
|
||||
|
||||
const onInvalid = () => {
|
||||
// Sections still behind the Fayda verify screen stay collapsed here — they only expand
|
||||
// when the user explicitly clicks "Skip for now" / "Enter details manually".
|
||||
// when the user explicitly clicks "Enter details manually" (shown only when Fayda is
|
||||
// unavailable).
|
||||
setSubmitError('Please fix the highlighted errors before continuing.');
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user