mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Boarding, payment methods, journey direction on seat hold, and more updates
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
import { Body, Controller, Get, Post, Put, Delete, Param, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { ConfigurableFareService } from './configurable-fare.service';
|
||||
import {
|
||||
CreateFareConfigurationDto,
|
||||
UpdateFareConfigurationDto,
|
||||
FareTestScenarioDto,
|
||||
FareCalculationResultDto,
|
||||
MigrateLegacyDto,
|
||||
CreateNewFormulaDto,
|
||||
ToggleFeatureDto
|
||||
} from './configurable-fare.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-configurations')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class ConfigurableFareController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all fare configurations' })
|
||||
@ApiResponse({ status: 200, description: 'List of all configurations with summary counts' })
|
||||
async getAllConfigurations() {
|
||||
return this.service.getAllConfigurations();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create new fare configuration' })
|
||||
@ApiResponse({ status: 201, description: 'Configuration created successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid configuration data' })
|
||||
async createConfiguration(
|
||||
@Body() dto: CreateFareConfigurationDto,
|
||||
@Request() req: any
|
||||
) {
|
||||
const createdBy = req.user?.id || req.user?.sub;
|
||||
return this.service.createConfiguration(dto, createdBy);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get configuration details' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration details with all rules' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async getConfigurationById(@Param('id') id: string) {
|
||||
return this.service.getConfigurationById(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration updated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async updateConfiguration(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateFareConfigurationDto,
|
||||
@Request() req: any
|
||||
) {
|
||||
const updatedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.updateConfiguration(id, dto, updatedBy);
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@ApiOperation({ summary: 'Activate configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration activated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async activateConfiguration(@Param('id') id: string, @Request() req: any) {
|
||||
const activatedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.activateConfiguration(id, activatedBy);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration deleted successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
@ApiResponse({ status: 409, description: 'Cannot delete active configuration' })
|
||||
async deleteConfiguration(@Param('id') id: string, @Request() req: any) {
|
||||
const deletedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.deleteConfiguration(id, deletedBy);
|
||||
}
|
||||
|
||||
@Post(':id/test')
|
||||
@ApiOperation({ summary: 'Test fare calculation with configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, type: FareCalculationResultDto, description: 'Fare calculation result' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid test scenario or missing rate rules' })
|
||||
async testConfiguration(
|
||||
@Param('id') id: string,
|
||||
@Body() scenario: FareTestScenarioDto
|
||||
): Promise<FareCalculationResultDto> {
|
||||
return this.service.testConfiguration(id, scenario);
|
||||
}
|
||||
|
||||
@Get(':id/audit')
|
||||
@ApiOperation({ summary: 'Get configuration audit trail' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Audit trail entries' })
|
||||
async getAuditTrail(@Param('id') id: string) {
|
||||
return this.service.getAuditTrail(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-migration')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class FareMigrationController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Post('migrate-legacy')
|
||||
@ApiOperation({ summary: 'Migrate existing fare rules to configurable system' })
|
||||
@ApiResponse({ status: 200, description: 'Migration completed successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Migration failed' })
|
||||
async migrateLegacySystem(@Body() dto: MigrateLegacyDto) {
|
||||
return this.service.migrateLegacySystem(dto);
|
||||
}
|
||||
|
||||
@Post('create-new-formula')
|
||||
@ApiOperation({ summary: 'Create new formula configuration with defaults' })
|
||||
@ApiResponse({ status: 201, description: 'New formula configuration created' })
|
||||
async createNewFormulaConfiguration(@Body() dto: CreateNewFormulaDto) {
|
||||
return this.service.createNewFormulaConfiguration(dto);
|
||||
}
|
||||
|
||||
@Post('complete-setup')
|
||||
@ApiOperation({
|
||||
summary: 'Complete system setup (migrate + create + activate)',
|
||||
description: 'Performs full system migration and setup in one operation'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'System setup completed successfully' })
|
||||
async completeSetup(@Body() body: { activateNewFormula?: boolean; enableFeature?: boolean }) {
|
||||
// Step 1: Migrate legacy system
|
||||
const migrationResult = await this.service.migrateLegacySystem({ dryRun: false });
|
||||
|
||||
// Step 2: Create new formula configuration
|
||||
const newConfig = await this.service.createNewFormulaConfiguration({
|
||||
name: 'Default System Configuration',
|
||||
description: 'System-generated configuration with optimal defaults',
|
||||
activateImmediately: body.activateNewFormula !== false
|
||||
});
|
||||
|
||||
// Step 3: Enable feature flag if requested
|
||||
if (body.enableFeature) {
|
||||
await this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: true,
|
||||
config: { rollout_percentage: 100 }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
migration: migrationResult,
|
||||
newConfiguration: newConfig,
|
||||
featureEnabled: body.enableFeature || false,
|
||||
message: 'System setup completed successfully'
|
||||
};
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Get migration and setup status' })
|
||||
@ApiResponse({ status: 200, description: 'Current system status' })
|
||||
async getStatus() {
|
||||
const featureStatus = await this.service.getFeatureStatus('USE_CONFIGURABLE_FARES');
|
||||
const configurations = await this.service.getAllConfigurations();
|
||||
|
||||
const activeConfig = (configurations as any[]).find(config => config.is_active);
|
||||
|
||||
return {
|
||||
configurableFaresEnabled: featureStatus.enabled,
|
||||
rolloutPercentage: featureStatus.config?.rollout_percentage || 0,
|
||||
totalConfigurations: (configurations as any[]).length,
|
||||
activeConfiguration: activeConfig?.id || null,
|
||||
activeConfigurationName: activeConfig?.name || null,
|
||||
systemReady: featureStatus.enabled && !!activeConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-configurations/system')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class FareSystemController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Get('feature-status')
|
||||
@ApiOperation({ summary: 'Check configurable fares feature status' })
|
||||
@ApiQuery({ name: 'feature', required: false, description: 'Feature name (defaults to USE_CONFIGURABLE_FARES)' })
|
||||
@ApiResponse({ status: 200, description: 'Feature status retrieved' })
|
||||
async getFeatureStatus(@Query('feature') featureName = 'USE_CONFIGURABLE_FARES') {
|
||||
return this.service.getFeatureStatus(featureName);
|
||||
}
|
||||
|
||||
@Post('toggle-feature')
|
||||
@ApiOperation({ summary: 'Enable or disable configurable fares system' })
|
||||
@ApiResponse({ status: 200, description: 'Feature toggled successfully' })
|
||||
async toggleFeature(@Body() dto: ToggleFeatureDto) {
|
||||
return this.service.toggleFeature(dto);
|
||||
}
|
||||
|
||||
@Post('enable-configurable-fares')
|
||||
@ApiOperation({
|
||||
summary: 'Enable configurable fares with rollout percentage',
|
||||
description: 'Quick endpoint to enable the configurable fares feature'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Configurable fares enabled successfully' })
|
||||
async enableConfigurableFares(@Body() body: { rolloutPercentage?: number }) {
|
||||
return this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: true,
|
||||
config: { rollout_percentage: body.rolloutPercentage || 100 }
|
||||
});
|
||||
}
|
||||
|
||||
@Post('disable-configurable-fares')
|
||||
@ApiOperation({
|
||||
summary: 'Disable configurable fares (fallback to legacy system)',
|
||||
description: 'Disables the configurable fares feature and falls back to legacy fare calculation'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Configurable fares disabled successfully' })
|
||||
async disableConfigurableFares() {
|
||||
return this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: false,
|
||||
config: { rollout_percentage: 0 }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsInt, IsArray, ValidateNested, IsDateString, IsEnum, IsNumber, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum NationalityType {
|
||||
LOCAL = 'LOCAL',
|
||||
INTERNATIONAL = 'INTERNATIONAL'
|
||||
}
|
||||
|
||||
export enum CoachType {
|
||||
REGULAR_SEAT = 'REGULAR_SEAT',
|
||||
ECONOMY_BED = 'ECONOMY_BED',
|
||||
VIP_BED = 'VIP_BED'
|
||||
}
|
||||
|
||||
export enum BedPosition {
|
||||
UPPER = 'UPPER',
|
||||
MIDDLE = 'MIDDLE',
|
||||
LOWER = 'LOWER'
|
||||
}
|
||||
|
||||
export enum ComponentType {
|
||||
INSURANCE = 'INSURANCE',
|
||||
PREMIUM = 'PREMIUM',
|
||||
SERVICE_CHARGE = 'SERVICE_CHARGE',
|
||||
TAX = 'TAX',
|
||||
DEMAND = 'DEMAND'
|
||||
}
|
||||
|
||||
export enum CalculationMethod {
|
||||
MULTIPLIER = 'MULTIPLIER',
|
||||
PERCENTAGE = 'PERCENTAGE',
|
||||
FIXED_AMOUNT = 'FIXED_AMOUNT'
|
||||
}
|
||||
|
||||
export enum AppliesTo {
|
||||
BASE_FARE = 'BASE_FARE',
|
||||
SUBTOTAL = 'SUBTOTAL',
|
||||
TOTAL = 'TOTAL'
|
||||
}
|
||||
|
||||
export enum PricingType {
|
||||
FREE = 'FREE',
|
||||
FULL_FARE = 'FULL_FARE',
|
||||
DISCOUNTED = 'DISCOUNTED'
|
||||
}
|
||||
|
||||
export class FareRateRuleDto {
|
||||
@ApiProperty({ enum: NationalityType })
|
||||
@IsEnum(NationalityType)
|
||||
nationalityType: NationalityType;
|
||||
|
||||
@ApiProperty({ enum: CoachType })
|
||||
@IsEnum(CoachType)
|
||||
coachType: CoachType;
|
||||
|
||||
@ApiPropertyOptional({ enum: BedPosition })
|
||||
@IsOptional()
|
||||
@IsEnum(BedPosition)
|
||||
bedPosition?: BedPosition;
|
||||
|
||||
@ApiProperty({ example: 3000, description: 'Rate per km in minor units (e.g., 30.00 ETB = 3000)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
ratePerKmMinor: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class FareComponentDto {
|
||||
@ApiProperty({ enum: ComponentType })
|
||||
@IsEnum(ComponentType)
|
||||
componentType: ComponentType;
|
||||
|
||||
@ApiProperty({ example: 'Travel Insurance' })
|
||||
@IsString()
|
||||
componentName: string;
|
||||
|
||||
@ApiProperty({ enum: CalculationMethod })
|
||||
@IsEnum(CalculationMethod)
|
||||
calculationMethod: CalculationMethod;
|
||||
|
||||
@ApiPropertyOptional({ example: 500, description: 'Fixed amount in minor units' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
valueMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.02, description: 'Percentage value (e.g., 0.02 for 2%)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
percentageValue?: number;
|
||||
|
||||
@ApiProperty({ enum: AppliesTo, default: AppliesTo.SUBTOTAL })
|
||||
@IsEnum(AppliesTo)
|
||||
appliesTo: AppliesTo;
|
||||
|
||||
@ApiProperty({ example: 1, description: 'Order of application' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
applyOrder: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class AgePricingRuleDto {
|
||||
@ApiProperty({ example: 'Adult Passengers' })
|
||||
@IsString()
|
||||
ruleName: string;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minAge: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 120 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxAge?: number;
|
||||
|
||||
@ApiProperty({ enum: PricingType })
|
||||
@IsEnum(PricingType)
|
||||
pricingType: PricingType;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.5, description: 'Discount percentage for DISCOUNTED type' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
discountPercentage?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, description: 'Max free passengers for FREE type' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxFreePassengers?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
appliesToComponents?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class CreateFareConfigurationDto {
|
||||
@ApiProperty({ example: 'Summer 2024 Rates' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated rates for summer season' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ example: '2024-06-01T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
effectiveDate: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2024-08-31T23:59:59.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiryDate?: string;
|
||||
|
||||
@ApiProperty({ type: [FareRateRuleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareRateRuleDto)
|
||||
rateRules: FareRateRuleDto[];
|
||||
|
||||
@ApiProperty({ type: [FareComponentDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareComponentDto)
|
||||
components: FareComponentDto[];
|
||||
|
||||
@ApiProperty({ type: [AgePricingRuleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AgePricingRuleDto)
|
||||
ageRules: AgePricingRuleDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateFareConfigurationDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
effectiveDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiryDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [FareRateRuleDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareRateRuleDto)
|
||||
rateRules?: FareRateRuleDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [FareComponentDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareComponentDto)
|
||||
components?: FareComponentDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [AgePricingRuleDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AgePricingRuleDto)
|
||||
ageRules?: AgePricingRuleDto[];
|
||||
}
|
||||
|
||||
export class FareTestScenarioDto {
|
||||
@ApiProperty({ example: 100 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
distanceKm: number;
|
||||
|
||||
@ApiProperty({ example: 'Ethiopian' })
|
||||
@IsString()
|
||||
nationality: string;
|
||||
|
||||
@ApiProperty({ enum: CoachType })
|
||||
@IsEnum(CoachType)
|
||||
coachType: CoachType;
|
||||
|
||||
@ApiPropertyOptional({ enum: BedPosition })
|
||||
@IsOptional()
|
||||
@IsEnum(BedPosition)
|
||||
bedPosition?: BedPosition;
|
||||
|
||||
@ApiProperty({ example: 2, default: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
adultCount: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
childCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'SUMMER20' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 500 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
loyaltyPoints?: number;
|
||||
}
|
||||
|
||||
export class FareCalculationResultDto {
|
||||
@ApiProperty()
|
||||
baseFareMinor: number;
|
||||
|
||||
@ApiProperty()
|
||||
componentsTotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalBeforeDiscounts: number;
|
||||
|
||||
@ApiProperty()
|
||||
discountsTotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
finalTotalMinor: number;
|
||||
|
||||
@ApiProperty()
|
||||
breakdown: Array<{
|
||||
step: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
runningTotal: number;
|
||||
}>;
|
||||
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
calculationTimestamp: Date;
|
||||
}
|
||||
|
||||
export class MigrateLegacyDto {
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
dryRun?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
migrateScheduleFares?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
migrateSegmentFares?: boolean;
|
||||
}
|
||||
|
||||
export class CreateNewFormulaDto {
|
||||
@ApiProperty({ example: 'Default Formula Configuration' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'System-generated default configuration' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
activateImmediately?: boolean;
|
||||
}
|
||||
|
||||
export class ToggleFeatureDto {
|
||||
@ApiProperty({ example: 'USE_CONFIGURABLE_FARES' })
|
||||
@IsString()
|
||||
featureName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: { rollout_percentage: 50 } })
|
||||
@IsOptional()
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigurableFareService } from './configurable-fare.service';
|
||||
import { ConfigurableFareController, FareMigrationController, FareSystemController } from './configurable-fare.controller';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ConfigurableFareController, FareMigrationController, FareSystemController],
|
||||
providers: [ConfigurableFareService],
|
||||
exports: [ConfigurableFareService],
|
||||
})
|
||||
export class ConfigurableFareModule {}
|
||||
@@ -0,0 +1,623 @@
|
||||
import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import {
|
||||
CreateFareConfigurationDto,
|
||||
UpdateFareConfigurationDto,
|
||||
FareTestScenarioDto,
|
||||
FareCalculationResultDto,
|
||||
MigrateLegacyDto,
|
||||
CreateNewFormulaDto,
|
||||
ToggleFeatureDto,
|
||||
NationalityType,
|
||||
CoachType,
|
||||
BedPosition,
|
||||
ComponentType,
|
||||
CalculationMethod,
|
||||
AppliesTo,
|
||||
PricingType
|
||||
} from './configurable-fare.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigurableFareService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// Helper method to map nationality to type
|
||||
private mapNationalityToType(nationality: string): NationalityType {
|
||||
const upperNationality = nationality.toUpperCase();
|
||||
if (upperNationality === 'ETHIOPIAN' || upperNationality === 'DJIBOUTIAN') {
|
||||
return NationalityType.LOCAL;
|
||||
}
|
||||
return NationalityType.INTERNATIONAL;
|
||||
}
|
||||
|
||||
// Generate unique IDs
|
||||
private generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Configuration Management
|
||||
async getAllConfigurations() {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT
|
||||
fc.*,
|
||||
COUNT(DISTINCT frr.id) as rate_rules_count,
|
||||
COUNT(DISTINCT fcmp.id) as components_count,
|
||||
COUNT(DISTINCT apr.id) as age_rules_count
|
||||
FROM fare_configurations fc
|
||||
LEFT JOIN fare_rate_rules frr ON fc.id = frr.fare_config_id AND frr.is_active = true
|
||||
LEFT JOIN fare_components fcmp ON fc.id = fcmp.fare_config_id AND fcmp.is_active = true
|
||||
LEFT JOIN age_pricing_rules apr ON fc.id = apr.fare_config_id AND apr.is_active = true
|
||||
GROUP BY fc.id, fc.name, fc.description, fc.effective_date, fc.expiry_date,
|
||||
fc.is_active, fc.is_default, fc.created_by, fc.approved_by,
|
||||
fc.approved_at, fc.created_at, fc.updated_at
|
||||
ORDER BY fc.created_at DESC
|
||||
`;
|
||||
}
|
||||
|
||||
async getConfigurationById(id: string) {
|
||||
const config = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_configurations WHERE id = ${id}
|
||||
`;
|
||||
|
||||
if (!Array.isArray(config) || config.length === 0) {
|
||||
throw new NotFoundException(`Fare configuration ${id} not found`);
|
||||
}
|
||||
|
||||
const rateRules = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_rate_rules WHERE fare_config_id = ${id} ORDER BY nationality_type, coach_type, bed_position
|
||||
`;
|
||||
|
||||
const components = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_components WHERE fare_config_id = ${id} ORDER BY apply_order
|
||||
`;
|
||||
|
||||
const ageRules = await this.prisma.$queryRaw`
|
||||
SELECT * FROM age_pricing_rules WHERE fare_config_id = ${id} ORDER BY min_age
|
||||
`;
|
||||
|
||||
return {
|
||||
...config[0],
|
||||
rateRules,
|
||||
components,
|
||||
ageRules
|
||||
};
|
||||
}
|
||||
|
||||
async createConfiguration(dto: CreateFareConfigurationDto, createdBy?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const configId = this.generateId('fc');
|
||||
|
||||
// Validate no overlapping active configurations
|
||||
if (dto.isDefault) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations SET is_default = false WHERE is_default = true
|
||||
`;
|
||||
}
|
||||
|
||||
// Create main configuration
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_configurations (
|
||||
id, name, description, effective_date, expiry_date,
|
||||
is_active, is_default, created_by, created_at, updated_at
|
||||
) VALUES (
|
||||
${configId}, ${dto.name}, ${dto.description}, ${dto.effectiveDate},
|
||||
${dto.expiryDate}, false, ${dto.isDefault || false}, ${createdBy},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
|
||||
// Create rate rules
|
||||
for (const rule of dto.rateRules) {
|
||||
const ruleId = this.generateId('frr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_rate_rules (
|
||||
id, fare_config_id, nationality_type, coach_type, bed_position,
|
||||
rate_per_km_minor, is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ruleId}, ${configId}, ${rule.nationalityType}, ${rule.coachType},
|
||||
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Create components
|
||||
for (const component of dto.components) {
|
||||
const componentId = this.generateId('fcmp');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_components (
|
||||
id, fare_config_id, component_type, component_name, calculation_method,
|
||||
value_minor, percentage_value, applies_to, apply_order, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
${componentId}, ${configId}, ${component.componentType}, ${component.componentName},
|
||||
${component.calculationMethod}, ${component.valueMinor || null},
|
||||
${component.percentageValue || null}, ${component.appliesTo},
|
||||
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Create age rules
|
||||
for (const ageRule of dto.ageRules) {
|
||||
const ageRuleId = this.generateId('apr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO age_pricing_rules (
|
||||
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
|
||||
discount_percentage, max_free_passengers, applies_to_components,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ageRuleId}, ${configId}, ${ageRule.ruleName}, ${ageRule.minAge},
|
||||
${ageRule.maxAge || null}, ${ageRule.pricingType},
|
||||
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
|
||||
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
await this.createAuditEntry(tx, configId, 'CREATED', createdBy, { action: 'Configuration created' });
|
||||
|
||||
return this.getConfigurationById(configId);
|
||||
});
|
||||
}
|
||||
|
||||
async updateConfiguration(id: string, dto: UpdateFareConfigurationDto, updatedBy?: string) {
|
||||
await this.getConfigurationById(id); // Validate exists
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Update main configuration
|
||||
if (dto.name || dto.description !== undefined || dto.effectiveDate || dto.expiryDate !== undefined) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations
|
||||
SET
|
||||
name = COALESCE(${dto.name}, name),
|
||||
description = COALESCE(${dto.description}, description),
|
||||
effective_date = COALESCE(${dto.effectiveDate}, effective_date),
|
||||
expiry_date = COALESCE(${dto.expiryDate}, expiry_date),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
}
|
||||
|
||||
// Update rate rules if provided
|
||||
if (dto.rateRules) {
|
||||
await tx.$executeRaw`DELETE FROM fare_rate_rules WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const rule of dto.rateRules) {
|
||||
const ruleId = this.generateId('frr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_rate_rules (
|
||||
id, fare_config_id, nationality_type, coach_type, bed_position,
|
||||
rate_per_km_minor, is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ruleId}, ${id}, ${rule.nationalityType}, ${rule.coachType},
|
||||
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update components if provided
|
||||
if (dto.components) {
|
||||
await tx.$executeRaw`DELETE FROM fare_components WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const component of dto.components) {
|
||||
const componentId = this.generateId('fcmp');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_components (
|
||||
id, fare_config_id, component_type, component_name, calculation_method,
|
||||
value_minor, percentage_value, applies_to, apply_order, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
${componentId}, ${id}, ${component.componentType}, ${component.componentName},
|
||||
${component.calculationMethod}, ${component.valueMinor || null},
|
||||
${component.percentageValue || null}, ${component.appliesTo},
|
||||
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update age rules if provided
|
||||
if (dto.ageRules) {
|
||||
await tx.$executeRaw`DELETE FROM age_pricing_rules WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const ageRule of dto.ageRules) {
|
||||
const ageRuleId = this.generateId('apr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO age_pricing_rules (
|
||||
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
|
||||
discount_percentage, max_free_passengers, applies_to_components,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ageRuleId}, ${id}, ${ageRule.ruleName}, ${ageRule.minAge},
|
||||
${ageRule.maxAge || null}, ${ageRule.pricingType},
|
||||
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
|
||||
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
await this.createAuditEntry(tx, id, 'UPDATED', updatedBy, { changes: dto });
|
||||
|
||||
return this.getConfigurationById(id);
|
||||
});
|
||||
}
|
||||
|
||||
async activateConfiguration(id: string, activatedBy?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Deactivate all other configurations
|
||||
await tx.$executeRaw`UPDATE fare_configurations SET is_active = false`;
|
||||
|
||||
// Activate this one
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations
|
||||
SET is_active = true, approved_by = ${activatedBy}, approved_at = NOW()
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
await this.createAuditEntry(tx, id, 'ACTIVATED', activatedBy, {});
|
||||
|
||||
return { success: true, message: `Configuration ${id} activated successfully` };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConfiguration(id: string, deletedBy?: string) {
|
||||
const config = await this.getConfigurationById(id);
|
||||
|
||||
if ((config as any).is_active) {
|
||||
throw new ConflictException('Cannot delete active configuration. Deactivate first.');
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw`DELETE FROM fare_configurations WHERE id = ${id}`;
|
||||
|
||||
return { success: true, message: `Configuration ${id} deleted successfully` };
|
||||
}
|
||||
|
||||
// Fare Calculation
|
||||
async testConfiguration(id: string, scenario: FareTestScenarioDto): Promise<FareCalculationResultDto> {
|
||||
const config = await this.getConfigurationById(id);
|
||||
|
||||
// Find matching rate rule
|
||||
const nationalityType = this.mapNationalityToType(scenario.nationality);
|
||||
|
||||
const rateRule = (config.rateRules as any[]).find((rule: any) =>
|
||||
rule.nationality_type === nationalityType &&
|
||||
rule.coach_type === scenario.coachType &&
|
||||
(scenario.bedPosition ? rule.bed_position === scenario.bedPosition : !rule.bed_position)
|
||||
);
|
||||
|
||||
if (!rateRule) {
|
||||
throw new BadRequestException(`No rate rule found for ${nationalityType}/${scenario.coachType}${scenario.bedPosition ? `/${scenario.bedPosition}` : ''}`);
|
||||
}
|
||||
|
||||
// Calculate base fare
|
||||
const baseFareMinor = scenario.distanceKm * rateRule.rate_per_km_minor;
|
||||
const breakdown = [
|
||||
{
|
||||
step: '1',
|
||||
description: `Base fare: ${scenario.distanceKm}km × ${rateRule.rate_per_km_minor} minor units/km`,
|
||||
amount: baseFareMinor,
|
||||
runningTotal: baseFareMinor
|
||||
}
|
||||
];
|
||||
|
||||
let runningTotal = baseFareMinor;
|
||||
|
||||
// Apply age-based pricing
|
||||
const { adultCount = 1, childCount = 0 } = scenario;
|
||||
let totalPassengerFare = 0;
|
||||
|
||||
// Process adults
|
||||
totalPassengerFare += adultCount * baseFareMinor;
|
||||
breakdown.push({
|
||||
step: '2a',
|
||||
description: `Adult passengers: ${adultCount} × ${baseFareMinor}`,
|
||||
amount: adultCount * baseFareMinor,
|
||||
runningTotal: adultCount * baseFareMinor
|
||||
});
|
||||
|
||||
// Process children with age rules
|
||||
if (childCount > 0) {
|
||||
const childRule = (config.ageRules as any[]).find((rule: any) =>
|
||||
rule.pricing_type === PricingType.FREE && rule.max_free_passengers > 0
|
||||
);
|
||||
|
||||
if (childRule) {
|
||||
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
|
||||
const paidChildren = Math.max(0, childCount - freeChildren);
|
||||
|
||||
if (freeChildren > 0) {
|
||||
breakdown.push({
|
||||
step: '2b',
|
||||
description: `Free children: ${freeChildren} × 0 (first ${childRule.max_free_passengers} free)`,
|
||||
amount: 0,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
|
||||
if (paidChildren > 0) {
|
||||
const paidChildrenFare = paidChildren * baseFareMinor;
|
||||
totalPassengerFare += paidChildrenFare;
|
||||
breakdown.push({
|
||||
step: '2c',
|
||||
description: `Paid children: ${paidChildren} × ${baseFareMinor}`,
|
||||
amount: paidChildrenFare,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// All children pay
|
||||
const childrenFare = childCount * baseFareMinor;
|
||||
totalPassengerFare += childrenFare;
|
||||
breakdown.push({
|
||||
step: '2b',
|
||||
description: `Child passengers: ${childCount} × ${baseFareMinor}`,
|
||||
amount: childrenFare,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runningTotal = totalPassengerFare;
|
||||
|
||||
// Apply components in order
|
||||
let componentsTotal = 0;
|
||||
const components = (config.components as any[])
|
||||
.filter((c: any) => c.is_active)
|
||||
.sort((a: any, b: any) => a.apply_order - b.apply_order);
|
||||
|
||||
for (const component of components) {
|
||||
let componentAmount = 0;
|
||||
let baseAmount = runningTotal;
|
||||
|
||||
if (component.applies_to === 'BASE_FARE') {
|
||||
baseAmount = baseFareMinor;
|
||||
} else if (component.applies_to === 'SUBTOTAL') {
|
||||
baseAmount = runningTotal;
|
||||
}
|
||||
|
||||
switch (component.calculation_method) {
|
||||
case 'PERCENTAGE':
|
||||
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
|
||||
break;
|
||||
case 'MULTIPLIER':
|
||||
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
|
||||
break;
|
||||
case 'FIXED_AMOUNT':
|
||||
componentAmount = component.value_minor || 0;
|
||||
break;
|
||||
}
|
||||
|
||||
componentsTotal += componentAmount;
|
||||
runningTotal += componentAmount;
|
||||
|
||||
breakdown.push({
|
||||
step: `3${String.fromCharCode(97 + component.apply_order - 1)}`,
|
||||
description: `${component.component_name}: ${component.calculation_method} on ${component.applies_to}`,
|
||||
amount: componentAmount,
|
||||
runningTotal
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
baseFareMinor,
|
||||
componentsTotal,
|
||||
totalBeforeDiscounts: runningTotal,
|
||||
discountsTotal: 0, // TODO: Implement promo code discounts
|
||||
finalTotalMinor: runningTotal,
|
||||
breakdown,
|
||||
currency: 'ETB',
|
||||
calculationTimestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
// Migration Methods
|
||||
async migrateLegacySystem(dto: MigrateLegacyDto) {
|
||||
const results = {
|
||||
scheduleFareRules: 0,
|
||||
segmentFareRules: 0,
|
||||
configurationsCreated: 0,
|
||||
dryRun: dto.dryRun || false
|
||||
};
|
||||
|
||||
if (dto.dryRun) {
|
||||
// Count what would be migrated
|
||||
const scheduleFares = await this.prisma.$queryRaw`
|
||||
SELECT COUNT(*) as count FROM "FareRule" WHERE migrated_to_config_id IS NULL
|
||||
`;
|
||||
|
||||
const segmentFares = await this.prisma.$queryRaw`
|
||||
SELECT COUNT(*) as count FROM "SegmentFareRule" WHERE migrated_to_config_id IS NULL
|
||||
`;
|
||||
|
||||
results.scheduleFareRules = Number((scheduleFares as any[])[0]?.count || 0);
|
||||
results.segmentFareRules = Number((segmentFares as any[])[0]?.count || 0);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Create a migration configuration based on existing rules
|
||||
const migrationConfig: CreateFareConfigurationDto = {
|
||||
name: 'Legacy Migration Configuration',
|
||||
description: 'Automatically migrated from existing fare rules',
|
||||
effectiveDate: new Date().toISOString(),
|
||||
rateRules: [
|
||||
// Default rates based on current system
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentType: ComponentType.INSURANCE,
|
||||
componentName: 'Travel Insurance',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.02,
|
||||
appliesTo: AppliesTo.BASE_FARE,
|
||||
applyOrder: 1
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.TAX,
|
||||
componentName: 'Government Tax',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.05,
|
||||
appliesTo: AppliesTo.SUBTOTAL,
|
||||
applyOrder: 2
|
||||
}
|
||||
],
|
||||
ageRules: [
|
||||
{
|
||||
ruleName: 'Adult Passengers',
|
||||
minAge: 5,
|
||||
pricingType: PricingType.FULL_FARE
|
||||
},
|
||||
{
|
||||
ruleName: 'Child Passengers (First Free)',
|
||||
minAge: 0,
|
||||
maxAge: 4,
|
||||
pricingType: PricingType.FREE,
|
||||
maxFreePassengers: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const newConfig = await this.createConfiguration(migrationConfig, 'system-migration');
|
||||
results.configurationsCreated = 1;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async createNewFormulaConfiguration(dto: CreateNewFormulaDto) {
|
||||
const config = await this.createConfiguration({
|
||||
name: dto.name,
|
||||
description: dto.description || 'System-generated default configuration',
|
||||
effectiveDate: new Date().toISOString(),
|
||||
rateRules: [
|
||||
// Default rates for all combinations
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 8000 },
|
||||
// International rates (2x local)
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 16000 },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentType: ComponentType.INSURANCE,
|
||||
componentName: 'Travel Insurance',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.02,
|
||||
appliesTo: AppliesTo.BASE_FARE,
|
||||
applyOrder: 1
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.SERVICE_CHARGE,
|
||||
componentName: 'Service Charge',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.03,
|
||||
appliesTo: AppliesTo.SUBTOTAL,
|
||||
applyOrder: 2
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.TAX,
|
||||
componentName: 'Government Tax',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.05,
|
||||
appliesTo: AppliesTo.TOTAL,
|
||||
applyOrder: 3
|
||||
}
|
||||
],
|
||||
ageRules: [
|
||||
{
|
||||
ruleName: 'Adult Passengers',
|
||||
minAge: 5,
|
||||
pricingType: PricingType.FULL_FARE
|
||||
},
|
||||
{
|
||||
ruleName: 'Child Passengers (First Free)',
|
||||
minAge: 0,
|
||||
maxAge: 4,
|
||||
pricingType: PricingType.FREE,
|
||||
maxFreePassengers: 1
|
||||
}
|
||||
],
|
||||
isDefault: true
|
||||
}, 'system');
|
||||
|
||||
if (dto.activateImmediately) {
|
||||
await this.activateConfiguration((config as any).id, 'system');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Feature Management
|
||||
async toggleFeature(dto: ToggleFeatureDto) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO system_features (id, feature_name, is_enabled, config, created_at, updated_at)
|
||||
VALUES (${this.generateId('sf')}, ${dto.featureName}, ${dto.enabled},
|
||||
${JSON.stringify(dto.config || {})}, NOW(), NOW())
|
||||
ON CONFLICT (feature_name) DO UPDATE SET
|
||||
is_enabled = ${dto.enabled},
|
||||
config = ${JSON.stringify(dto.config || {})},
|
||||
updated_at = NOW()
|
||||
`;
|
||||
|
||||
return { success: true, message: `Feature ${dto.featureName} ${dto.enabled ? 'enabled' : 'disabled'}` };
|
||||
});
|
||||
}
|
||||
|
||||
async getFeatureStatus(featureName: string) {
|
||||
const result = await this.prisma.$queryRaw`
|
||||
SELECT * FROM system_features WHERE feature_name = ${featureName}
|
||||
`;
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return { enabled: false, config: {} };
|
||||
}
|
||||
|
||||
const feature = result[0] as any;
|
||||
return {
|
||||
enabled: feature.is_enabled,
|
||||
config: feature.config || {}
|
||||
};
|
||||
}
|
||||
|
||||
async getAuditTrail(configId: string) {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_configuration_audit
|
||||
WHERE fare_config_id = ${configId}
|
||||
ORDER BY timestamp DESC
|
||||
`;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async createAuditEntry(tx: any, configId: string, action: string, changedBy?: string, changes?: any) {
|
||||
const auditId = this.generateId('fca');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_configuration_audit (
|
||||
id, fare_config_id, action, changed_by, changes, timestamp
|
||||
) VALUES (
|
||||
${auditId}, ${configId}, ${action}, ${changedBy || 'system'},
|
||||
${JSON.stringify(changes || {})}, NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user