mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 00:10:57 +00:00
Bulk (per ton per km) last-mile rates now carry From/To km bands like
container mode: the Add Rate dialog offers the multi-tier editor in both modes, each tier is created as its own rate row, and overlapping bulk bands are rejected. Pricing picks the tier whose half-open band holds the trip km, falling back to the legacy bandless bulk rate.
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||
import { Not } from 'typeorm';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { CreateRateDto } from '../dto/create-rate.dto';
|
||||
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
||||
import { UpdateRateDto } from '../dto/update-rate.dto';
|
||||
@@ -344,11 +344,12 @@ export class RatesService {
|
||||
/**
|
||||
* Validate and normalise the last-mile band fields for a rate shape.
|
||||
*
|
||||
* Last-mile rates come in two calculation modes: bulk (PER_TON_KM — price =
|
||||
* tons × km × rate, one row, no scope) and container (PER_KM — one row per
|
||||
* container type per distance band, price = km × rate × quantity). Every
|
||||
* other rate shape has its band fields cleared, mirroring how yard scope is
|
||||
* cleared for non-route rates.
|
||||
* Last-mile rates come in two calculation modes: bulk (PER_TON_KM — one row
|
||||
* per distance band, price = tons × km × rate) and container (PER_KM — one
|
||||
* row per container type per distance band, price = km × rate × quantity).
|
||||
* A bandless bulk row (NULL minKm) is the legacy pre-band shape and still
|
||||
* prices every distance. Every other rate shape has its band fields cleared,
|
||||
* mirroring how yard scope is cleared for non-route rates.
|
||||
*/
|
||||
private resolveLastMileBand(input: {
|
||||
appliesTo: Rate['appliesTo'];
|
||||
@@ -366,7 +367,21 @@ export class RatesService {
|
||||
'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.',
|
||||
);
|
||||
}
|
||||
return { minKm: null, maxKm: null };
|
||||
const minKm = input.minKm ?? null;
|
||||
const maxKm = input.maxKm ?? null;
|
||||
if (minKm === null) {
|
||||
if (maxKm !== null) {
|
||||
throw new BadRequestException(
|
||||
'"To km" needs a "From km" — set the band start (0 for the first tier).',
|
||||
);
|
||||
}
|
||||
// Legacy bandless bulk rate — prices every distance.
|
||||
return { minKm: null, maxKm: null };
|
||||
}
|
||||
if (maxKm !== null && maxKm <= minKm) {
|
||||
throw new BadRequestException('"To km" must be greater than "From km".');
|
||||
}
|
||||
return { minKm, maxKm };
|
||||
}
|
||||
|
||||
if (rateUnit === 'PER_KM') {
|
||||
@@ -393,14 +408,16 @@ export class RatesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a container last-mile band that overlaps an existing band for the
|
||||
* same container type. Bands are half-open [minKm, maxKm) with NULL maxKm =
|
||||
* open-ended, so 0–30 and 30–∞ tile cleanly. Checked across every
|
||||
* non-superseded row (DRAFT included) — two drafts with colliding bands would
|
||||
* only defer the conflict to approval.
|
||||
* Reject a last-mile band that overlaps an existing band for the same scope —
|
||||
* container bands collide per container type (PER_KM), bulk bands collide
|
||||
* with each other (PER_TON_KM, no container scope). Bands are half-open
|
||||
* [minKm, maxKm) with NULL maxKm = open-ended, so 0–30 and 30–∞ tile
|
||||
* cleanly. Checked across every non-superseded row (DRAFT included) — two
|
||||
* drafts with colliding bands would only defer the conflict to approval.
|
||||
*/
|
||||
private async assertNoBandOverlap(input: {
|
||||
containerTypeId: string;
|
||||
rateUnit: 'PER_KM' | 'PER_TON_KM';
|
||||
containerTypeId: string | null;
|
||||
minKm: number;
|
||||
maxKm: number | null;
|
||||
ignoreId?: string;
|
||||
@@ -408,8 +425,8 @@ export class RatesService {
|
||||
const siblings = await this.repository.findAll({
|
||||
where: {
|
||||
rateType: 'LAST_MILE',
|
||||
rateUnit: 'PER_KM',
|
||||
containerTypeId: input.containerTypeId,
|
||||
rateUnit: input.rateUnit,
|
||||
containerTypeId: input.containerTypeId ?? IsNull(),
|
||||
status: Not('SUPERSEDED'),
|
||||
},
|
||||
});
|
||||
@@ -425,7 +442,7 @@ export class RatesService {
|
||||
if (input.minKm < sibMax && sibMin < newMax) {
|
||||
const sibLabel = `${sibMin}–${sibMax === Number.POSITIVE_INFINITY ? 'open' : sibMax} km`;
|
||||
throw new ConflictException(
|
||||
`This distance band overlaps the existing ${sibLabel} band for this container type. Adjust the ranges so each distance falls in exactly one band.`,
|
||||
`This distance band overlaps the existing ${sibLabel} band for ${input.rateUnit === 'PER_TON_KM' ? 'bulk last-mile' : 'this container type'}. Adjust the ranges so each distance falls in exactly one band.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -538,8 +555,12 @@ export class RatesService {
|
||||
minKm: dto.minKm,
|
||||
maxKm: dto.maxKm,
|
||||
});
|
||||
if (appliesTo === 'LAST_MILE' && rateUnit === 'PER_KM' && containerTypeId && minKm !== null) {
|
||||
await this.assertNoBandOverlap({ containerTypeId, minKm, maxKm });
|
||||
if (
|
||||
appliesTo === 'LAST_MILE' &&
|
||||
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
|
||||
minKm !== null
|
||||
) {
|
||||
await this.assertNoBandOverlap({ rateUnit, containerTypeId, minKm, maxKm });
|
||||
}
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
@@ -742,12 +763,12 @@ export class RatesService {
|
||||
updates.maxKm = maxKm;
|
||||
if (
|
||||
appliesTo === 'LAST_MILE' &&
|
||||
rateUnit === 'PER_KM' &&
|
||||
updates.containerTypeId &&
|
||||
(rateUnit === 'PER_KM' || rateUnit === 'PER_TON_KM') &&
|
||||
minKm !== null
|
||||
) {
|
||||
await this.assertNoBandOverlap({
|
||||
containerTypeId: updates.containerTypeId,
|
||||
rateUnit,
|
||||
containerTypeId: updates.containerTypeId ?? null,
|
||||
minKm,
|
||||
maxKm,
|
||||
ignoreId: id,
|
||||
|
||||
Reference in New Issue
Block a user