mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
feat: ( reschedule ) manage reschedule policies from a Master Data page with full CRUD
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import { Body, Controller, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Patch, Post, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { JwtGuard } from '../../common/jwt.guard';
|
||||
import { PassengerAdmin, PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
import { RescheduleService } from './reschedule.service';
|
||||
import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto';
|
||||
import {
|
||||
CreateReschedulePolicyDto,
|
||||
CreateRescheduleDto,
|
||||
RescheduleQuoteDto,
|
||||
UpdateReschedulePolicyDto,
|
||||
} from './reschedule.dto';
|
||||
|
||||
@ApiTags('Reschedule')
|
||||
@Controller()
|
||||
@@ -14,11 +19,27 @@ export class RescheduleController {
|
||||
@Get('reschedule/policies')
|
||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Reschedule policy per coach type (fare class)' })
|
||||
@ApiOperation({ summary: 'Every reschedule policy, each with its coach type (fare class)' })
|
||||
listPolicies() {
|
||||
return this.service.listPolicies();
|
||||
}
|
||||
|
||||
@Get('reschedule/policies/available-coach-types')
|
||||
@PassengerStaff(PASSENGER_PERMS.bookings.view)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Coach types that do not have a reschedule policy yet (add-dialog dropdown)' })
|
||||
listUnconfiguredCoachTypes() {
|
||||
return this.service.listUnconfiguredCoachTypes();
|
||||
}
|
||||
|
||||
@Post('reschedule/policies')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a reschedule policy for a coach type (admin)' })
|
||||
createPolicy(@Req() req: any, @Body() dto: CreateReschedulePolicyDto) {
|
||||
return this.service.createPolicy(dto, req.user?.id);
|
||||
}
|
||||
|
||||
@Patch('reschedule/policies/:coachTypeId')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -27,6 +48,14 @@ export class RescheduleController {
|
||||
return this.service.updatePolicy(coachTypeId, dto, req.user?.id);
|
||||
}
|
||||
|
||||
@Delete('reschedule/policies/:coachTypeId')
|
||||
@PassengerAdmin()
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a reschedule policy — rescheduling is then refused for that fare class (admin)' })
|
||||
deletePolicy(@Req() req: any, @Param('coachTypeId') coachTypeId: string) {
|
||||
return this.service.deletePolicy(coachTypeId, req.user?.id);
|
||||
}
|
||||
|
||||
@Get('bookings/:bookingRef/reschedule')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -45,6 +45,13 @@ export class UpdateReschedulePolicyDto {
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
/** Same fields as the update DTO, plus the fare class the new policy attaches to. */
|
||||
export class CreateReschedulePolicyDto extends UpdateReschedulePolicyDto {
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'CoachType the policy applies to (one policy per fare class)' })
|
||||
@IsString()
|
||||
coachTypeId: string;
|
||||
}
|
||||
|
||||
export class RescheduleQuoteDto {
|
||||
@ApiPropertyOptional({ example: 1, description: '1 = outbound (default), 2 = return leg of a round trip' })
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(2)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -19,11 +20,33 @@ import { TicketsService } from '../tickets/tickets.service';
|
||||
import { PaymentsService } from '../payments/payments.service';
|
||||
import { SupplementaryChargesService } from '../payments/supplementary-charges.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { CreateRescheduleDto, RescheduleQuoteDto, UpdateReschedulePolicyDto } from './reschedule.dto';
|
||||
import {
|
||||
CreateReschedulePolicyDto,
|
||||
CreateRescheduleDto,
|
||||
RescheduleQuoteDto,
|
||||
UpdateReschedulePolicyDto,
|
||||
} from './reschedule.dto';
|
||||
|
||||
export const RESCHEDULE_CHARGE_REASON = 'RESCHEDULE';
|
||||
export const SUPPLEMENTARY_CHARGE_PAID_EVENT = 'supplementary-charge.paid';
|
||||
|
||||
/**
|
||||
* Coaches nobody buys a seat in, so they can never carry a reschedule policy.
|
||||
*
|
||||
* Matched loosely on purpose: `CoachType.type` is documented as 'passenger' | 'sleeper' |
|
||||
* 'dining' | 'baggage', but the live data holds display labels ('Dining Coach ', trailing space
|
||||
* included). A `notIn: ['dining','baggage']` filter therefore matches nothing and offers the
|
||||
* dining coach as a fare class. This mirrors the portal's own test (`/dining|dpc/i`,
|
||||
* booking/seats/page.tsx) and checks `code` as well as `type`.
|
||||
*/
|
||||
const NON_FARE_COACH_TERMS = ['dining', 'dpc', 'baggage'];
|
||||
const NOT_A_FARE_CLASS = {
|
||||
NOT: NON_FARE_COACH_TERMS.flatMap((term) => [
|
||||
{ type: { contains: term, mode: 'insensitive' as const } },
|
||||
{ code: { contains: term, mode: 'insensitive' as const } },
|
||||
]),
|
||||
};
|
||||
|
||||
type PolicyNumbers = {
|
||||
feePercent: number;
|
||||
feeMinMinor: number;
|
||||
@@ -90,18 +113,62 @@ export class RescheduleService {
|
||||
|
||||
// ── Policy admin ─────────────────────────────────────────────────────────
|
||||
|
||||
/** The policies that exist, each carrying its fare class. A coach type with no policy is simply absent. */
|
||||
async listPolicies() {
|
||||
const coachTypes = await this.prisma.coachType.findMany({
|
||||
where: { type: { notIn: ['dining', 'baggage'] } },
|
||||
include: { reschedulePolicy: true },
|
||||
return this.prisma.reschedulePolicy.findMany({
|
||||
include: { coachType: { select: { id: true, code: true, name: true, type: true } } },
|
||||
orderBy: { coachType: { code: 'asc' } },
|
||||
});
|
||||
}
|
||||
|
||||
/** Fare classes still available to attach a policy to — the "add" dialog's dropdown. */
|
||||
async listUnconfiguredCoachTypes() {
|
||||
return this.prisma.coachType.findMany({
|
||||
where: { ...NOT_A_FARE_CLASS, reschedulePolicy: { is: null } },
|
||||
select: { id: true, code: true, name: true, type: true },
|
||||
orderBy: { code: 'asc' },
|
||||
});
|
||||
return coachTypes.map((ct) => ({
|
||||
coachTypeId: ct.id,
|
||||
code: ct.code,
|
||||
name: ct.name,
|
||||
policy: ct.reschedulePolicy,
|
||||
}));
|
||||
}
|
||||
|
||||
async createPolicy(dto: CreateReschedulePolicyDto, actorId?: string) {
|
||||
const { coachTypeId, ...values } = dto;
|
||||
const coachType = await this.prisma.coachType.findUnique({ where: { id: coachTypeId } });
|
||||
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||
if (NON_FARE_COACH_TERMS.some((t) => `${coachType.type} ${coachType.code}`.toLowerCase().includes(t))) {
|
||||
throw new BadRequestException(`${coachType.code} is not a fare class — no seats are sold in it.`);
|
||||
}
|
||||
const existing = await this.prisma.reschedulePolicy.findUnique({ where: { coachTypeId } });
|
||||
if (existing) throw new ConflictException(`${coachType.code} already has a reschedule policy — edit it instead.`);
|
||||
|
||||
const policy = await this.prisma.reschedulePolicy.create({ data: { coachTypeId, ...values } });
|
||||
await this.auditService.log({
|
||||
userId: actorId,
|
||||
action: AUDIT_ACTIONS.CREATE,
|
||||
entityType: AUDIT_ENTITIES.ReschedulePolicy,
|
||||
entityId: policy.id,
|
||||
newData: { coachTypeCode: coachType.code, ...values },
|
||||
});
|
||||
return policy;
|
||||
}
|
||||
|
||||
async deletePolicy(coachTypeId: string, actorId?: string) {
|
||||
const policy = await this.prisma.reschedulePolicy.findUnique({
|
||||
where: { coachTypeId },
|
||||
include: { coachType: { select: { code: true } } },
|
||||
});
|
||||
if (!policy) throw new NotFoundException('Reschedule policy not found');
|
||||
|
||||
await this.prisma.reschedulePolicy.delete({ where: { coachTypeId } });
|
||||
await this.auditService.log({
|
||||
userId: actorId,
|
||||
action: AUDIT_ACTIONS.DELETE,
|
||||
entityType: AUDIT_ENTITIES.ReschedulePolicy,
|
||||
entityId: policy.id,
|
||||
oldData: policy,
|
||||
});
|
||||
// Rescheduling for this fare class is now refused outright (legBlockers treats a missing
|
||||
// policy the same as an inactive one), which is the intended effect of deleting it.
|
||||
return { deleted: true, coachTypeId };
|
||||
}
|
||||
|
||||
async updatePolicy(coachTypeId: string, dto: UpdateReschedulePolicyDto, actorId?: string) {
|
||||
|
||||
Reference in New Issue
Block a user