mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-09 05:58:18 +00:00
changes
This commit is contained in:
@@ -141,4 +141,66 @@ describe('BookingsRepository', () => {
|
|||||||
expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-001116']);
|
expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-001116']);
|
||||||
expect(result[0].ft20Quantity).toBe(1);
|
expect(result[0].ft20Quantity).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('findLatestDocumentSubmissionDates', () => {
|
||||||
|
function mockGroupedQueryBuilder(rows: unknown[]) {
|
||||||
|
return {
|
||||||
|
innerJoin: jest.fn().mockReturnThis(),
|
||||||
|
select: jest.fn().mockReturnThis(),
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
groupBy: jest.fn().mockReturnThis(),
|
||||||
|
getRawMany: jest.fn().mockResolvedValue(rows),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it('maps each booking to its newest document upload', async () => {
|
||||||
|
const latest = new Date('2026-09-08T14:22:00.000Z');
|
||||||
|
const qb = mockGroupedQueryBuilder([
|
||||||
|
{ bookingId: 'b1', submittedAt: latest },
|
||||||
|
{ bookingId: 'b2', submittedAt: new Date('2026-09-06T09:10:00.000Z') },
|
||||||
|
]);
|
||||||
|
dataSource.getRepository.mockReturnValue({
|
||||||
|
createQueryBuilder: () => qb,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await bookingsRepository.findLatestDocumentSubmissionDates([
|
||||||
|
'b1',
|
||||||
|
'b2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.get('b1')).toEqual(latest);
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
// Soft-deleted review rows must not resurrect a superseded date.
|
||||||
|
expect(
|
||||||
|
qb.andWhere.mock.calls.some(([clause]) =>
|
||||||
|
String(clause).includes('deleted_at IS NULL'),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits bookings whose review rows have no backing file', async () => {
|
||||||
|
// A required slot the customer never filled has a review row but no file,
|
||||||
|
// so the aggregate is null — such a booking has submitted nothing and
|
||||||
|
// must not appear with a bogus date.
|
||||||
|
const qb = mockGroupedQueryBuilder([{ bookingId: 'b1', submittedAt: null }]);
|
||||||
|
dataSource.getRepository.mockReturnValue({
|
||||||
|
createQueryBuilder: () => qb,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await bookingsRepository.findLatestDocumentSubmissionDates([
|
||||||
|
'b1',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(result.has('b1')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query when there are no bookings', async () => {
|
||||||
|
const result = await bookingsRepository.findLatestDocumentSubmissionDates([]);
|
||||||
|
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
expect(dataSource.getRepository).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -822,6 +822,37 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
|||||||
return new Set(rows.map((r) => r.bookingId));
|
return new Set(rows.map((r) => r.bookingId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Latest customer clearance-document submission per booking, for a whole
|
||||||
|
* queue page in one query. Every customer upload (fixed slot or ad-hoc
|
||||||
|
* `custom_*`) writes a review row pointing at its stored file, and GL's own
|
||||||
|
* output documents never take that path — so the newest backing file behind a
|
||||||
|
* review row IS the customer's newest submission. A re-upload answering a
|
||||||
|
* query supersedes the file, moving the date forward with it.
|
||||||
|
*/
|
||||||
|
async findLatestDocumentSubmissionDates(
|
||||||
|
bookingIds: string[],
|
||||||
|
): Promise<Map<string, Date>> {
|
||||||
|
if (bookingIds.length === 0) return new Map();
|
||||||
|
const rows = (await this.dataSource
|
||||||
|
.getRepository(BookingDocumentReview)
|
||||||
|
.createQueryBuilder('r')
|
||||||
|
.innerJoin(FileRecord, 'f', 'f.id = r.file_record_id')
|
||||||
|
.select('r.booking_id', 'bookingId')
|
||||||
|
.addSelect('MAX(f.created_at)', 'submittedAt')
|
||||||
|
.where('r.booking_id IN (:...bookingIds)', { bookingIds })
|
||||||
|
.andWhere('r.deleted_at IS NULL')
|
||||||
|
.groupBy('r.booking_id')
|
||||||
|
.getRawMany()) as Array<{ bookingId: string; submittedAt: Date | null }>;
|
||||||
|
return new Map(
|
||||||
|
rows
|
||||||
|
.filter((r): r is { bookingId: string; submittedAt: Date } =>
|
||||||
|
Boolean(r.submittedAt),
|
||||||
|
)
|
||||||
|
.map((r) => [r.bookingId, r.submittedAt]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bookings among `bookingIds` that hold a redeemable wagon-cancellation
|
* Bookings among `bookingIds` that hold a redeemable wagon-cancellation
|
||||||
* credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth
|
* credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ export interface TrainScheduleSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
reference: string | null;
|
reference: string | null;
|
||||||
trainNumber: string | null;
|
trainNumber: string | null;
|
||||||
|
/** Voyage (sailing) number for THIS departure — the schedule's own, not the train's. */
|
||||||
|
voyageNumber: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
scheduledDepartureDate: string | null;
|
scheduledDepartureDate: string | null;
|
||||||
scheduledArrivalDate: string | null;
|
scheduledArrivalDate: string | null;
|
||||||
@@ -2010,6 +2012,23 @@ ${footer}
|
|||||||
}
|
}
|
||||||
this.attachPaymentDrainEnds(bookings);
|
this.attachPaymentDrainEnds(bookings);
|
||||||
await this.attachShippingLineCompanies(bookings);
|
await this.attachShippingLineCompanies(bookings);
|
||||||
|
await this.attachDocumentSubmissionDates(bookings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stamp each row with the newest customer clearance-document submission, for
|
||||||
|
* the "Documents submitted" column on the clearance worklists. Null on a
|
||||||
|
* booking whose customer has not uploaded anything yet.
|
||||||
|
*/
|
||||||
|
private async attachDocumentSubmissionDates(bookings: Booking[]): Promise<void> {
|
||||||
|
const ids = bookings.map((b) => b.id);
|
||||||
|
if (!ids.length) return;
|
||||||
|
const byBooking =
|
||||||
|
await this.bookingsRepository.findLatestDocumentSubmissionDates(ids);
|
||||||
|
for (const b of bookings) {
|
||||||
|
(b as Booking & { documentsSubmittedAt?: string | null })
|
||||||
|
.documentsSubmittedAt = byBooking.get(b.id)?.toISOString() ?? null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2558,6 +2577,7 @@ ${footer}
|
|||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
trainNumber: schedule.trainNumber ?? null,
|
trainNumber: schedule.trainNumber ?? null,
|
||||||
|
voyageNumber: schedule.voyageNumber ?? null,
|
||||||
status: schedule.status ?? null,
|
status: schedule.status ?? null,
|
||||||
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
|
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
|
||||||
scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null,
|
scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
import { PageContainer, PageHeader, KpiStrip } from "@/components/page";
|
||||||
@@ -285,6 +286,11 @@ export default function BookingRequestDetailPage() {
|
|||||||
|
|
||||||
const hasSignableContract = booking.isGovernment && booking.contractSummary;
|
const hasSignableContract = booking.isGovernment && booking.contractSummary;
|
||||||
|
|
||||||
|
// The voyage (sailing) number of the schedule this booking rides. Null until
|
||||||
|
// a train is allocated (or requested at day-commit), and on legacy schedules
|
||||||
|
// created before voyage numbers were captured.
|
||||||
|
const voyageNumber = booking.trainScheduleSummary?.voyageNumber ?? null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -296,6 +302,13 @@ export default function BookingRequestDetailPage() {
|
|||||||
title={booking.reference}
|
title={booking.reference}
|
||||||
meta={
|
meta={
|
||||||
<Group gap={6} wrap="wrap">
|
<Group gap={6} wrap="wrap">
|
||||||
|
{voyageNumber ? (
|
||||||
|
// Sits at title weight beside the reference: yards and customs
|
||||||
|
// quote the voyage, so it is the second thing staff look for.
|
||||||
|
<Title order={2} c="dimmed" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
· {voyageNumber}
|
||||||
|
</Title>
|
||||||
|
) : null}
|
||||||
<BookingStatusBadge status={booking.status} />
|
<BookingStatusBadge status={booking.status} />
|
||||||
<BookingPriorityBadge score={booking.priorityScore} />
|
<BookingPriorityBadge score={booking.priorityScore} />
|
||||||
{booking.schedulingStatus ? (
|
{booking.schedulingStatus ? (
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { BookingStatusBadge } from "@/components/bookings/BookingStatusBadge";
|
|||||||
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
import { ContractReferenceLink } from "@/components/bookings/ContractReferenceLink";
|
||||||
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
import { bookingTable } from "@/components/bookings/booking-ui.styles";
|
||||||
import { PageContainer, PageHeader } from "@/components/page";
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { formatDateTime } from "@/lib/format";
|
||||||
import { bookingsService, type BookingListFilter } from "@/services/bookings.service";
|
import { bookingsService, type BookingListFilter } from "@/services/bookings.service";
|
||||||
import type { BookingDetail } from "@/types/booking";
|
import type { BookingDetail } from "@/types/booking";
|
||||||
import {
|
import {
|
||||||
@@ -210,6 +211,28 @@ export default function ClearanceDocumentsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "documentsSubmittedAt",
|
||||||
|
header: () => (
|
||||||
|
<span className={bookingTable.headerCell}>Documents submitted</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const submittedAt = row.original.documentsSubmittedAt;
|
||||||
|
return submittedAt ? (
|
||||||
|
<Text size="sm">{formatDateTime(submittedAt)}</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
Not submitted
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// The table caps cells at 100px; a date needs its own width or it wraps
|
||||||
|
// to three lines.
|
||||||
|
meta: {
|
||||||
|
headerClassName: "min-w-[9rem]",
|
||||||
|
cellClassName: "min-w-[9rem]",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: "status",
|
id: "status",
|
||||||
size: 200,
|
size: 200,
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export interface BookingTrainScheduleSummary {
|
|||||||
id: string;
|
id: string;
|
||||||
reference: string | null;
|
reference: string | null;
|
||||||
trainNumber: string | null;
|
trainNumber: string | null;
|
||||||
|
/** Voyage (sailing) number for THIS departure — the schedule's own, not the train's. */
|
||||||
|
voyageNumber?: string | null;
|
||||||
status: string | null;
|
status: string | null;
|
||||||
scheduledDepartureDate: string | null;
|
scheduledDepartureDate: string | null;
|
||||||
scheduledArrivalDate: string | null;
|
scheduledArrivalDate: string | null;
|
||||||
@@ -252,6 +254,13 @@ export interface BookingDetail {
|
|||||||
allDocsApproved?: boolean;
|
allDocsApproved?: boolean;
|
||||||
/** ET clearance queue: a customer document is PENDING or QUERIED. */
|
/** ET clearance queue: a customer document is PENDING or QUERIED. */
|
||||||
hasDocumentsAwaitingReview?: boolean;
|
hasDocumentsAwaitingReview?: boolean;
|
||||||
|
/**
|
||||||
|
* Newest customer clearance-document upload on this booking, attached by the
|
||||||
|
* LIST endpoint for the clearance worklists' "Documents submitted" column. A
|
||||||
|
* re-upload answering a query moves it forward; null until the customer has
|
||||||
|
* submitted anything.
|
||||||
|
*/
|
||||||
|
documentsSubmittedAt?: string | null;
|
||||||
/**
|
/**
|
||||||
* ET clearance queue: id of an unspent wagon-cancellation credit on this
|
* ET clearance queue: id of an unspent wagon-cancellation credit on this
|
||||||
* booking (CREDIT_AVAILABLE, worth > 0, not yet rebooked). Null when there is
|
* booking (CREDIT_AVAILABLE, worth > 0, not yet rebooked). Null when there is
|
||||||
|
|||||||
Reference in New Issue
Block a user