add per-container handling options for hazardous, reefer, and return services

- Introduced new boolean fields (isHazardous, isReefer, isReturn) in UnitDraft and related interfaces to allow individual container handling options.
- Updated emptyUnit function to initialize these new fields.
- Modified GlCreateBookingForm to handle and display these options for each container.
- Adjusted calculations for hazardous, reefer, and return quantities based on the new handling options.
- Updated the schema for container units and booking container lines to include handling options.
- Added migration to support the new return flag in the database.
- Enhanced various components to reflect gross weight calculations, ensuring consistency across the application.
This commit is contained in:
Marshal
2026-07-18 19:20:45 +00:00
parent a7041ee70f
commit 0dead281ce
28 changed files with 585 additions and 250 deletions

View File

@@ -40,7 +40,10 @@ import { ContractsRepository } from './contracts.repository';
import { ClearanceFeeService } from './clearance-fee.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
import {
CreateBookingContainerLineDto,
CreateBookingUnderContractDto,
} from './dto/create-booking-under-contract.dto';
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
@@ -283,8 +286,8 @@ export class ContractBookingService {
tradeDirection: contract.tradeDirection,
freightType,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
cargoTotalWeightVgm: this.resolveBulkTons(dto),
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
firstMilePickupLat: contract.firstMilePickupLat ?? null,
@@ -1451,6 +1454,53 @@ export class ContractBookingService {
);
}
/**
* Per-line handling counts. Each physical container carries its own hazardous
* / reefer / return switch (entered next to its VGM), so the count is however
* many units opted in. Forms that predate per-unit switches send line-level
* counts and no unit flags — those are honoured as-is.
*/
private handlingCounts(line: CreateBookingContainerLineDto): {
hazardousQuantity: number;
reeferQuantity: number;
returnQuantity: number;
} {
const units = line.units ?? [];
const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn);
if (!flagged) {
return {
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
reeferQuantity: Number(line.reeferQuantity ?? 0),
returnQuantity: Number(line.returnQuantity ?? 0),
};
}
return {
hazardousQuantity: units.filter((u) => u.isHazardous).length,
reeferQuantity: units.filter((u) => u.isReefer).length,
returnQuantity: units.filter((u) => u.isReturn).length,
};
}
/**
* Booking-level hazardous / reefer flags. The CONTRACT gates the service; the
* per-container opt-ins decide whether THIS shipment actually uses it. A
* container contract that allows hazardous but a booking where nobody ticked
* the switch is not a hazardous booking, and must not fire the surcharge.
* Bulk keeps the contract flag — it has its own bulk*Quantity fields.
*/
private resolveShipmentHandlingFlag(
contract: Contract,
dto: CreateBookingUnderContractDto,
field: 'hazardousQuantity' | 'reeferQuantity',
): boolean {
const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer;
if (!gated) return false;
if (contract.freightType !== 'CONTAINER') return true;
const lines = dto.containers ?? [];
if (!lines.length) return Boolean(gated);
return lines.some((l) => this.handlingCounts(l)[field] > 0);
}
/**
* Resolve the booking's equipment return from the per-line return quantities
* (container freight). The CONTRACT gates the service — like hazardous:
@@ -1470,7 +1520,7 @@ export class ContractBookingService {
const lines = dto.containers ?? [];
for (const line of lines) {
const qty = Number(line.returnQuantity ?? 0);
const qty = this.handlingCounts(line).returnQuantity;
if (qty === 0) continue;
if (contract.equipmentReturn !== 'WITH_RETURN') {
throw new BadRequestException(
@@ -1486,7 +1536,7 @@ export class ContractBookingService {
}
if (contract.equipmentReturn === 'WITH_RETURN') {
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0);
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
}
return legacy;
@@ -1524,9 +1574,10 @@ export class ContractBookingService {
);
}
const counts = this.handlingCounts(line);
const containerType = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
contract.isReefer || counts.reeferQuantity > 0,
);
const vgmPerUnit = line.units.length
@@ -1540,12 +1591,10 @@ export class ContractBookingService {
containerTypeId: containerType.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
hazardousQuantity: counts.hazardousQuantity,
reeferQuantity: counts.reeferQuantity,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0)
: 0,
contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
vgmPerUnitTons: vgmPerUnit,
totalVgmTons: totalVgm,
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
@@ -1564,6 +1613,8 @@ export class ContractBookingService {
vgmTons: unit.vgmTons,
isHazardous: unit.isHazardous ?? false,
isReefer: unit.isReefer ?? false,
isReturn:
contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
sortOrder: sortOrder++,
}),
);
@@ -1664,8 +1715,8 @@ export class ContractBookingService {
paymentCurrency: contract.paymentCurrency,
serviceTypeId: contract.serviceTypeId,
cargoTypeId: this.resolveCargoTypeId(contract, dto),
isHazardous: contract.isHazardous,
isReefer: contract.isReefer,
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
isGovernment: contract.isGovernment,
shippingLineId: null,
@@ -1680,11 +1731,11 @@ export class ContractBookingService {
containerTypeId: ct.id,
containerSize: line.containerSize,
quantity: line.quantity,
hazardousQuantity: line.hazardousQuantity ?? 0,
reeferQuantity: line.reeferQuantity ?? 0,
hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
reeferQuantity: this.handlingCounts(line).reeferQuantity,
returnQuantity:
contract.equipmentReturn === 'WITH_RETURN'
? (line.returnQuantity ?? 0)
? this.handlingCounts(line).returnQuantity
: 0,
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
totalVgmTons,

View File

@@ -50,6 +50,15 @@ export class CreateContainerUnitDto {
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReefer?: boolean;
@ApiPropertyOptional({
default: false,
description: 'This container ships back empty (equipment return).',
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isReturn?: boolean;
}
export class CreateBookingContainerLineDto {