Luggage processing,, schedule times and tariff related updates

This commit is contained in:
Stephanos A
2026-07-09 11:00:56 +03:00
parent 9825d37e66
commit 554756a116
15 changed files with 365 additions and 196 deletions

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { AgentsService } from './agents.service';
import { CreateAgentDto, CreateAgentBookingDto, OpenShiftDto, CloseShiftDto } from './agents.dto';
@@ -34,6 +34,12 @@ export class AgentsController {
updateAgent(@Param('id') id: string, @Body() dto: Partial<CreateAgentDto> & { active?: boolean }) {
return this.service.updateAgent(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete agent profile' })
deleteAgent(@Param('id') id: string) {
return this.service.deleteAgent(id);
}
@Post('bookings')
@ApiOperation({ summary: 'Create agent booking with cash payment' })
createBooking(@Body() dto: CreateAgentBookingDto) {

View File

@@ -210,4 +210,11 @@ export class AgentsService {
},
});
}
async deleteAgent(id: string) {
const agent = await this.prisma.agent.findUnique({ where: { id } });
if (!agent) throw new NotFoundException('Agent not found');
await this.prisma.agent.delete({ where: { id } });
return { deleted: true };
}
}

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, Request, UseGuards } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { IsInt, IsPositive, IsString } from 'class-validator';
import { ExcessBaggageService } from './excess-baggage.service';
@@ -26,7 +26,8 @@ export class ExcessBaggageAgentController {
@Post()
@ApiOperation({ summary: 'Log excess baggage charge and optionally collect cash' })
logCharge(@Body() dto: LogExcessBaggageDto) {
logCharge(@Request() req: any, @Body() dto: LogExcessBaggageDto) {
dto.agentId = req.user?.id ?? req.user?.sub ?? dto.agentId;
return this.service.logCharge(dto);
}
@@ -50,24 +51,6 @@ export class ExcessBaggageAgentController {
});
}
@Get(':id')
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
getCharge(@Param('id') id: string) {
return this.service.getCharge(id);
}
@Post(':id/resend')
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);
}
@Patch(':id/waive')
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
@Get('allowances')
@ApiOperation({ summary: 'List all baggage allowance rules' })
getAllowances() {
@@ -92,6 +75,24 @@ export class ExcessBaggageAgentController {
return this.service.deleteAllowance(id);
}
@Get(':id')
@ApiOperation({ summary: 'Get a single charge by ID (agent polling)' })
getCharge(@Param('id') id: string) {
return this.service.getCharge(id);
}
@Post(':id/resend')
@ApiOperation({ summary: 'Resend payment link (extends expiry by 30 min)' })
resendLink(@Param('id') id: string) {
return this.service.resendLink(id);
}
@Patch(':id/waive')
@ApiOperation({ summary: 'Waive a charge (supervisor only)' })
waiveCharge(@Param('id') id: string, @Body() dto: WaiveChargeDto) {
return this.service.waiveCharge(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
deleteCharge(@Param('id') id: string) {

View File

@@ -3,7 +3,8 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class LogExcessBaggageDto {
@ApiProperty({ example: 'booking-uuid' }) @IsString() bookingId: string;
@ApiProperty({ example: 'agent-uuid' }) @IsString() agentId: string;
@ApiPropertyOptional({ example: 'agent-uuid', description: 'Injected from IAM token; optional override' })
@IsOptional() @IsString() agentId?: string;
@ApiProperty({ example: 7, description: 'Excess weight in kg above the free allowance' })
@IsInt() @IsPositive() excessWeightKg: number;
@ApiPropertyOptional({ description: 'Collect cash now instead of sending a payment link' })

View File

@@ -75,7 +75,7 @@ export class ExcessBaggageService {
const charge = await this.prisma.excessBaggageCharge.create({
data: {
bookingId: dto.bookingId,
agentId: dto.agentId,
agentId: dto.agentId ?? '',
excessWeightKg: dto.excessWeightKg,
feePerKgMinor,
totalMinor,

View File

@@ -154,43 +154,54 @@ export class SearchService {
) {
const [y, m, d] = dateStr.split('-').map(Number);
const requestedDate = new Date(y, m - 1, d, 0, 0, 0, 0);
const now = new Date();
const daysBefore = Math.min(7, Math.floor(requestedDate.getTime() / 86_400_000));
const daysAfter = 14 - daysBefore;
const windowStart = new Date(requestedDate);
windowStart.setDate(windowStart.getDate() - daysBefore);
if (windowStart < now) windowStart.setTime(now.getTime());
const windowEnd = new Date(requestedDate);
windowEnd.setDate(windowEnd.getDate() + daysAfter + 1);
const totalPassengers = adultCount + (childCount ?? 0);
const requestedNextDay = new Date(y, m - 1, d + 1, 0, 0, 0, 0);
const now = new Date();
const totalPassengers = adultCount + (childCount ?? 0);
const NEEDED = 3;
const schedules = await this.prisma.trainSchedule.findMany({
where: {
status: 'SCHEDULED',
isPackageOnly: false,
OR: [
{ departureAt: { gte: windowStart, lt: requestedDate } },
{ departureAt: { gte: requestedNextDay < now ? now : requestedNextDay, lt: windowEnd } },
],
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
},
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
});
const baseWhere = {
status: 'SCHEDULED',
isPackageOnly: false,
stopTimes: { some: { stationId: originStationId } },
coachAssignments: { some: {} },
} as const;
const results = await Promise.all(
schedules.map(schedule =>
this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality)
)
);
return results.filter((r): r is NonNullable<typeof r> => !!r && r.hasAvailability);
// Fetch candidates before and after in parallel; take more than needed to
// account for routes that don't serve the destination or have no availability.
const FETCH_LIMIT = NEEDED * 5;
const [beforeCandidates, afterCandidates] = await Promise.all([
this.prisma.trainSchedule.findMany({
where: { ...baseWhere, departureAt: { gte: now < requestedDate ? now : new Date(0), lt: requestedDate } },
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'desc' },
take: FETCH_LIMIT,
}),
this.prisma.trainSchedule.findMany({
where: { ...baseWhere, departureAt: { gte: requestedNextDay > now ? requestedNextDay : now } },
include: SCHEDULE_INCLUDE,
orderBy: { departureAt: 'asc' },
take: FETCH_LIMIT,
}),
]);
const pickN = async (candidates: typeof beforeCandidates, limit: number) => {
const out: NonNullable<Awaited<ReturnType<typeof this.buildScheduleResult>>>[] = [];
for (const schedule of candidates) {
if (out.length >= limit) break;
const r = await this.buildScheduleResult(schedule as any, originStationId, destinationStationId, totalPassengers, nationality);
if (r?.hasAvailability) out.push(r);
}
return out;
};
const [before, after] = await Promise.all([
pickN(beforeCandidates, NEEDED),
pickN(afterCandidates, NEEDED),
]);
// before was fetched desc (closest first); reverse so result is chronological
return [...before.reverse(), ...after];
}
private async searchSchedules(
@@ -415,7 +426,7 @@ export class SearchService {
}
}
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass);
const coachTypes = this.buildCoachTypeDetails(schedule, faresByClass, nationality);
const legDepartureAt = originStop.plannedDepartureAt ?? schedule.departureAt;
const legArrivalAt = destStop.plannedArrivalAt ?? schedule.arrivalAt;
@@ -638,6 +649,10 @@ export class SearchService {
): Promise<Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>> {
const displayCurrency = resolveCurrencyFromNationality(nationality);
const nationalityUpper = (nationality ?? '').toUpperCase();
const nationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
// Collect seat class IDs from the schedule include for the ID set,
// but fetch fresh records from DB so updated baseFareMinor is always current
const seatClassIdSet = new Set<string>();
@@ -647,7 +662,14 @@ export class SearchService {
}
}
const freshSeatClasses = await this.prisma.seatClass.findMany({
where: { id: { in: Array.from(seatClassIdSet) }, isActive: true },
where: {
id: { in: Array.from(seatClassIdSet) },
isActive: true,
OR: [
{ nationalityType: null },
{ nationalityType: nationalityType },
],
},
});
const seatClassMap = new Map(freshSeatClasses.map(sc => [sc.id, sc]));
const seatClasses = freshSeatClasses.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
@@ -725,6 +747,7 @@ export class SearchService {
private buildCoachTypeDetails(
schedule: ScheduleWithIncludes,
faresByClass: Array<{ seatClassName: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number }>,
nationality?: string,
): Array<{
coachTypeId: string;
coachTypeName: string;
@@ -749,8 +772,16 @@ export class SearchService {
});
}
const nationalityUpper = (nationality ?? '').toUpperCase();
const resolvedNationalityType = (nationalityUpper === 'ETHIOPIAN' || nationalityUpper === 'DJIBOUTIAN')
? 'LOCAL' : 'INTERNATIONAL';
const entry = coachTypeMap.get(coachType.id)!;
coachType.seatClasses?.forEach((sc: any) => entry.classNames.add(sc.name));
coachType.seatClasses?.forEach((sc: any) => {
// Exclude classes that belong to the wrong nationality type
if (sc.nationalityType && sc.nationalityType !== resolvedNationalityType) return;
if (faresByClass.some(f => f.seatClassName === sc.name)) entry.classNames.add(sc.name);
});
}
const result = [];
@@ -769,6 +800,7 @@ export class SearchService {
.filter((c): c is { name: string; baseFareMinor: number; displayCurrency: Currency; displayAmountMinor: number } => c !== null)
.sort((a, b) => a.baseFareMinor - b.baseFareMinor);
if (classes.length === 0) continue;
result.push({
coachTypeId: coachType.id,
coachTypeName: coachType.name,