mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 15:30:56 +00:00
Merge pull request #719 from Tria-plc/freight_feature/usermanagement
implement empty-container return service: add return quantity handlin…
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds freight.booking_container.return_quantity — how many units of a
|
||||
* container line ship with the empty-container-return service (≤ quantity).
|
||||
* Mirrors hazardous_quantity / reefer_quantity: captured per line at booking
|
||||
* creation when the contract enables WITH_RETURN (container freight only) and
|
||||
* drives the booking-level equipment_return flag that fires the WITH_RETURN
|
||||
* pricing surcharge.
|
||||
*/
|
||||
export class AddContainerReturnQuantity2270000000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
ADD COLUMN IF NOT EXISTS return_quantity SMALLINT NOT NULL DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.booking_container
|
||||
DROP COLUMN IF EXISTS return_quantity;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -328,6 +328,11 @@ export class BookingPricingService {
|
||||
// reefer quantity) applies the REEFER surcharge even for non-reefer
|
||||
// container types. ORed with per-container reefer in the engine.
|
||||
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
|
||||
// Empty-container return service (container freight only) — bills the
|
||||
// WITH_RETURN surcharge per container, like hazard/reefer.
|
||||
withReturn:
|
||||
booking.freightType === 'CONTAINER' &&
|
||||
booking.equipmentReturn === 'WITH_RETURN',
|
||||
isGovernment: booking.isGovernment,
|
||||
allowConsolidation,
|
||||
shippingLineId: booking.shippingLineId,
|
||||
|
||||
@@ -41,6 +41,10 @@ export class BookingContainer extends BaseEntity {
|
||||
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
|
||||
reeferQuantity!: number;
|
||||
|
||||
/** How many units of this line ship with empty-container return (≤ quantity). */
|
||||
@Column({ name: 'return_quantity', type: 'smallint', default: 0 })
|
||||
returnQuantity!: number;
|
||||
|
||||
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||
vgmPerUnitTons!: number;
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ export class ContractBookingService {
|
||||
contractType: 'NEW',
|
||||
customsClearingEnabled: contract.customsClearingEnabled,
|
||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
@@ -665,7 +665,7 @@ export class ContractBookingService {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
} as never);
|
||||
|
||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
||||
@@ -1416,6 +1416,47 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the booking's equipment return from the per-line return quantities
|
||||
* (container freight). The CONTRACT gates the service — like hazardous:
|
||||
* - contract WITH_RETURN → per-line returnQuantity (≤ quantity) decides; any
|
||||
* line > 0 makes the booking WITH_RETURN (fires the pricing surcharge).
|
||||
* - contract WITHOUT_RETURN/unset → returnQuantity is rejected and the legacy
|
||||
* booking-level override (dto.equipmentReturn ?? contract default) applies.
|
||||
* Bulk freight keeps the legacy behaviour untouched.
|
||||
*/
|
||||
private resolveShipmentEquipmentReturn(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): string {
|
||||
const legacy =
|
||||
dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN';
|
||||
if (contract.freightType !== 'CONTAINER') return legacy;
|
||||
|
||||
const lines = dto.containers ?? [];
|
||||
for (const line of lines) {
|
||||
const qty = Number(line.returnQuantity ?? 0);
|
||||
if (qty === 0) continue;
|
||||
if (contract.equipmentReturn !== 'WITH_RETURN') {
|
||||
throw new BadRequestException(
|
||||
'This contract was not created with the empty-container return ' +
|
||||
'service — return quantities are not allowed on its bookings.',
|
||||
);
|
||||
}
|
||||
if (qty > line.quantity) {
|
||||
throw new BadRequestException(
|
||||
`Return quantity ${qty} exceeds the ${line.containerSize} line quantity ${line.quantity}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (contract.equipmentReturn === 'WITH_RETURN') {
|
||||
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
|
||||
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
|
||||
}
|
||||
return legacy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map each contract-scope container size to a concrete container type and
|
||||
* persist the booking_container line + its per-unit container numbers. Weight
|
||||
@@ -1466,6 +1507,10 @@ export class ContractBookingService {
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
: 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
||||
@@ -1586,6 +1631,7 @@ export class ContractBookingService {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
isGovernment: contract.isGovernment,
|
||||
shippingLineId: null,
|
||||
contractRouteId: route?.id ?? null,
|
||||
@@ -1599,6 +1645,10 @@ export class ContractBookingService {
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
: 0,
|
||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||
totalVgmTons,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
|
||||
|
||||
@@ -188,6 +188,25 @@ export class ContractPricingService {
|
||||
});
|
||||
}
|
||||
}
|
||||
// Empty-container return service — container contracts only, toggled on the
|
||||
// contract like hazard/reefer. Billed at booking per WITH_RETURN container.
|
||||
if (
|
||||
contract.freightType === 'CONTAINER' &&
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
) {
|
||||
const withReturn = liveRates.find(
|
||||
(r) => r.rateType === 'RETURN_SURCHARGE' && r.currency === 'USD',
|
||||
);
|
||||
if (withReturn && Number(withReturn.rateValue) > 0) {
|
||||
lineItems.push({
|
||||
code: 'RETURN_SURCHARGE',
|
||||
label: 'Empty container return',
|
||||
unit: toContractUnit(withReturn.rateUnit),
|
||||
unitPrice: convert(Number(withReturn.rateValue)),
|
||||
conditionalOn: 'with_return',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Customs clearance service fee (Path B) — a FLAT prepaid fee, shown on the
|
||||
// contract and billed via its own clearance invoice: after counter-sign for
|
||||
|
||||
@@ -77,6 +77,18 @@ export class CreateBookingContainerLineDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
reeferQuantity?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 0,
|
||||
description:
|
||||
'How many units of this line ship with empty-container return (≤ quantity). ' +
|
||||
'Only allowed when the contract was created WITH_RETURN (container freight).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
returnQuantity?: number;
|
||||
|
||||
@ApiProperty({ type: [CreateContainerUnitDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
|
||||
@@ -28,6 +28,9 @@ export function allowedRateUnits(input: {
|
||||
return ['PER_CONTAINER', 'PER_TON'];
|
||||
case 'DEMURRAGE':
|
||||
return ['PER_CONTAINER', 'PER_TON'];
|
||||
case 'WITH_RETURN':
|
||||
// Container-only empty-return service — bills per returned container.
|
||||
return ['PER_CONTAINER', 'FLAT'];
|
||||
case 'CANCELLATION':
|
||||
return ['FLAT', 'PER_INVOICE'];
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
|
||||
@@ -20,6 +20,7 @@ export const RATE_TYPES = [
|
||||
'OVERWEIGHT_PER_TON',
|
||||
'HAZARD_SURCHARGE',
|
||||
'REEFER_SURCHARGE',
|
||||
'RETURN_SURCHARGE',
|
||||
'PIL_EXTRA_FEE',
|
||||
'CUSTOMS_CLEARANCE',
|
||||
] as const;
|
||||
@@ -71,6 +72,9 @@ export const RATE_TRIGGERS = [
|
||||
'HAZARDOUS',
|
||||
'OVERWEIGHT',
|
||||
'REEFER',
|
||||
// Empty-container return service (container freight only) — fires when the
|
||||
// booking ships WITH_RETURN, billed like hazard/reefer (usually PER_CONTAINER).
|
||||
'WITH_RETURN',
|
||||
'SHIPPING_LINE',
|
||||
'CONSOLIDATION',
|
||||
'CANCELLATION',
|
||||
|
||||
@@ -53,6 +53,11 @@ export interface BookingEvaluationInput {
|
||||
isHazardous: boolean;
|
||||
/** Booking-level reefer flag; ORed with per-container reefer. */
|
||||
isReefer?: boolean;
|
||||
/**
|
||||
* Booking ships with empty-container return (equipment_return = WITH_RETURN,
|
||||
* container freight only). Fires the WITH_RETURN surcharge like hazard/reefer.
|
||||
*/
|
||||
withReturn?: boolean;
|
||||
isGovernment?: boolean;
|
||||
allowConsolidation?: boolean;
|
||||
shippingLineId?: string | null;
|
||||
@@ -228,6 +233,7 @@ export class RuleEngineService {
|
||||
const triggered = this.matchesTrigger(rate.trigger, {
|
||||
isHazardous: input.isHazardous,
|
||||
hasReefer,
|
||||
withReturn: input.withReturn ?? false,
|
||||
hasOverweight,
|
||||
shippingLineMapped,
|
||||
allowConsolidation: input.allowConsolidation ?? false,
|
||||
@@ -456,6 +462,7 @@ export class RuleEngineService {
|
||||
state: {
|
||||
isHazardous: boolean;
|
||||
hasReefer: boolean;
|
||||
withReturn: boolean;
|
||||
hasOverweight: boolean;
|
||||
shippingLineMapped: boolean;
|
||||
allowConsolidation: boolean;
|
||||
@@ -469,6 +476,8 @@ export class RuleEngineService {
|
||||
return truthy(state.isHazardous);
|
||||
case 'REEFER':
|
||||
return truthy(state.hasReefer);
|
||||
case 'WITH_RETURN':
|
||||
return truthy(state.withReturn);
|
||||
case 'OVERWEIGHT':
|
||||
return truthy(state.hasOverweight);
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
@@ -343,7 +344,7 @@ export class TrainBuilderService {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
|
||||
const patch: Partial<Train> = {};
|
||||
const patch: QueryDeepPartialEntity<Train> = {};
|
||||
if (dto.trainName !== undefined) {
|
||||
patch.trainName = dto.trainName.trim() || null;
|
||||
}
|
||||
|
||||
@@ -415,6 +415,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise<void> {
|
||||
// Small test values (< 20) so the surcharge stays a minor add for now.
|
||||
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 15, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" },
|
||||
// Empty-container return service — container contracts opted in at
|
||||
// creation; bills per container on WITH_RETURN bookings.
|
||||
{ appliesTo: "OTHER", trigger: "WITH_RETURN", rateType: "RETURN_SURCHARGE", rateValue: 20, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "OTHER", trigger: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, rateUnit: "PER_CONTAINER" },
|
||||
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
||||
|
||||
Reference in New Issue
Block a user