refactor booking priority logic and constants; adjust scoring thresholds and remove unused service types

This commit is contained in:
Marshal
2026-06-23 14:25:54 +00:00
parent 6a63ae2fa6
commit eb85f8d32d
7 changed files with 35 additions and 34 deletions

View File

@@ -44,7 +44,6 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never, {} as never,
{} as never, {} as never,
ratesService as never, ratesService as never,
{} as never,
exchangeService as never, exchangeService as never,
); );
}); });

View File

@@ -2,7 +2,6 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service'; import { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RatesService } from '../rule-engine/services/rates.service'; import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity'; import { Rate } from '../rule-engine/entities/rate.entity';
import { ExchangeService } from '@edr/api-common'; import { ExchangeService } from '@edr/api-common';
import { import {
@@ -40,7 +39,6 @@ export class BookingPricingService {
private readonly ruleEngineService: RuleEngineService, private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService, private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService, private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService,
private readonly exchangeService: ExchangeService, private readonly exchangeService: ExchangeService,
) {} ) {}
@@ -255,27 +253,18 @@ export class BookingPricingService {
}; };
} }
/** Recompute priority on submit (USD + service tier). */ /**
* Recompute priority on submit.
*
* The full priority model is additive and capped at 100:
* service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35).
* All three components are produced by RuleEngineService.evaluate, so submit
* simply re-runs the engine — there is no extra submit-time inflation.
*/
async computeSubmitPriorityScore(booking: Booking): Promise<number> { async computeSubmitPriorityScore(booking: Booking): Promise<number> {
const evalInput = await this.buildEvalInputForBooking(booking); const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput); const ruleResult = await this.ruleEngineService.evaluate(evalInput);
let score = ruleResult.priorityScore; return ruleResult.priorityScore;
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
if (booking.paymentCurrency === 'USD' && serviceType) {
const code = (serviceType.code ?? '').toUpperCase();
const hasForwarding =
serviceType.includesFirstMile ||
serviceType.includesLastMile ||
code.includes('FORWARD') ||
code.includes('Y');
const railOnly = code.includes('RAIL') && !hasForwarding;
if (hasForwarding) score += 1000;
else if (railOnly || code.includes('X')) score += 500;
}
return score;
} }
private async computeBaseRailLinesWithRates( private async computeBaseRailLinesWithRates(

View File

@@ -1,4 +1,4 @@
export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 1000; export const OVERVIEW_URGENT_PRIORITY_THRESHOLD = 70;
export const OVERVIEW_NEEDS_ACTION_STATUSES = [ export const OVERVIEW_NEEDS_ACTION_STATUSES = [
'SUBMITTED', 'SUBMITTED',

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min } from 'class-validator'; import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreatePriorityConfigDto { export class CreatePriorityConfigDto {
@ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] }) @ApiProperty({ description: 'Config type: WAGON or CURRENCY', enum: ['WAGON', 'CURRENCY'] })
@@ -30,9 +30,15 @@ export class CreatePriorityConfigDto {
@Min(0) @Min(0)
maxWagonCount!: number; maxWagonCount!: number;
@ApiProperty({ description: 'Points awarded when booking matches this rule', default: 0 }) @ApiProperty({
description:
'Points awarded when booking matches this rule. Capped so the priority blocks sum to ≤ 100 alongside the service-type bonus (service ≤ 15 + wagon ≤ 50 + currency ≤ 35). WAGON configs should not exceed 50; CURRENCY configs should not exceed 35.',
default: 0,
maximum: 50,
})
@IsInt() @IsInt()
@Min(0) @Min(0)
@Max(50)
scorePoints!: number; scorePoints!: number;
@ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' }) @ApiPropertyOptional({ default: false, description: 'Feature flag — toggle without code deploy' })

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
export class CreateServiceTypeDto { export class CreateServiceTypeDto {
@ApiProperty({ description: 'Service type display name', maxLength: 255 }) @ApiProperty({ description: 'Service type display name', maxLength: 255 })
@@ -32,10 +32,15 @@ export class CreateServiceTypeDto {
@IsBoolean() @IsBoolean()
includesCustoms?: boolean; includesCustoms?: boolean;
@ApiPropertyOptional({ description: 'Priority bonus points awarded when this service is used', default: 0 }) @ApiPropertyOptional({
description: 'Priority bonus points awarded when this service is used (015)',
default: 0,
maximum: 15,
})
@IsOptional() @IsOptional()
@IsInt() @IsInt()
@Min(0) @Min(0)
@Max(15)
priorityBonusPoints?: number; priorityBonusPoints?: number;
@ApiPropertyOptional({ default: true }) @ApiPropertyOptional({ default: true })

View File

@@ -180,7 +180,7 @@ export class PricingDataSeeder {
includesFirstMile: true, includesFirstMile: true,
includesLastMile: true, includesLastMile: true,
includesCustoms: true, includesCustoms: true,
priorityBonusPoints: 100, priorityBonusPoints: 15,
isActive: true, isActive: true,
displayOrder: 2, displayOrder: 2,
}, },
@@ -192,7 +192,7 @@ export class PricingDataSeeder {
includesFirstMile: false, includesFirstMile: false,
includesLastMile: false, includesLastMile: false,
includesCustoms: false, includesCustoms: false,
priorityBonusPoints: 50, priorityBonusPoints: 10,
isActive: true, isActive: true,
displayOrder: 3, displayOrder: 3,
}, },
@@ -382,9 +382,11 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
this.logger.log("Seeded weight limit rules"); this.logger.log("Seeded weight limit rules");
} }
private async seedPriorityConfigs(prRepo: any): Promise<void> { private async seedPriorityConfigs(prRepo: any): Promise<void> {
// Wagon Count Block — independent, applies regardless of currency. // Priority rule = Wagon Block + Currency Block (both additive; see RuleEngineService.evaluate).
// Currency Block — applies only to the matching payment currency, within the wagon range. // Combined with the service-type bonus the total priority score caps at 100:
// Both blocks are additive (see RuleEngineService.evaluate). // service-type bonus (≤ 15) + wagon block (≤ 50) + currency block (≤ 35) = 100.
// Wagon Count Block — independent, applies regardless of currency. Max 50.
// Currency Block — applies only to the matching payment currency, within the wagon range. Max 35.
const rows = [ const rows = [
// ── Wagon Count Block ─────────────────────────────────────────────── // ── Wagon Count Block ───────────────────────────────────────────────
{ type: "WAGON", label: "Wagons 120", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 }, { type: "WAGON", label: "Wagons 120", currency: null, minWagonCount: 1, maxWagonCount: 20, scorePoints: 0, displayOrder: 1 },
@@ -392,7 +394,7 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
{ type: "WAGON", label: "Wagons 3140", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 }, { type: "WAGON", label: "Wagons 3140", currency: null, minWagonCount: 31, maxWagonCount: 40, scorePoints: 30, displayOrder: 3 },
{ type: "WAGON", label: "Wagons 4150", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 }, { type: "WAGON", label: "Wagons 4150", currency: null, minWagonCount: 41, maxWagonCount: 50, scorePoints: 50, displayOrder: 4 },
// ── Payment Currency Block ────────────────────────────────────────── // ── Payment Currency Block ──────────────────────────────────────────
{ type: "CURRENCY", label: "USD · Wagons 125", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 17, displayOrder: 5 }, { type: "CURRENCY", label: "USD · Wagons 125", currency: "USD", minWagonCount: 1, maxWagonCount: 25, scorePoints: 15, displayOrder: 5 },
{ type: "CURRENCY", label: "USD · Wagons 2650", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 }, { type: "CURRENCY", label: "USD · Wagons 2650", currency: "USD", minWagonCount: 26, maxWagonCount: 50, scorePoints: 35, displayOrder: 6 },
{ type: "CURRENCY", label: "ETB · Wagons 150", currency: "ETB", minWagonCount: 1, maxWagonCount: 50, scorePoints: 0, displayOrder: 7 }, { type: "CURRENCY", label: "ETB · Wagons 150", currency: "ETB", minWagonCount: 1, maxWagonCount: 50, scorePoints: 0, displayOrder: 7 },
]; ];

View File

@@ -1,14 +1,14 @@
import { Badge } from "@mantine/core"; import { Badge } from "@mantine/core";
export function BookingPriorityBadge({ score }: { score: number }) { export function BookingPriorityBadge({ score }: { score: number }) {
if (score >= 1000) { if (score >= 70) {
return ( return (
<Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase"> <Badge color="red" variant="filled" size="sm" radius="lg" tt="uppercase">
Urgent Urgent
</Badge> </Badge>
); );
} }
if (score >= 500) { if (score >= 40) {
return ( return (
<Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase"> <Badge color="yellow" variant="filled" size="sm" radius="lg" tt="uppercase">
High High