mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 11:21:18 +00:00
feat(rates): last-mile rate rules with bulk per-ton-km and container distance bands
- rates: min_km/max_km columns, PER_TON_KM unit, ETB|USD currency for last mile - extend UQ_rates_pattern with band start; overlap + shape validation - shared last-mile charge resolver; approve-dialog price estimate endpoint - delivery-fee invoice prices via rules, falls back to vehicle price/km - backoffice: last-mile rate form (mode, container type, band, currency)
This commit is contained in:
@@ -727,8 +727,16 @@ export class BookingPricingService {
|
||||
|
||||
for (const leg of legs) {
|
||||
if (!leg.active) continue;
|
||||
// New-style last-mile rates (PER_TON_KM bulk / distance-banded PER_KM)
|
||||
// price the operational leg via last-mile-charge.util, not the booking
|
||||
// quote — this legacy lookup must never pick one up.
|
||||
const rate = liveRates.find(
|
||||
(r) => r.rateType === leg.rateType && r.currency === 'USD' && r.status === 'LIVE',
|
||||
(r) =>
|
||||
r.rateType === leg.rateType &&
|
||||
r.currency === 'USD' &&
|
||||
r.status === 'LIVE' &&
|
||||
r.rateUnit !== 'PER_TON_KM' &&
|
||||
r.minKm == null,
|
||||
);
|
||||
if (!rate) continue;
|
||||
|
||||
|
||||
@@ -177,8 +177,14 @@ export class ContractPricingService {
|
||||
}
|
||||
}
|
||||
if (contract.lastMileDeliveryAddress) {
|
||||
// New-style last-mile rates (PER_TON_KM / distance-banded PER_KM) are
|
||||
// priced operationally per job, not as a single contract unit price.
|
||||
const lm = liveRates.find(
|
||||
(r) => r.rateType === 'LAST_MILE' && r.currency === 'USD',
|
||||
(r) =>
|
||||
r.rateType === 'LAST_MILE' &&
|
||||
r.currency === 'USD' &&
|
||||
r.rateUnit !== 'PER_TON_KM' &&
|
||||
r.minKm == null,
|
||||
);
|
||||
if (lm && Number(lm.rateValue) > 0) {
|
||||
lineItems.push({
|
||||
|
||||
@@ -3,10 +3,8 @@ import { Transform } from 'class-transformer';
|
||||
import { IsNumber, Min } from 'class-validator';
|
||||
|
||||
export class ApproveLastMileRequestDto {
|
||||
// ponytail: flat manual advance amount — no rate model exists yet at this
|
||||
// pre-distance stage (delivery-fee invoicing needs assigned-truck distance,
|
||||
// which isn't known until after payment). Wire a FeeRule-based estimate
|
||||
// (see double-handling/truck-detention fee rules) once one exists.
|
||||
// The approve dialog prefills this from GET :id/price-estimate (rule-based),
|
||||
// but the chief can still override — the typed value is what's invoiced.
|
||||
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsNumber()
|
||||
|
||||
@@ -42,6 +42,16 @@ export class LastMileRequestsController {
|
||||
return this.requestsService.freeTruckCount().then((count) => ({ count }));
|
||||
}
|
||||
|
||||
@Get(':id/price-estimate')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Rule-based last-mile price estimate (estimated km × live last-mile rates) — informational context for approval',
|
||||
})
|
||||
priceEstimate(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.requestsService.priceEstimate(id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
|
||||
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
|
||||
|
||||
@@ -3,7 +3,14 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { DataSource, FindOptionsWhere } from 'typeorm';
|
||||
import { Freight, LastMileRequestStatus } from '@edr/types';
|
||||
|
||||
import {
|
||||
LastMileCharge,
|
||||
computeLastMileCharge,
|
||||
lastMileShipmentShape,
|
||||
} from '../../common/last-mile-charge.util';
|
||||
import { estimateMileKm } from '../../common/mile-distance.util';
|
||||
import { usesEdrMileService } from '../../common/mile-haulage.util';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
@@ -38,6 +45,7 @@ export class LastMileRequestsService {
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly notifications: NotificationInboxService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
@@ -199,6 +207,44 @@ export class LastMileRequestsService {
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
|
||||
* delivery point, straight-line) × the LIVE last-mile rate rules against the
|
||||
* containers the customer confirmed (or the booking's bulk tonnage). All
|
||||
* nulls when km or rate coverage is missing — the dialog then behaves as
|
||||
* before (manually typed advance).
|
||||
*/
|
||||
async priceEstimate(id: string): Promise<{
|
||||
estimatedKm: number | null;
|
||||
mode: LastMileCharge['mode'] | null;
|
||||
currency: string | null;
|
||||
total: number | null;
|
||||
lines: Array<{ description: string; amount: number }>;
|
||||
}> {
|
||||
const request = await this.findById(id);
|
||||
const estimatedKm = await estimateMileKm(this.dataSource, request.bookingId, 'LAST');
|
||||
if (!estimatedKm) {
|
||||
return { estimatedKm: null, mode: null, currency: null, total: null, lines: [] };
|
||||
}
|
||||
const shape = await lastMileShipmentShape(
|
||||
this.dataSource,
|
||||
request.bookingId,
|
||||
request.requestedContainerNumbers ?? [],
|
||||
);
|
||||
const charge = computeLastMileCharge({
|
||||
...shape,
|
||||
km: estimatedKm,
|
||||
liveRates: await this.ratesService.findLiveRatesDetailed(),
|
||||
});
|
||||
return {
|
||||
estimatedKm,
|
||||
mode: charge?.mode ?? null,
|
||||
currency: charge?.currency ?? null,
|
||||
total: charge?.total ?? null,
|
||||
lines: (charge?.lines ?? []).map(({ description, amount }) => ({ description, amount })),
|
||||
};
|
||||
}
|
||||
|
||||
/** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */
|
||||
async freeTruckCount(): Promise<number> {
|
||||
return this.dataSource.manager.count(Vehicle, {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
|
||||
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
|
||||
import {
|
||||
BillingService,
|
||||
GenerateInvoiceInput,
|
||||
InvoiceEventPayload,
|
||||
} from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import { LastMileRepository } from './last-mile.repository';
|
||||
import { LastMile } from './entities/last-mile.entity';
|
||||
|
||||
@@ -26,6 +29,8 @@ export class LastMileInvoiceService {
|
||||
constructor(
|
||||
private readonly billing: BillingService,
|
||||
private readonly lastMileRepo: LastMileRepository,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -54,6 +59,41 @@ export class LastMileInvoiceService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rule-based pricing first (bulk per-ton-km / container distance bands
|
||||
// against the exact km): when a LIVE last-mile rate covers the job, it —
|
||||
// not the per-vehicle price/km — is the delivery fee, with its own
|
||||
// currency and per-size breakdown. Same resolver setDistances used to
|
||||
// write remainingPayment, recomputed here so a rate change between the
|
||||
// two moments settles on the invoice's side.
|
||||
const exactKm = Number(record.exactKm) || 0;
|
||||
const rule =
|
||||
exactKm > 0
|
||||
? await ruleBasedLastMileCharge(
|
||||
this.dataSource,
|
||||
await this.ratesService.findLiveRatesDetailed(),
|
||||
record.id,
|
||||
exactKm,
|
||||
)
|
||||
: null;
|
||||
if (rule && rule.total > 0) {
|
||||
return this.billing.generateInvoice({
|
||||
source: 'last_mile' as Freight.InvoiceSource,
|
||||
sourceId: record.id,
|
||||
type: 'DELIVERY_FEE',
|
||||
companyId: lm.booking!.companyId,
|
||||
companyProfileId: lm.booking!.companyProfileId || '',
|
||||
currency: rule.currency,
|
||||
lines: rule.lines.map((line) => ({
|
||||
chargeType: 'DELIVERY',
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
unitRate: line.unitRate,
|
||||
amount: line.amount,
|
||||
})),
|
||||
totalAmount: rule.total,
|
||||
});
|
||||
}
|
||||
|
||||
// numeric columns come back as strings — coerce before billing.
|
||||
const totalAmount = Number(record.remainingPayment) || 0;
|
||||
if (!Number.isFinite(totalAmount) || totalAmount <= 0) {
|
||||
|
||||
@@ -42,6 +42,7 @@ function makeService(opts: { booking?: BookingRow; hasCustomerTruck?: boolean })
|
||||
{ query } as unknown as DataSource,
|
||||
{ record: jest.fn() } as never, // history
|
||||
{} as never, // billing
|
||||
{ findLiveRatesDetailed: jest.fn().mockResolvedValue([]) } as never, // ratesService
|
||||
{} as never, // filesService
|
||||
);
|
||||
|
||||
|
||||
@@ -13,7 +13,9 @@ import {
|
||||
usesEdrMileService,
|
||||
} from '../../common/mile-haulage.util';
|
||||
import { attachMileFinancials } from '../../common/mile-financials.util';
|
||||
import { ruleBasedLastMileCharge } from '../../common/last-mile-charge.util';
|
||||
import { estimateMileKm } from '../../common/mile-distance.util';
|
||||
import { RatesService } from '../rule-engine/services/rates.service';
|
||||
import {
|
||||
assertBulkTonnageRemains,
|
||||
assertTruckCountWithinContainers,
|
||||
@@ -70,6 +72,7 @@ export class LastMileService {
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly history: FleetHistoryService,
|
||||
private readonly billing: BillingService,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly filesService: FilesService,
|
||||
) {}
|
||||
|
||||
@@ -986,9 +989,18 @@ export class LastMileService {
|
||||
(s, a) => s + (Number(a.distanceKm) || 0) * (Number(a.vehicle?.pricePerKm) || 0),
|
||||
0,
|
||||
);
|
||||
// Prefer the rule-based last-mile rate (bulk per-ton-km / container
|
||||
// distance bands) over the per-vehicle price; the truck math stays as the
|
||||
// fallback when no LIVE rule covers this job.
|
||||
const rule = await ruleBasedLastMileCharge(
|
||||
this.dataSource,
|
||||
await this.ratesService.findLiveRatesDetailed(),
|
||||
id,
|
||||
total,
|
||||
);
|
||||
await this.lastMileRepository.update(id, {
|
||||
exactKm: total,
|
||||
remainingPayment: amount,
|
||||
remainingPayment: rule?.total ?? amount,
|
||||
} as any);
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from '../entities/rate.entity';
|
||||
|
||||
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH'] as const;
|
||||
const CURRENCIES = ['USD'] as const;
|
||||
// ETB is accepted only for last-mile rates; the service forces USD elsewhere.
|
||||
const CURRENCIES = ['USD', 'ETB'] as const;
|
||||
export const INTERCITY_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||
export const CARGO_KINDS = ['CONTAINER', 'BULK'] as const;
|
||||
|
||||
@@ -92,6 +93,28 @@ export class CreateRateDto {
|
||||
@IsOptional()
|
||||
@IsIn([...RATE_UNITS])
|
||||
rateUnit?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Distance band start (km, inclusive). Container last-mile rates only (rateUnit = PER_KM).',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||
minKm?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Distance band end (km, exclusive). Null/omitted = open-ended band. Container last-mile rates only.',
|
||||
minimum: 0,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => (value === null || value === undefined || value === '' ? undefined : Number(value)))
|
||||
maxKm?: number;
|
||||
}
|
||||
|
||||
export class SubmitRateForApprovalDto {
|
||||
|
||||
@@ -93,8 +93,12 @@ function unitsForShape(input: {
|
||||
case 'INTERCITY':
|
||||
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
|
||||
case 'FIRST_MILE':
|
||||
case 'LAST_MILE':
|
||||
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
|
||||
case 'LAST_MILE':
|
||||
// PER_KM = container mode (banded by distance + container size),
|
||||
// PER_TON_KM = bulk mode (tons × km × rate). Legacy units kept for
|
||||
// existing rows.
|
||||
return ['PER_KM', 'PER_TON_KM', 'PER_CONTAINER', 'PER_TON', 'FLAT'];
|
||||
default:
|
||||
return ['FLAT'];
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ export const RATE_UNITS = [
|
||||
'PER_ITEM',
|
||||
'PER_CONTAINER',
|
||||
'PER_KM',
|
||||
// Last-mile bulk: price = tons × km × rateValue.
|
||||
'PER_TON_KM',
|
||||
'PER_INVOICE',
|
||||
'FLAT',
|
||||
] as const;
|
||||
@@ -156,6 +158,17 @@ export class Rate extends BaseEntity {
|
||||
@Column({ name: 'rate_unit', type: 'varchar', length: 30 })
|
||||
rateUnit!: RateUnit;
|
||||
|
||||
/**
|
||||
* Distance band for container last-mile rates (rateUnit = PER_KM, scoped by
|
||||
* containerTypeId): the rate applies when minKm <= km < maxKm (maxKm NULL =
|
||||
* open-ended). NULL on every other rate shape.
|
||||
*/
|
||||
@Column({ name: 'min_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
minKm?: number | null;
|
||||
|
||||
@Column({ name: 'max_km', type: 'numeric', precision: 10, scale: 2, nullable: true })
|
||||
maxKm?: number | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
|
||||
status!: RateStatus;
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface IRatesRepository {
|
||||
tradeDirection?: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
/** Band start for container last-mile rates; omitted/null elsewhere. */
|
||||
minKm?: number | null;
|
||||
}): Promise<Rate | null>;
|
||||
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
|
||||
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
|
||||
|
||||
@@ -74,6 +74,7 @@ export class RatesRepository implements IRatesRepository {
|
||||
tradeDirection?: string | null;
|
||||
originYardId?: string | null;
|
||||
destinationYardId?: string | null;
|
||||
minKm?: number | null;
|
||||
}): Promise<Rate | null> {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('rate')
|
||||
@@ -111,6 +112,13 @@ export class RatesRepository implements IRatesRepository {
|
||||
} else {
|
||||
qb.andWhere('rate.destination_yard_id IS NULL');
|
||||
}
|
||||
// Band start distinguishes sibling last-mile bands, mirroring the
|
||||
// COALESCE(min_km, -1) column of UQ_rates_pattern.
|
||||
if (pattern.minKm !== null && pattern.minKm !== undefined) {
|
||||
qb.andWhere('rate.min_km = :minKm', { minKm: pattern.minKm });
|
||||
} else {
|
||||
qb.andWhere('rate.min_km IS NULL');
|
||||
}
|
||||
|
||||
return qb.getOne();
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ const DIFFABLE_FIELDS = [
|
||||
// diffed to nothing and the submit was refused as "nothing changed".
|
||||
'originYardId',
|
||||
'destinationYardId',
|
||||
// Container last-mile distance bands. Missing here, a band-range edit on a
|
||||
// LIVE last-mile rate would diff to "nothing changed".
|
||||
'minKm',
|
||||
'maxKm',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PaginatedResponse, YardCountry } from '@edr/types';
|
||||
import { 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';
|
||||
@@ -340,6 +341,96 @@ export class RatesService {
|
||||
return appliesTo === 'INTERCITY' ? intercityKind === 'BULK' : appliesTo === 'BULK';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private resolveLastMileBand(input: {
|
||||
appliesTo: Rate['appliesTo'];
|
||||
rateUnit: Rate['rateUnit'];
|
||||
containerTypeId: string | null;
|
||||
minKm?: number | null;
|
||||
maxKm?: number | null;
|
||||
}): { minKm: number | null; maxKm: number | null } {
|
||||
const { appliesTo, rateUnit, containerTypeId } = input;
|
||||
if (appliesTo !== 'LAST_MILE') return { minKm: null, maxKm: null };
|
||||
|
||||
if (rateUnit === 'PER_TON_KM') {
|
||||
if (containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A bulk last-mile rate (per ton per km) cannot be scoped to a container type.',
|
||||
);
|
||||
}
|
||||
return { minKm: null, maxKm: null };
|
||||
}
|
||||
|
||||
if (rateUnit === 'PER_KM') {
|
||||
if (!containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A container last-mile rate must name the container type it covers (20ft and 40ft price differently).',
|
||||
);
|
||||
}
|
||||
const minKm = input.minKm ?? null;
|
||||
const maxKm = input.maxKm ?? null;
|
||||
if (minKm === null) {
|
||||
throw new BadRequestException(
|
||||
'A container last-mile rate needs a distance band — set "From km" (0 for the first band).',
|
||||
);
|
||||
}
|
||||
if (maxKm !== null && maxKm <= minKm) {
|
||||
throw new BadRequestException('"To km" must be greater than "From km".');
|
||||
}
|
||||
return { minKm, maxKm };
|
||||
}
|
||||
|
||||
// Legacy last-mile shapes (FLAT / PER_CONTAINER / PER_TON) carry no band.
|
||||
return { minKm: null, maxKm: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private async assertNoBandOverlap(input: {
|
||||
containerTypeId: string;
|
||||
minKm: number;
|
||||
maxKm: number | null;
|
||||
ignoreId?: string;
|
||||
}): Promise<void> {
|
||||
const siblings = await this.repository.findAll({
|
||||
where: {
|
||||
rateType: 'LAST_MILE',
|
||||
rateUnit: 'PER_KM',
|
||||
containerTypeId: input.containerTypeId,
|
||||
status: Not('SUPERSEDED'),
|
||||
},
|
||||
});
|
||||
const newMax = input.maxKm ?? Number.POSITIVE_INFINITY;
|
||||
for (const sibling of siblings) {
|
||||
if (sibling.id === input.ignoreId) continue;
|
||||
if (sibling.minKm === null || sibling.minKm === undefined) continue; // legacy row, no band
|
||||
const sibMin = Number(sibling.minKm);
|
||||
const sibMax =
|
||||
sibling.maxKm === null || sibling.maxKm === undefined
|
||||
? Number.POSITIVE_INFINITY
|
||||
: Number(sibling.maxKm);
|
||||
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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a second rate with the same identity pattern (rateType + scope). With
|
||||
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
|
||||
@@ -360,6 +451,8 @@ export class RatesService {
|
||||
tradeDirection: string | null;
|
||||
originYardId: string | null;
|
||||
destinationYardId: string | null;
|
||||
/** Band start — part of the identity for container last-mile bands only. */
|
||||
minKm?: number | null;
|
||||
ignoreId?: string;
|
||||
}): Promise<void> {
|
||||
const existing = await this.repository.findByPattern(pattern);
|
||||
@@ -438,6 +531,17 @@ export class RatesService {
|
||||
cargoTypeId,
|
||||
);
|
||||
|
||||
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||
appliesTo,
|
||||
rateUnit,
|
||||
containerTypeId,
|
||||
minKm: dto.minKm,
|
||||
maxKm: dto.maxKm,
|
||||
});
|
||||
if (appliesTo === 'LAST_MILE' && rateUnit === 'PER_KM' && containerTypeId && minKm !== null) {
|
||||
await this.assertNoBandOverlap({ containerTypeId, minKm, maxKm });
|
||||
}
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
...(this.resolvesSingleRate(appliesTo, trigger) ? {} : { rateUnit }),
|
||||
@@ -446,6 +550,7 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
minKm,
|
||||
});
|
||||
|
||||
return this.repository.create({
|
||||
@@ -457,9 +562,13 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
currency: dto.currency ?? 'USD',
|
||||
// Last-mile is the one shape sold in birr (or USD); everything else is
|
||||
// USD by contract.
|
||||
currency: appliesTo === 'LAST_MILE' ? (dto.currency ?? 'ETB') : 'USD',
|
||||
rateValue: dto.rateValue,
|
||||
rateUnit,
|
||||
minKm,
|
||||
maxKm,
|
||||
status: 'DRAFT',
|
||||
proposedByStaffId,
|
||||
});
|
||||
@@ -622,6 +731,29 @@ export class RatesService {
|
||||
);
|
||||
updates.rateUnit = rateUnit;
|
||||
|
||||
const { minKm, maxKm } = this.resolveLastMileBand({
|
||||
appliesTo,
|
||||
rateUnit,
|
||||
containerTypeId: updates.containerTypeId,
|
||||
minKm: dto.minKm !== undefined ? dto.minKm : existing.minKm,
|
||||
maxKm: dto.maxKm !== undefined ? dto.maxKm : existing.maxKm,
|
||||
});
|
||||
updates.minKm = minKm;
|
||||
updates.maxKm = maxKm;
|
||||
if (
|
||||
appliesTo === 'LAST_MILE' &&
|
||||
rateUnit === 'PER_KM' &&
|
||||
updates.containerTypeId &&
|
||||
minKm !== null
|
||||
) {
|
||||
await this.assertNoBandOverlap({
|
||||
containerTypeId: updates.containerTypeId,
|
||||
minKm,
|
||||
maxKm,
|
||||
ignoreId: id,
|
||||
});
|
||||
}
|
||||
|
||||
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
@@ -631,10 +763,14 @@ export class RatesService {
|
||||
tradeDirection: updates.tradeDirection,
|
||||
originYardId: updates.originYardId,
|
||||
destinationYardId: updates.destinationYardId,
|
||||
minKm,
|
||||
ignoreId: id,
|
||||
});
|
||||
|
||||
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
||||
updates.currency =
|
||||
appliesTo === 'LAST_MILE'
|
||||
? (dto.currency ?? existing.currency ?? 'ETB')
|
||||
: 'USD';
|
||||
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
||||
return updates;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user