mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
- Added parameter to and for server-side free-text search on contract reference, company name, and booking details. - Introduced new validation errors in for container clashes and space issues when creating bookings. - Implemented paginated dropdown settings retrieval in . - Updated to fetch active yards using a new method that handles pagination. - Enhanced with a method to fetch all records by walking through pages. - Refactored to support filtering and pagination in schedule listings. - Improved to return a paginated list of facilities. - Updated UI components in and to utilize debounced search inputs for better performance. - Added alerts in to inform users about booking constraints related to splits and capacity. - Enhanced to display notifications for split bookings and capacity usage.
216 lines
7.5 KiB
TypeScript
216 lines
7.5 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Inject,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { PaginatedResponse } from '@edr/types';
|
|
import { CreateRateDto } from '../dto/create-rate.dto';
|
|
import { ListRatesQueryDto } from '../dto/list-rule-engine-query.dto';
|
|
import { UpdateRateDto } from '../dto/update-rate.dto';
|
|
import { Rate } from '../entities/rate.entity';
|
|
import { deriveRateType } from '../entities/rate-type.util';
|
|
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
|
|
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
|
|
|
|
@Injectable()
|
|
export class RatesService {
|
|
constructor(
|
|
@Inject(RATES_REPOSITORY)
|
|
private readonly repository: IRatesRepository,
|
|
) {}
|
|
|
|
/** List rates — standard paginated envelope with server-side search. */
|
|
async findAll(query: ListRatesQueryDto): Promise<PaginatedResponse<Rate>> {
|
|
return this.repository.findPaged(query);
|
|
}
|
|
|
|
/** Return all currently LIVE rates. */
|
|
async findLiveRates(): Promise<Rate[]> {
|
|
return this.repository.findLiveRates();
|
|
}
|
|
|
|
/** Get a rate by ID. */
|
|
async findById(id: string): Promise<Rate> {
|
|
const entity = await this.repository.findById(id);
|
|
if (!entity) throw new NotFoundException(`Rate ${id} not found`);
|
|
return entity;
|
|
}
|
|
|
|
/**
|
|
* Normalise + validate the weighting unit for a rate shape. Overweight is
|
|
* always billed per excess ton, so its unit is forced to PER_TON regardless
|
|
* of what the client sent. Every other shape must pick a unit the pricing
|
|
* engine can actually apply (see `allowedRateUnits`).
|
|
*/
|
|
private resolveRateUnit(
|
|
appliesTo: Rate['appliesTo'],
|
|
trigger: Rate['trigger'],
|
|
requestedUnit: Rate['rateUnit'],
|
|
): Rate['rateUnit'] {
|
|
// Overweight is per-ton, full stop.
|
|
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
|
|
|
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
|
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
|
|
throw new BadRequestException(
|
|
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
|
|
);
|
|
}
|
|
return requestedUnit;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* make pricing ambiguous — so we allow exactly one per pattern.
|
|
*/
|
|
private async assertNoDuplicatePattern(pattern: {
|
|
rateType: string;
|
|
rateUnit: string;
|
|
containerTypeId: string | null;
|
|
cargoTypeId: string | null;
|
|
tradeDirection: string | null;
|
|
ignoreId?: string;
|
|
}): Promise<void> {
|
|
const existing = await this.repository.findByPattern(pattern);
|
|
if (existing && existing.id !== pattern.ignoreId) {
|
|
throw new ConflictException(
|
|
'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Create a rate in DRAFT status. */
|
|
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
|
|
const appliesTo = dto.appliesTo as Rate['appliesTo'];
|
|
const trigger = dto.trigger as Rate['trigger'];
|
|
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
|
// the engine never accidentally narrows a surcharge by container/direction.
|
|
const isSurcharge = trigger !== 'ALWAYS';
|
|
const containerTypeId = isSurcharge ? null : (dto.containerTypeId ?? null);
|
|
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
|
|
const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
|
|
|
|
const rateType = deriveRateType({
|
|
appliesTo,
|
|
trigger,
|
|
tradeDirection,
|
|
isBulk: Boolean(cargoTypeId),
|
|
});
|
|
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
|
|
|
await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection });
|
|
|
|
return this.repository.create({
|
|
appliesTo,
|
|
trigger,
|
|
rateType,
|
|
containerTypeId,
|
|
cargoTypeId,
|
|
tradeDirection,
|
|
currency: dto.currency ?? 'USD',
|
|
rateValue: dto.rateValue,
|
|
rateUnit,
|
|
status: 'DRAFT',
|
|
proposedByStaffId,
|
|
});
|
|
}
|
|
|
|
/** Update a DRAFT rate. */
|
|
async update(id: string, dto: UpdateRateDto): Promise<Rate> {
|
|
const existing = await this.findById(id);
|
|
if (existing.status !== 'DRAFT') {
|
|
throw new BadRequestException('Only DRAFT rates can be updated');
|
|
}
|
|
const updates: Partial<Rate> = {};
|
|
|
|
const appliesTo = (dto.appliesTo as Rate['appliesTo']) ?? existing.appliesTo;
|
|
const trigger = (dto.trigger as Rate['trigger']) ?? existing.trigger;
|
|
const isSurcharge = trigger !== 'ALWAYS';
|
|
|
|
if (dto.appliesTo) updates.appliesTo = appliesTo;
|
|
if (dto.trigger) updates.trigger = trigger;
|
|
|
|
const containerTypeId = isSurcharge
|
|
? null
|
|
: dto.containerTypeId !== undefined
|
|
? dto.containerTypeId
|
|
: existing.containerTypeId;
|
|
const cargoTypeId = isSurcharge
|
|
? null
|
|
: dto.cargoTypeId !== undefined
|
|
? dto.cargoTypeId
|
|
: existing.cargoTypeId;
|
|
const tradeDirection = isSurcharge
|
|
? null
|
|
: dto.tradeDirection !== undefined
|
|
? dto.tradeDirection
|
|
: existing.tradeDirection;
|
|
|
|
updates.containerTypeId = containerTypeId ?? null;
|
|
updates.cargoTypeId = cargoTypeId ?? null;
|
|
updates.tradeDirection = tradeDirection ?? null;
|
|
// Keep the derived rateType in sync with whatever changed.
|
|
const rateType = deriveRateType({
|
|
appliesTo,
|
|
trigger,
|
|
tradeDirection,
|
|
isBulk: Boolean(cargoTypeId),
|
|
});
|
|
updates.rateType = rateType;
|
|
|
|
// Re-validate the unit against the (possibly changed) shape; overweight is
|
|
// forced to PER_TON.
|
|
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
|
|
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
|
|
|
|
// Guard the pattern uniqueness for the new identity, ignoring this row.
|
|
await this.assertNoDuplicatePattern({
|
|
rateType,
|
|
rateUnit: updates.rateUnit,
|
|
containerTypeId: updates.containerTypeId,
|
|
cargoTypeId: updates.cargoTypeId,
|
|
tradeDirection: updates.tradeDirection,
|
|
ignoreId: id,
|
|
});
|
|
|
|
updates.currency = dto.currency ?? existing.currency ?? 'USD';
|
|
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
|
|
const updated = await this.repository.update(id, updates);
|
|
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
|
|
return updated;
|
|
}
|
|
|
|
/** Submit a DRAFT rate for CEO approval. */
|
|
async submitForApproval(id: string): Promise<Rate> {
|
|
const rate = await this.findById(id);
|
|
if (rate.status !== 'DRAFT') {
|
|
throw new BadRequestException('Only DRAFT rates can be submitted for approval');
|
|
}
|
|
const updated = await this.repository.update(id, { status: 'PENDING_APPROVAL' });
|
|
return updated!;
|
|
}
|
|
|
|
/** CEO approves a rate — moves to LIVE. */
|
|
async approve(id: string, approverUserId: string): Promise<Rate> {
|
|
const rate = await this.findById(id);
|
|
if (rate.status !== 'PENDING_APPROVAL') {
|
|
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
|
|
}
|
|
const updated = await this.repository.update(id, {
|
|
status: 'LIVE',
|
|
approvedByCeoId: approverUserId,
|
|
approvedAt: new Date(),
|
|
});
|
|
return updated!;
|
|
}
|
|
|
|
/** Soft-delete a rate. */
|
|
async remove(id: string): Promise<void> {
|
|
await this.findById(id);
|
|
await this.repository.softDelete(id);
|
|
}
|
|
}
|