implement empty-container return service: add return quantity handling, update related DTOs, services, and UI components

This commit is contained in:
Marshal
2026-07-15 22:48:04 +00:00
parent fc44eee25e
commit 234a74e812
20 changed files with 324 additions and 20 deletions

View File

@@ -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)),

View File

@@ -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

View File

@@ -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 })