mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +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
|
// reefer quantity) applies the REEFER surcharge even for non-reefer
|
||||||
// container types. ORed with per-container reefer in the engine.
|
// container types. ORed with per-container reefer in the engine.
|
||||||
isReefer: booking.isReefer === true || (booking.isReefer as unknown) === 'true',
|
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,
|
isGovernment: booking.isGovernment,
|
||||||
allowConsolidation,
|
allowConsolidation,
|
||||||
shippingLineId: booking.shippingLineId,
|
shippingLineId: booking.shippingLineId,
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ export class BookingContainer extends BaseEntity {
|
|||||||
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
|
@Column({ name: 'reefer_quantity', type: 'smallint', default: 0 })
|
||||||
reeferQuantity!: number;
|
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 })
|
@Column({ name: 'vgm_per_unit_tons', type: 'numeric', precision: 10, scale: 3 })
|
||||||
vgmPerUnitTons!: number;
|
vgmPerUnitTons!: number;
|
||||||
|
|
||||||
|
|||||||
@@ -263,7 +263,7 @@ export class ContractBookingService {
|
|||||||
contractType: 'NEW',
|
contractType: 'NEW',
|
||||||
customsClearingEnabled: contract.customsClearingEnabled,
|
customsClearingEnabled: contract.customsClearingEnabled,
|
||||||
customsClearingAgent: contract.customsClearingAgent ?? null,
|
customsClearingAgent: contract.customsClearingAgent ?? null,
|
||||||
equipmentReturn: dto.equipmentReturn ?? contract.equipmentReturn ?? 'WITHOUT_RETURN',
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
originYardId: route?.originYardId ?? null,
|
originYardId: route?.originYardId ?? null,
|
||||||
destinationYardId: route?.destinationYardId ?? null,
|
destinationYardId: route?.destinationYardId ?? null,
|
||||||
tradeDirection: contract.tradeDirection,
|
tradeDirection: contract.tradeDirection,
|
||||||
@@ -665,7 +665,7 @@ export class ContractBookingService {
|
|||||||
await this.bookingsRepository.update(booking.id, {
|
await this.bookingsRepository.update(booking.id, {
|
||||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||||
...(dto.equipmentReturn ? { equipmentReturn: dto.equipmentReturn } : {}),
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const loaded = await this.bookingsRepository.findByIdWithFiles(booking.id);
|
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
|
* Map each contract-scope container size to a concrete container type and
|
||||||
* persist the booking_container line + its per-unit container numbers. Weight
|
* persist the booking_container line + its per-unit container numbers. Weight
|
||||||
@@ -1466,6 +1507,10 @@ export class ContractBookingService {
|
|||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||||
reeferQuantity: line.reeferQuantity ?? 0,
|
reeferQuantity: line.reeferQuantity ?? 0,
|
||||||
|
returnQuantity:
|
||||||
|
contract.equipmentReturn === 'WITH_RETURN'
|
||||||
|
? (line.returnQuantity ?? 0)
|
||||||
|
: 0,
|
||||||
vgmPerUnitTons: vgmPerUnit,
|
vgmPerUnitTons: vgmPerUnit,
|
||||||
totalVgmTons: totalVgm,
|
totalVgmTons: totalVgm,
|
||||||
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
wagonsRequired: Math.ceil(line.quantity * Number(containerType.wagonsPerUnit ?? 1)),
|
||||||
@@ -1586,6 +1631,7 @@ export class ContractBookingService {
|
|||||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||||
isHazardous: contract.isHazardous,
|
isHazardous: contract.isHazardous,
|
||||||
isReefer: contract.isReefer,
|
isReefer: contract.isReefer,
|
||||||
|
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||||
isGovernment: contract.isGovernment,
|
isGovernment: contract.isGovernment,
|
||||||
shippingLineId: null,
|
shippingLineId: null,
|
||||||
contractRouteId: route?.id ?? null,
|
contractRouteId: route?.id ?? null,
|
||||||
@@ -1599,6 +1645,10 @@ export class ContractBookingService {
|
|||||||
quantity: line.quantity,
|
quantity: line.quantity,
|
||||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||||
reeferQuantity: line.reeferQuantity ?? 0,
|
reeferQuantity: line.reeferQuantity ?? 0,
|
||||||
|
returnQuantity:
|
||||||
|
contract.equipmentReturn === 'WITH_RETURN'
|
||||||
|
? (line.returnQuantity ?? 0)
|
||||||
|
: 0,
|
||||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||||
totalVgmTons,
|
totalVgmTons,
|
||||||
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
|
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
|
// 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
|
// contract and billed via its own clearance invoice: after counter-sign for
|
||||||
|
|||||||
@@ -77,6 +77,18 @@ export class CreateBookingContainerLineDto {
|
|||||||
@Transform(({ value }) => Number(value))
|
@Transform(({ value }) => Number(value))
|
||||||
reeferQuantity?: number;
|
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] })
|
@ApiProperty({ type: [CreateContainerUnitDto] })
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ export function allowedRateUnits(input: {
|
|||||||
return ['PER_CONTAINER', 'PER_TON'];
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
case 'DEMURRAGE':
|
case 'DEMURRAGE':
|
||||||
return ['PER_CONTAINER', 'PER_TON'];
|
return ['PER_CONTAINER', 'PER_TON'];
|
||||||
|
case 'WITH_RETURN':
|
||||||
|
// Container-only empty-return service — bills per returned container.
|
||||||
|
return ['PER_CONTAINER', 'FLAT'];
|
||||||
case 'CANCELLATION':
|
case 'CANCELLATION':
|
||||||
return ['FLAT', 'PER_INVOICE'];
|
return ['FLAT', 'PER_INVOICE'];
|
||||||
case 'CUSTOMS_CLEARANCE':
|
case 'CUSTOMS_CLEARANCE':
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const RATE_TYPES = [
|
|||||||
'OVERWEIGHT_PER_TON',
|
'OVERWEIGHT_PER_TON',
|
||||||
'HAZARD_SURCHARGE',
|
'HAZARD_SURCHARGE',
|
||||||
'REEFER_SURCHARGE',
|
'REEFER_SURCHARGE',
|
||||||
|
'RETURN_SURCHARGE',
|
||||||
'PIL_EXTRA_FEE',
|
'PIL_EXTRA_FEE',
|
||||||
'CUSTOMS_CLEARANCE',
|
'CUSTOMS_CLEARANCE',
|
||||||
] as const;
|
] as const;
|
||||||
@@ -71,6 +72,9 @@ export const RATE_TRIGGERS = [
|
|||||||
'HAZARDOUS',
|
'HAZARDOUS',
|
||||||
'OVERWEIGHT',
|
'OVERWEIGHT',
|
||||||
'REEFER',
|
'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',
|
'SHIPPING_LINE',
|
||||||
'CONSOLIDATION',
|
'CONSOLIDATION',
|
||||||
'CANCELLATION',
|
'CANCELLATION',
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ export interface BookingEvaluationInput {
|
|||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
/** Booking-level reefer flag; ORed with per-container reefer. */
|
/** Booking-level reefer flag; ORed with per-container reefer. */
|
||||||
isReefer?: boolean;
|
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;
|
isGovernment?: boolean;
|
||||||
allowConsolidation?: boolean;
|
allowConsolidation?: boolean;
|
||||||
shippingLineId?: string | null;
|
shippingLineId?: string | null;
|
||||||
@@ -228,6 +233,7 @@ export class RuleEngineService {
|
|||||||
const triggered = this.matchesTrigger(rate.trigger, {
|
const triggered = this.matchesTrigger(rate.trigger, {
|
||||||
isHazardous: input.isHazardous,
|
isHazardous: input.isHazardous,
|
||||||
hasReefer,
|
hasReefer,
|
||||||
|
withReturn: input.withReturn ?? false,
|
||||||
hasOverweight,
|
hasOverweight,
|
||||||
shippingLineMapped,
|
shippingLineMapped,
|
||||||
allowConsolidation: input.allowConsolidation ?? false,
|
allowConsolidation: input.allowConsolidation ?? false,
|
||||||
@@ -456,6 +462,7 @@ export class RuleEngineService {
|
|||||||
state: {
|
state: {
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
hasReefer: boolean;
|
hasReefer: boolean;
|
||||||
|
withReturn: boolean;
|
||||||
hasOverweight: boolean;
|
hasOverweight: boolean;
|
||||||
shippingLineMapped: boolean;
|
shippingLineMapped: boolean;
|
||||||
allowConsolidation: boolean;
|
allowConsolidation: boolean;
|
||||||
@@ -469,6 +476,8 @@ export class RuleEngineService {
|
|||||||
return truthy(state.isHazardous);
|
return truthy(state.isHazardous);
|
||||||
case 'REEFER':
|
case 'REEFER':
|
||||||
return truthy(state.hasReefer);
|
return truthy(state.hasReefer);
|
||||||
|
case 'WITH_RETURN':
|
||||||
|
return truthy(state.withReturn);
|
||||||
case 'OVERWEIGHT':
|
case 'OVERWEIGHT':
|
||||||
return truthy(state.hasOverweight);
|
return truthy(state.hasOverweight);
|
||||||
case 'SHIPPING_LINE':
|
case 'SHIPPING_LINE':
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
import { DataSource, EntityManager, ILike, In } from 'typeorm';
|
||||||
|
import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||||
|
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
@@ -343,7 +344,7 @@ export class TrainBuilderService {
|
|||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const train = await this.getEditableTrain(manager, id);
|
const train = await this.getEditableTrain(manager, id);
|
||||||
|
|
||||||
const patch: Partial<Train> = {};
|
const patch: QueryDeepPartialEntity<Train> = {};
|
||||||
if (dto.trainName !== undefined) {
|
if (dto.trainName !== undefined) {
|
||||||
patch.trainName = dto.trainName.trim() || null;
|
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.
|
// 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: 15, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "REEFER", rateType: "REEFER_SURCHARGE", rateValue: 2, rateUnit: "PER_TON" },
|
{ 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: "SHIPPING_LINE", rateType: "DOUBLE_HANDLING", rateValue: 100, rateUnit: "PER_CONTAINER" },
|
||||||
{ appliesTo: "OTHER", trigger: "CONSOLIDATION", rateType: "LASHING", rateValue: 50, 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 ──
|
// ── First/last-mile road haulage (per km) — drives the mile invoices ──
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ interface LineErrors {
|
|||||||
quantity?: string;
|
quantity?: string;
|
||||||
hazardousQuantity?: string;
|
hazardousQuantity?: string;
|
||||||
reeferQuantity?: string;
|
reeferQuantity?: string;
|
||||||
|
returnQuantity?: string;
|
||||||
units?: string;
|
units?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,6 +136,8 @@ interface ContainerLineDraft {
|
|||||||
quantity: string;
|
quantity: string;
|
||||||
hazardousQuantity: string;
|
hazardousQuantity: string;
|
||||||
reeferQuantity: string;
|
reeferQuantity: string;
|
||||||
|
/** Units of this line shipping with empty-container return (contract WITH_RETURN only). */
|
||||||
|
returnQuantity: string;
|
||||||
units: UnitDraft[];
|
units: UnitDraft[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +158,7 @@ function emptyLine(size: string): ContainerLineDraft {
|
|||||||
quantity: "1",
|
quantity: "1",
|
||||||
hazardousQuantity: "0",
|
hazardousQuantity: "0",
|
||||||
reeferQuantity: "0",
|
reeferQuantity: "0",
|
||||||
|
returnQuantity: "0",
|
||||||
units: [emptyUnit()],
|
units: [emptyUnit()],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -272,6 +276,14 @@ export default function GlCreateBookingForm() {
|
|||||||
}, [contract]);
|
}, [contract]);
|
||||||
|
|
||||||
const isContainer = contract?.freightType === "CONTAINER";
|
const isContainer = contract?.freightType === "CONTAINER";
|
||||||
|
// The contract gates the empty-container return service — like hazardous.
|
||||||
|
// WITH_RETURN contracts capture a per-line return quantity instead of the
|
||||||
|
// legacy booking-level toggle; other contracts cannot switch it on.
|
||||||
|
const contractWithReturn =
|
||||||
|
isContainer && contract?.equipmentReturn === "WITH_RETURN";
|
||||||
|
// Legacy contracts (no equipment return chosen at creation) keep the old
|
||||||
|
// booking-level toggle.
|
||||||
|
const legacyReturnToggle = isContainer && !contract?.equipmentReturn;
|
||||||
// Intercity shipments ride a passing import/export train staff pick at
|
// Intercity shipments ride a passing import/export train staff pick at
|
||||||
// finalize time — no shipment day is chosen and no window gate applies.
|
// finalize time — no shipment day is chosen and no window gate applies.
|
||||||
const isIntercity = contract?.tradeDirection === "DOMESTIC";
|
const isIntercity = contract?.tradeDirection === "DOMESTIC";
|
||||||
@@ -354,6 +366,7 @@ export default function GlCreateBookingForm() {
|
|||||||
quantity: String(Math.max(1, c.quantity)),
|
quantity: String(Math.max(1, c.quantity)),
|
||||||
hazardousQuantity: String(c.hazardousQuantity ?? 0),
|
hazardousQuantity: String(c.hazardousQuantity ?? 0),
|
||||||
reeferQuantity: String(c.reeferQuantity ?? 0),
|
reeferQuantity: String(c.reeferQuantity ?? 0),
|
||||||
|
returnQuantity: "0",
|
||||||
units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit),
|
units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
@@ -390,6 +403,7 @@ export default function GlCreateBookingForm() {
|
|||||||
quantity: String(qty),
|
quantity: String(qty),
|
||||||
hazardousQuantity: "0",
|
hazardousQuantity: "0",
|
||||||
reeferQuantity: "0",
|
reeferQuantity: "0",
|
||||||
|
returnQuantity: "0",
|
||||||
units: Array.from({ length: qty }, emptyUnit),
|
units: Array.from({ length: qty }, emptyUnit),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -542,6 +556,8 @@ export default function GlCreateBookingForm() {
|
|||||||
quantity: String(imported.length),
|
quantity: String(imported.length),
|
||||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||||
|
returnQuantity:
|
||||||
|
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
|
||||||
units: imported.map((r) => ({
|
units: imported.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
sealNumber: r.sealNumber,
|
sealNumber: r.sealNumber,
|
||||||
@@ -614,9 +630,17 @@ export default function GlCreateBookingForm() {
|
|||||||
errs.reeferQuantity = `Can't exceed the ${qty} container(s) in this line.`;
|
errs.reeferQuantity = `Can't exceed the ${qty} container(s) in this line.`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (contractWithReturn) {
|
||||||
|
const w = Number(line.returnQuantity || 0);
|
||||||
|
if (Number.isNaN(w) || w < 0) {
|
||||||
|
errs.returnQuantity = "Enter a valid return quantity.";
|
||||||
|
} else if (w > qty) {
|
||||||
|
errs.returnQuantity = `Can't exceed the ${qty} container(s) in this line.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
return errs;
|
return errs;
|
||||||
});
|
});
|
||||||
}, [isContainer, contract, containerLines]);
|
}, [isContainer, contract, containerLines, contractWithReturn]);
|
||||||
|
|
||||||
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
|
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
|
||||||
|
|
||||||
@@ -653,7 +677,11 @@ export default function GlCreateBookingForm() {
|
|||||||
const cargoValid = isContainer
|
const cargoValid = isContainer
|
||||||
? lineErrors.every(
|
? lineErrors.every(
|
||||||
(e) =>
|
(e) =>
|
||||||
!e.quantity && !e.units && !e.hazardousQuantity && !e.reeferQuantity,
|
!e.quantity &&
|
||||||
|
!e.units &&
|
||||||
|
!e.hazardousQuantity &&
|
||||||
|
!e.reeferQuantity &&
|
||||||
|
!e.returnQuantity,
|
||||||
) &&
|
) &&
|
||||||
unitErrors.every((line) =>
|
unitErrors.every((line) =>
|
||||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||||
@@ -675,8 +703,10 @@ export default function GlCreateBookingForm() {
|
|||||||
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
||||||
: {}),
|
: {}),
|
||||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||||
// Equipment return is a container concern — bulk keeps the contract default.
|
// Equipment return: WITH_RETURN contracts derive it server-side from the
|
||||||
...(isContainer
|
// per-line return quantities; only legacy contracts (no value chosen at
|
||||||
|
// creation) still send the booking-level toggle. Bulk keeps the default.
|
||||||
|
...(legacyReturnToggle
|
||||||
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
|
? { equipmentReturn: withReturn ? "WITH_RETURN" : "WITHOUT_RETURN" }
|
||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
@@ -689,6 +719,9 @@ export default function GlCreateBookingForm() {
|
|||||||
quantity: Number(l.quantity),
|
quantity: Number(l.quantity),
|
||||||
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
|
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
|
||||||
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
||||||
|
...(contractWithReturn
|
||||||
|
? { returnQuantity: Number(l.returnQuantity || 0) }
|
||||||
|
: {}),
|
||||||
units: l.units.map((u) => ({
|
units: l.units.map((u) => ({
|
||||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||||
@@ -1168,6 +1201,28 @@ export default function GlCreateBookingForm() {
|
|||||||
styles={fieldStyles}
|
styles={fieldStyles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{contractWithReturn && (
|
||||||
|
<TextInput
|
||||||
|
type="number"
|
||||||
|
onKeyDown={blockNegative}
|
||||||
|
label="With return qty"
|
||||||
|
description="Containers EDR returns empty"
|
||||||
|
min={0}
|
||||||
|
value={line.returnQuantity}
|
||||||
|
error={
|
||||||
|
showErrors
|
||||||
|
? lineErrors[lineIdx]?.returnQuantity
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
patchLine(lineIdx, {
|
||||||
|
returnQuantity: e.currentTarget.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
radius={10}
|
||||||
|
styles={fieldStyles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<StepLabel>Per-container details</StepLabel>
|
<StepLabel>Per-container details</StepLabel>
|
||||||
@@ -1323,7 +1378,9 @@ export default function GlCreateBookingForm() {
|
|||||||
</StepCard>
|
</StepCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isContainer ? (
|
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
|
||||||
|
return quantities above, WITHOUT_RETURN contracts locked it off. */}
|
||||||
|
{legacyReturnToggle ? (
|
||||||
<StepCard>
|
<StepCard>
|
||||||
<StepHeader
|
<StepHeader
|
||||||
icon={<Repeat size={22} />}
|
icon={<Repeat size={22} />}
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ const RATE_TRIGGERS = [
|
|||||||
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
||||||
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
||||||
{ label: "Reefer cargo", value: "REEFER" },
|
{ label: "Reefer cargo", value: "REEFER" },
|
||||||
|
{ label: "Empty container return", value: "WITH_RETURN" },
|
||||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
{ label: "Consolidation", value: "CONSOLIDATION" },
|
||||||
{ label: "Cancellation", value: "CANCELLATION" },
|
{ label: "Cancellation", value: "CANCELLATION" },
|
||||||
@@ -161,6 +162,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
|||||||
case "HAZARDOUS":
|
case "HAZARDOUS":
|
||||||
case "DEMURRAGE":
|
case "DEMURRAGE":
|
||||||
return ["PER_CONTAINER", "PER_TON"];
|
return ["PER_CONTAINER", "PER_TON"];
|
||||||
|
case "WITH_RETURN":
|
||||||
|
// Container-only service — bills per returned container.
|
||||||
|
return ["PER_CONTAINER", "FLAT"];
|
||||||
case "CANCELLATION":
|
case "CANCELLATION":
|
||||||
return ["FLAT", "PER_INVOICE"];
|
return ["FLAT", "PER_INVOICE"];
|
||||||
case "CUSTOMS_CLEARANCE":
|
case "CUSTOMS_CLEARANCE":
|
||||||
|
|||||||
@@ -592,8 +592,17 @@ export default function NewContractPage({
|
|||||||
: Freight.ContractFreightType.Bulk,
|
: Freight.ContractFreightType.Bulk,
|
||||||
serviceTypeId: data.serviceTypeId,
|
serviceTypeId: data.serviceTypeId,
|
||||||
paymentCurrency: data.paymentCurrency,
|
paymentCurrency: data.paymentCurrency,
|
||||||
// Equipment return is decided at booking time, not on the contract. Omit
|
// Empty-container return is a contract-level opt-in (container freight
|
||||||
// it here so we don't send a value the contract API rejects.
|
// only) — like hazardous. Per-booking return quantities are still set at
|
||||||
|
// booking time, but only on contracts created WITH_RETURN.
|
||||||
|
...(isContainer
|
||||||
|
? {
|
||||||
|
equipmentReturn:
|
||||||
|
data.equipmentReturn === "with_return"
|
||||||
|
? "WITH_RETURN"
|
||||||
|
: "WITHOUT_RETURN",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
isHazardous: data.isHazardous,
|
isHazardous: data.isHazardous,
|
||||||
// Reefer is a contract-level flag for both container and bulk.
|
// Reefer is a contract-level flag for both container and bulk.
|
||||||
isReefer: data.isRefrigerated,
|
isReefer: data.isRefrigerated,
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ function NewShipmentBookingForm({
|
|||||||
isContainer: contract.freightType === "CONTAINER",
|
isContainer: contract.freightType === "CONTAINER",
|
||||||
isHazardous: contract.isHazardous ?? false,
|
isHazardous: contract.isHazardous ?? false,
|
||||||
isReefer: contract.isReefer ?? false,
|
isReefer: contract.isReefer ?? false,
|
||||||
|
withReturnService:
|
||||||
|
contract.freightType === "CONTAINER" &&
|
||||||
|
contract.equipmentReturn === "WITH_RETURN",
|
||||||
unitOfMeasure: bulkUnitOfMeasure(contract),
|
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||||
// Intercity rides a passing train staff pick later — no date to choose.
|
// Intercity rides a passing train staff pick later — no date to choose.
|
||||||
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
||||||
@@ -296,6 +299,12 @@ function NewShipmentBookingForm({
|
|||||||
values: ShipmentFormValues,
|
values: ShipmentFormValues,
|
||||||
): Freight.CreateBookingUnderContractDto {
|
): Freight.CreateBookingUnderContractDto {
|
||||||
const isContainer = contract.freightType === "CONTAINER";
|
const isContainer = contract.freightType === "CONTAINER";
|
||||||
|
// WITH_RETURN contracts carry a per-line return quantity and the server
|
||||||
|
// derives the booking's equipment return from it; only legacy contracts
|
||||||
|
// (no equipment return chosen at creation) still send the toggle.
|
||||||
|
const withReturnService =
|
||||||
|
isContainer && contract.equipmentReturn === "WITH_RETURN";
|
||||||
|
const legacyReturnToggle = isContainer && !contract.equipmentReturn;
|
||||||
return {
|
return {
|
||||||
...(values.contractRouteId
|
...(values.contractRouteId
|
||||||
? { contractRouteId: values.contractRouteId }
|
? { contractRouteId: values.contractRouteId }
|
||||||
@@ -304,10 +313,16 @@ function NewShipmentBookingForm({
|
|||||||
...(values.scheduledDate
|
...(values.scheduledDate
|
||||||
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(legacyReturnToggle
|
||||||
|
? {
|
||||||
|
equipmentReturn: values.withReturn
|
||||||
|
? "WITH_RETURN"
|
||||||
|
: "WITHOUT_RETURN",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
// Equipment return is a container concern — bulk keeps the contract default.
|
// Equipment return is a container concern — bulk keeps the contract default.
|
||||||
...(isContainer
|
...(isContainer
|
||||||
? {
|
? {
|
||||||
equipmentReturn: values.withReturn ? "WITH_RETURN" : "WITHOUT_RETURN",
|
|
||||||
containers: values.containers
|
containers: values.containers
|
||||||
.filter((l) => Number(l.quantity) >= 1)
|
.filter((l) => Number(l.quantity) >= 1)
|
||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
@@ -315,6 +330,9 @@ function NewShipmentBookingForm({
|
|||||||
quantity: Number(l.quantity),
|
quantity: Number(l.quantity),
|
||||||
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
|
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
|
||||||
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
||||||
|
...(withReturnService
|
||||||
|
? { returnQuantity: Number(l.returnQuantity || 0) }
|
||||||
|
: {}),
|
||||||
units: l.units.map((u) => ({
|
units: l.units.map((u) => ({
|
||||||
containerNumber: u.containerNumber,
|
containerNumber: u.containerNumber,
|
||||||
sealNumber: u.sealNumber || undefined,
|
sealNumber: u.sealNumber || undefined,
|
||||||
@@ -443,9 +461,10 @@ function NewShipmentBookingForm({
|
|||||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||||
<RouteStep form={form} contract={contract} routes={routes} />
|
<RouteStep form={form} contract={contract} routes={routes} />
|
||||||
<CargoStep form={form} contract={contract} />
|
<CargoStep form={form} contract={contract} />
|
||||||
{contract.freightType === "CONTAINER" && (
|
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
|
||||||
<EquipmentReturnStep form={form} />
|
return quantities in the cargo step; WITHOUT_RETURN locked it off. */}
|
||||||
)}
|
{contract.freightType === "CONTAINER" &&
|
||||||
|
!contract.equipmentReturn && <EquipmentReturnStep form={form} />}
|
||||||
<ScheduleStep form={form} contract={contract} routes={routes} />
|
<ScheduleStep form={form} contract={contract} routes={routes} />
|
||||||
<NotesSection form={form} />
|
<NotesSection form={form} />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -1075,6 +1094,7 @@ function CargoStep({
|
|||||||
quantity: "1",
|
quantity: "1",
|
||||||
hazardousQuantity: "0",
|
hazardousQuantity: "0",
|
||||||
reeferQuantity: "0",
|
reeferQuantity: "0",
|
||||||
|
returnQuantity: "0",
|
||||||
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
||||||
})),
|
})),
|
||||||
{ shouldValidate: false },
|
{ shouldValidate: false },
|
||||||
@@ -1117,6 +1137,7 @@ function CargoStep({
|
|||||||
quantity: "1",
|
quantity: "1",
|
||||||
hazardousQuantity: "0",
|
hazardousQuantity: "0",
|
||||||
reeferQuantity: "0",
|
reeferQuantity: "0",
|
||||||
|
returnQuantity: "0",
|
||||||
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -1126,6 +1147,8 @@ function CargoStep({
|
|||||||
quantity: String(imported.length),
|
quantity: String(imported.length),
|
||||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||||
|
returnQuantity:
|
||||||
|
current.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
|
||||||
units: imported.map((r) => ({
|
units: imported.map((r) => ({
|
||||||
containerNumber: r.containerNumber,
|
containerNumber: r.containerNumber,
|
||||||
sealNumber: r.sealNumber,
|
sealNumber: r.sealNumber,
|
||||||
@@ -1234,6 +1257,7 @@ function CargoStep({
|
|||||||
size={line.containerSize}
|
size={line.containerSize}
|
||||||
isHazardous={contract.isHazardous}
|
isHazardous={contract.isHazardous}
|
||||||
isReefer={contract.isReefer}
|
isReefer={contract.isReefer}
|
||||||
|
withReturnService={contract.equipmentReturn === "WITH_RETURN"}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{sizes.length === 0 && (
|
{sizes.length === 0 && (
|
||||||
@@ -1433,12 +1457,15 @@ function ContainerLineEditor({
|
|||||||
size,
|
size,
|
||||||
isHazardous,
|
isHazardous,
|
||||||
isReefer,
|
isReefer,
|
||||||
|
withReturnService,
|
||||||
}: {
|
}: {
|
||||||
form: ShipmentForm;
|
form: ShipmentForm;
|
||||||
index: number;
|
index: number;
|
||||||
size: "20ft" | "40ft";
|
size: "20ft" | "40ft";
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
isReefer: boolean;
|
isReefer: boolean;
|
||||||
|
/** Contract opted into empty-container return — capture the per-line quantity. */
|
||||||
|
withReturnService?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const line = form.watch(`containers.${index}`);
|
const line = form.watch(`containers.${index}`);
|
||||||
const quantity = Number(line?.quantity || 0);
|
const quantity = Number(line?.quantity || 0);
|
||||||
@@ -1519,6 +1546,25 @@ function ContainerLineEditor({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{withReturnService && (
|
||||||
|
<Controller
|
||||||
|
name={`containers.${index}.returnQuantity`}
|
||||||
|
control={form.control}
|
||||||
|
render={({ field, fieldState }) => (
|
||||||
|
<TextInput
|
||||||
|
{...field}
|
||||||
|
type="number"
|
||||||
|
onKeyDown={blockNegative}
|
||||||
|
label="With return qty"
|
||||||
|
description="Containers EDR returns empty"
|
||||||
|
min={0}
|
||||||
|
error={fieldState.error?.message}
|
||||||
|
radius={10}
|
||||||
|
styles={fieldStyles}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<StepLabel>Per-container details</StepLabel>
|
<StepLabel>Per-container details</StepLabel>
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export const contractFormSchema = z
|
|||||||
}),
|
}),
|
||||||
equipmentReturn: z
|
equipmentReturn: z
|
||||||
.enum(["with_return", "without_return"])
|
.enum(["with_return", "without_return"])
|
||||||
.default("with_return"),
|
.default("without_return"),
|
||||||
customsClearingEnabled: z.boolean().default(false),
|
customsClearingEnabled: z.boolean().default(false),
|
||||||
customsClearingAgent: z.string().default(""),
|
customsClearingAgent: z.string().default(""),
|
||||||
|
|
||||||
@@ -275,7 +275,7 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
|||||||
paymentCurrency: undefined,
|
paymentCurrency: undefined,
|
||||||
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
||||||
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||||
equipmentReturn: "with_return",
|
equipmentReturn: "without_return",
|
||||||
customsClearingEnabled: false,
|
customsClearingEnabled: false,
|
||||||
customsClearingAgent: "",
|
customsClearingAgent: "",
|
||||||
|
|
||||||
|
|||||||
@@ -126,11 +126,12 @@ export function Step1ContractType({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Equipment return / customs ──
|
// ── Equipment return / customs ──
|
||||||
|
// Stored uppercase on the contract (WITH_RETURN / WITHOUT_RETURN).
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"equipmentReturn",
|
"equipmentReturn",
|
||||||
contract.equipmentReturn === "without_return"
|
(contract.equipmentReturn ?? "").toUpperCase() === "WITH_RETURN"
|
||||||
? "without_return"
|
? "with_return"
|
||||||
: "with_return",
|
: "without_return",
|
||||||
);
|
);
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"customsClearingEnabled",
|
"customsClearingEnabled",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||||
import { Flame, Snowflake } from "lucide-react";
|
import { Flame, RotateCcw, Snowflake } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Group,
|
Group,
|
||||||
@@ -242,6 +242,28 @@ export function Step3CargoScope({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Empty-container return is a container-only service. Like hazardous,
|
||||||
|
enabling it here adds the return surcharge as a unit rate; at
|
||||||
|
booking time the customer/GL sets how many containers return. */}
|
||||||
|
{cargoType === "container" && (
|
||||||
|
<Controller
|
||||||
|
name="equipmentReturn"
|
||||||
|
control={form.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<ToggleRow
|
||||||
|
icon={<RotateCcw size={18} />}
|
||||||
|
iconBg="#EAF6EC"
|
||||||
|
iconColor="#1E7B34"
|
||||||
|
title="Empty Container Return"
|
||||||
|
description="EDR returns the empty containers — applies a per-container return surcharge."
|
||||||
|
checked={field.value === "with_return"}
|
||||||
|
onChange={(v) =>
|
||||||
|
field.onChange(v ? "with_return" : "without_return")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export interface ShipmentValidationContext {
|
|||||||
isContainer: boolean;
|
isContainer: boolean;
|
||||||
isHazardous: boolean;
|
isHazardous: boolean;
|
||||||
isReefer: boolean;
|
isReefer: boolean;
|
||||||
|
/**
|
||||||
|
* Contract was created with the empty-container return service
|
||||||
|
* (equipment_return = WITH_RETURN, container freight only). Enables the
|
||||||
|
* per-line "with return" quantity, validated like hazardous/reefer.
|
||||||
|
*/
|
||||||
|
withReturnService?: boolean;
|
||||||
unitOfMeasure?: "PER_TON" | "PER_ITEM";
|
unitOfMeasure?: "PER_TON" | "PER_ITEM";
|
||||||
/**
|
/**
|
||||||
* Intercity (DOMESTIC) shipments ride a passing import/export train that
|
* Intercity (DOMESTIC) shipments ride a passing import/export train that
|
||||||
@@ -52,6 +58,7 @@ const containerLineSchema = z.object({
|
|||||||
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 1, "At least 1."),
|
.refine((v) => !Number.isNaN(Number(v)) && Number(v) >= 1, "At least 1."),
|
||||||
hazardousQuantity: z.string().default("0"),
|
hazardousQuantity: z.string().default("0"),
|
||||||
reeferQuantity: z.string().default("0"),
|
reeferQuantity: z.string().default("0"),
|
||||||
|
returnQuantity: z.string().default("0"),
|
||||||
units: z.array(containerUnitSchema).default([]),
|
units: z.array(containerUnitSchema).default([]),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,6 +150,22 @@ export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ctx.withReturnService) {
|
||||||
|
const w = Number(line.returnQuantity || 0);
|
||||||
|
if (w < 0) {
|
||||||
|
refineCtx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["containers", i, "returnQuantity"],
|
||||||
|
message: "Enter a valid return quantity.",
|
||||||
|
});
|
||||||
|
} else if (w > qty) {
|
||||||
|
refineCtx.addIssue({
|
||||||
|
code: "custom",
|
||||||
|
path: ["containers", i, "returnQuantity"],
|
||||||
|
message: `Can't exceed the ${qty} container(s) in this line.`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const isPerItem = ctx.unitOfMeasure === "PER_ITEM";
|
const isPerItem = ctx.unitOfMeasure === "PER_ITEM";
|
||||||
|
|||||||
@@ -733,6 +733,11 @@ export interface CreateBookingContainerLineDto {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
hazardousQuantity?: number;
|
hazardousQuantity?: number;
|
||||||
reeferQuantity?: number;
|
reeferQuantity?: number;
|
||||||
|
/**
|
||||||
|
* How many units ship with empty-container return (≤ quantity). Only allowed
|
||||||
|
* when the contract was created WITH_RETURN (container freight only).
|
||||||
|
*/
|
||||||
|
returnQuantity?: number;
|
||||||
units: CreateContainerUnitDto[];
|
units: CreateContainerUnitDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user