This commit is contained in:
marshal
2026-09-08 22:34:56 +00:00
parent 54f76d4779
commit 37ef0f9cac
6 changed files with 158 additions and 0 deletions

View File

@@ -141,4 +141,66 @@ describe('BookingsRepository', () => {
expect(result.map((r) => r.booking.reference)).toEqual(['BK-2026-001116']);
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();
});
});
});

View File

@@ -822,6 +822,37 @@ export class BookingsRepository extends BaseRepository<Booking> {
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
* credit — the cut is settled (CREDIT_AVAILABLE), the credit is worth

View File

@@ -76,6 +76,8 @@ export interface TrainScheduleSummary {
id: string;
reference: 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;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
@@ -2010,6 +2012,23 @@ ${footer}
}
this.attachPaymentDrainEnds(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,
reference: schedule.reference ?? null,
trainNumber: schedule.trainNumber ?? null,
voyageNumber: schedule.voyageNumber ?? null,
status: schedule.status ?? null,
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
scheduledArrivalDate: schedule.scheduledArrivalDate?.toISOString() ?? null,