Update group booking and package

This commit is contained in:
Roba Boru
2026-08-27 22:54:59 +03:00
parent 5c2100e76d
commit 1603ff8211
35 changed files with 1170 additions and 377 deletions

View File

@@ -27,6 +27,13 @@ export class SearchTripsDto {
@ApiPropertyOptional({ example: '2026-06-20', description: 'Return date (YYYY-MM-DD) — required for ROUND_TRIP, must be after outbound date' })
@IsOptional() @IsDateString() returnDate?: string;
@ApiPropertyOptional({
example: 'PORTAL',
enum: ['PORTAL', 'GROUP_BOOKING'],
description: 'Calling surface. Omit or PORTAL for normal ticket search (default) — only sees schedules with isGroupBookingOnly=false. GROUP_BOOKING sees only schedules with isGroupBookingOnly=true — the two are an exclusive partition, not additive; each channel sees a disjoint set of schedules.',
})
@IsOptional() @IsEnum(['PORTAL', 'GROUP_BOOKING']) channel?: string;
}
export class AvailableDatesQueryDto {

View File

@@ -82,6 +82,10 @@ export class SearchService {
) {}
async searchTrips(dto: SearchTripsDto) {
// GROUP_BOOKING is the staff-only bulk-booking wizard's own calling surface — isGroupBookingOnly
// is an exclusive partition, not additive: this channel sees ONLY schedules explicitly created
// for group booking, and the normal ticket channel (the default, PORTAL) sees only the rest.
const forGroupBooking = dto.channel === "GROUP_BOOKING";
const [direct, transit] = await Promise.all([
this.searchSchedules(
dto.originStationId,
@@ -90,6 +94,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.searchTransitOptions(
dto.originStationId,
@@ -98,6 +103,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
]);
@@ -112,8 +118,9 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date),
this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking),
]);
return {
journeyType: "ONE_WAY",
@@ -133,6 +140,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
this.searchTransitOptions(
dto.destinationStationId,
@@ -141,6 +149,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
),
]);
@@ -172,6 +181,7 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
)
: Promise.resolve([]),
inbound.length === 0
@@ -182,13 +192,14 @@ export class SearchService {
dto.adultCount,
dto.childCount,
dto.nationality,
forGroupBooking,
)
: Promise.resolve([]),
outbound.length === 0
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date)
? this.classifyEmptySearch(dto.originStationId, dto.destinationStationId, dto.date, forGroupBooking)
: Promise.resolve(undefined),
inbound.length === 0
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate)
? this.classifyEmptySearch(dto.destinationStationId, dto.originStationId, returnDate, forGroupBooking)
: Promise.resolve(undefined),
]);
return {
@@ -223,6 +234,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
@@ -241,6 +253,10 @@ export class SearchService {
const baseWhere: Prisma.TrainScheduleWhereInput = {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Group Booking's search is exclusive, not additive: staff only ever see schedules
// explicitly created for group booking, never the normal passenger-facing ones, and the
// portal never sees group-only ones. Each channel is a strict partition of the other.
isGroupBookingOnly: forGroupBooking,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
};
@@ -310,6 +326,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
@@ -329,6 +346,8 @@ export class SearchService {
// statuses, not booking-closed signals (see comment on searchAlternatives' baseWhere).
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: date, lt: nextDay },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
@@ -363,6 +382,7 @@ export class SearchService {
originStationId: string,
destinationStationId: string,
dateStr: string,
forGroupBooking = false,
): Promise<Passenger.ISearchEmptyReason> {
const [origin, destination] = await Promise.all([
this.prisma.station.findUnique({ where: { id: originStationId }, select: { name: true } }),
@@ -393,6 +413,7 @@ export class SearchService {
select: {
status: true,
isPackageOnly: true,
isGroupBookingOnly: true,
departureAt: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
@@ -409,13 +430,20 @@ export class SearchService {
if (sameDayForPair.length === 0) return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
// 3. Schedules exist that date — narrow to ones that would otherwise be bookable
// (right status, not package-only, has at least one coach assigned).
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s));
// (right status, not package-only, not group-booking-only unless this IS a group-booking
// search, has at least one coach assigned).
const bookable = sameDayForPair.filter((s) => this.isBookableSchedule(s, forGroupBooking));
if (bookable.length === 0) {
if (sameDayForPair.every((s) => s.status === "CANCELLED"))
return withCode(Passenger.SearchEmptyReasonCode.Cancelled);
if (sameDayForPair.every((s) => s.isPackageOnly))
return withCode(Passenger.SearchEmptyReasonCode.PackageOnly);
// isGroupBookingOnly is an exclusive partition (see isBookableSchedule) — this same reason
// code covers both directions: the portal finding only group-reserved schedules, and Group
// Booking finding only normal ones (nothing set up for it on this date). The frontend picks
// the right copy per caller.
if (sameDayForPair.every((s) => s.isGroupBookingOnly !== forGroupBooking))
return withCode(Passenger.SearchEmptyReasonCode.GroupBookingOnly);
return withCode(Passenger.SearchEmptyReasonCode.NoScheduleOnDate);
}
@@ -483,11 +511,22 @@ export class SearchService {
/** Bounds the stop-time fallback scan — connectivity is a yes/no, not a survey. */
private readonly ROUTE_EXISTS_SCHEDULE_SCAN_LIMIT = 200;
/** Status/package/coach bookability only — ignores date, cutoff, and seat-level availability. */
private isBookableSchedule(s: { status: string; isPackageOnly: boolean; coachAssignments: { id: string }[] }): boolean {
/**
* Status/package/group-booking/coach bookability only — ignores date, cutoff, and seat-level
* availability. `forGroupBooking` defaults false so existing single-arg callers (e.g.
* getAvailableDates, the portal's calendar) keep hiding group-booking-only schedules.
* isGroupBookingOnly is an exclusive partition, not an additive one: a schedule is bookable
* for a given channel only when its flag exactly matches that channel (normal schedules for
* the portal, group-only schedules for Group Booking — never both from one channel).
*/
private isBookableSchedule(
s: { status: string; isPackageOnly: boolean; isGroupBookingOnly: boolean; coachAssignments: { id: string }[] },
forGroupBooking = false,
): boolean {
return (
(["SCHEDULED", "BOARDING", "EN_ROUTE"] as string[]).includes(s.status) &&
!s.isPackageOnly &&
s.isGroupBookingOnly === forGroupBooking &&
s.coachAssignments.length > 0
);
}
@@ -542,6 +581,7 @@ export class SearchService {
departureAt: true,
status: true,
isPackageOnly: true,
isGroupBookingOnly: true,
route: {
select: { checkinMinutesBefore: true, stops: { select: { stationId: true, checkinMinutesBefore: true } } },
},
@@ -581,6 +621,7 @@ export class SearchService {
adultCount: number,
childCount?: number,
nationality?: string,
forGroupBooking = false,
) {
// Real millisecond arithmetic, not string-padded day-of-month increment — the latter
// produces an invalid date (e.g. "2026-07-32") for any search on the last day of a month.
@@ -599,6 +640,8 @@ export class SearchService {
// BOARDING included alongside SCHEDULED — see comment on searchAlternatives' baseWhere.
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
// Exclusive partition — see the comment on searchAlternatives' baseWhere.
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: dayStart, lt: dayEnd },
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
@@ -609,6 +652,7 @@ export class SearchService {
where: {
status: { in: ["SCHEDULED", "BOARDING", "EN_ROUTE"] },
isPackageOnly: false,
isGroupBookingOnly: forGroupBooking,
departureAt: { gte: dayStart, lt: leg2WindowEnd },
coachAssignments: { some: {} },
},