Merge pull request #782 from Tria-plc/freight_feature/usermanagement

add lashing surcharge for cargo types with hasLashing flag
This commit is contained in:
marshal
2026-07-18 02:27:50 +03:00
committed by GitHub
59 changed files with 1919 additions and 140 deletions

View File

@@ -37,6 +37,14 @@ export class CreateCargoTypeDto {
@IsBoolean()
requiresDirectorApproval?: boolean;
@ApiPropertyOptional({
default: false,
description: 'When true, bookings of this cargo type incur the flat LASHING surcharge.',
})
@IsOptional()
@IsBoolean()
hasLashing?: boolean;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -53,6 +53,14 @@ export class CargoType extends BaseEntity {
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;
/**
* When true, any booking of this cargo type incurs the flat LASHING surcharge
* (the LASHING-trigger rate). Set on commodities that need EDR-provided
* lashing/securing; leave false for cargo that ships without it.
*/
@Column({ name: 'has_lashing', type: 'boolean', default: false })
hasLashing!: boolean;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -30,6 +30,7 @@ export function deriveRateType(input: {
case 'SHIPPING_LINE':
return 'DOUBLE_HANDLING';
case 'CONSOLIDATION':
case 'LASHING':
return 'LASHING';
case 'CANCELLATION':
return 'CANCELLATION_FEE';

View File

@@ -36,6 +36,9 @@ export function allowedRateUnits(input: {
case 'CUSTOMS_CLEARANCE':
// Flat per clearance (ONE_TIME contract) / per shipment request (GENERAL).
return ['FLAT'];
case 'LASHING':
// Flat cargo-securing fee, billed once per booking.
return ['FLAT'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':

View File

@@ -78,6 +78,9 @@ export const RATE_TRIGGERS = [
'WITH_RETURN',
'SHIPPING_LINE',
'CONSOLIDATION',
// Cargo securing / lashing. Fires when the booking's cargo type has
// hasLashing = true. Flat fee, billed once per booking.
'LASHING',
'CANCELLATION',
'DEMURRAGE',
'PIL_EXTRA_FEE',

View File

@@ -6,6 +6,12 @@ import { Rate } from '../entities/rate.entity';
export interface IRatesRepository {
findById(id: string): Promise<Rate | null>;
findLiveRates(): Promise<Rate[]>;
/**
* LIVE rates with the yard / container / cargo relations eagerly joined, so
* lanes can be rendered with human labels (contract rate schedule). Ordered
* for a stable, readable schedule table.
*/
findLiveRatesDetailed(): Promise<Rate[]>;
findByPattern(pattern: {
rateType: string;
rateUnit: string;

View File

@@ -25,6 +25,22 @@ export class RatesRepository implements IRatesRepository {
.getMany();
}
findLiveRatesDetailed(): Promise<Rate[]> {
return this.repo
.createQueryBuilder('rate')
.leftJoinAndSelect('rate.originYard', 'originYard')
.leftJoinAndSelect('rate.destinationYard', 'destinationYard')
.leftJoinAndSelect('rate.containerType', 'containerType')
.leftJoinAndSelect('rate.cargoType', 'cargoType')
.where('rate.status = :status', { status: 'LIVE' })
.orderBy('rate.appliesTo', 'ASC')
.addOrderBy('rate.tradeDirection', 'ASC')
.addOrderBy('originYard.label', 'ASC')
.addOrderBy('destinationYard.label', 'ASC')
.addOrderBy('rate.rateValue', 'ASC')
.getMany();
}
/**
* Find a non-superseded rate matching an identity pattern — the same tuple the
* `UQ_rates_pattern` unique index enforces. Used to reject duplicates before

View File

@@ -61,6 +61,12 @@ export interface BookingEvaluationInput {
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
/**
* Booking's cargo type needs EDR-provided lashing/securing (cargoType
* hasLashing = true). Fires the flat LASHING surcharge. Resolved by the
* engine from cargoTypeId when omitted.
*/
hasLashing?: boolean;
totalWagons: number;
/**
* Total bulk tonnage on the booking (cargoTotalWeightVgm). Used to scale
@@ -132,12 +138,22 @@ export class RuleEngineService {
requiresDirectorApproval = true;
}
// Lashing is a cargo-type property: a booking incurs the flat LASHING
// surcharge when its cargo type has hasLashing = true. Resolve it here so
// matchesTrigger can fire the LASHING rate. Falls back to an explicit
// input flag when no cargo type is set (e.g. container bookings).
let hasLashing = input.hasLashing === true;
if (input.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) {
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
} else if (cargoType.requiresDirectorApproval) {
requiresDirectorApproval = true;
} else {
if (cargoType.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
if (cargoType.hasLashing) {
hasLashing = true;
}
}
}
@@ -237,6 +253,7 @@ export class RuleEngineService {
hasOverweight,
shippingLineMapped,
allowConsolidation: input.allowConsolidation ?? false,
hasLashing,
});
if (!triggered) continue;
@@ -466,6 +483,7 @@ export class RuleEngineService {
hasOverweight: boolean;
shippingLineMapped: boolean;
allowConsolidation: boolean;
hasLashing: boolean;
},
): boolean {
// Coerce defensively: a flag may arrive as the string "true"/"false" (e.g.
@@ -484,6 +502,8 @@ export class RuleEngineService {
return truthy(state.shippingLineMapped);
case 'CONSOLIDATION':
return truthy(state.allowConsolidation);
case 'LASHING':
return truthy(state.hasLashing);
// CANCELLATION / DEMURRAGE / PIL_EXTRA_FEE are contextual charges applied
// explicitly elsewhere (not auto-triggered by a booking's cargo flags).
default:

View File

@@ -44,6 +44,14 @@ export class RatesService {
return this.repository.findLiveRates();
}
/**
* LIVE rates with yard / container / cargo relations joined — used to render
* the origin → destination rate schedule inside generated contracts.
*/
async findLiveRatesDetailed(): Promise<Rate[]> {
return this.repository.findLiveRatesDetailed();
}
/** Get a rate by ID. */
async findById(id: string): Promise<Rate> {
const entity = await this.repository.findById(id);