mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
implement empty-container return service: add return quantity handling, update related DTOs, services, and UI components
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 ──
|
||||
|
||||
@@ -97,6 +97,7 @@ interface LineErrors {
|
||||
quantity?: string;
|
||||
hazardousQuantity?: string;
|
||||
reeferQuantity?: string;
|
||||
returnQuantity?: string;
|
||||
units?: string;
|
||||
}
|
||||
|
||||
@@ -135,6 +136,8 @@ interface ContainerLineDraft {
|
||||
quantity: string;
|
||||
hazardousQuantity: string;
|
||||
reeferQuantity: string;
|
||||
/** Units of this line shipping with empty-container return (contract WITH_RETURN only). */
|
||||
returnQuantity: string;
|
||||
units: UnitDraft[];
|
||||
}
|
||||
|
||||
@@ -155,6 +158,7 @@ function emptyLine(size: string): ContainerLineDraft {
|
||||
quantity: "1",
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
returnQuantity: "0",
|
||||
units: [emptyUnit()],
|
||||
};
|
||||
}
|
||||
@@ -272,6 +276,14 @@ export default function GlCreateBookingForm() {
|
||||
}, [contract]);
|
||||
|
||||
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
|
||||
// finalize time — no shipment day is chosen and no window gate applies.
|
||||
const isIntercity = contract?.tradeDirection === "DOMESTIC";
|
||||
@@ -354,6 +366,7 @@ export default function GlCreateBookingForm() {
|
||||
quantity: String(Math.max(1, c.quantity)),
|
||||
hazardousQuantity: String(c.hazardousQuantity ?? 0),
|
||||
reeferQuantity: String(c.reeferQuantity ?? 0),
|
||||
returnQuantity: "0",
|
||||
units: Array.from({ length: Math.max(1, c.quantity) }, emptyUnit),
|
||||
})),
|
||||
);
|
||||
@@ -390,6 +403,7 @@ export default function GlCreateBookingForm() {
|
||||
quantity: String(qty),
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
returnQuantity: "0",
|
||||
units: Array.from({ length: qty }, emptyUnit),
|
||||
};
|
||||
}),
|
||||
@@ -542,6 +556,8 @@ export default function GlCreateBookingForm() {
|
||||
quantity: String(imported.length),
|
||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||
returnQuantity:
|
||||
prev.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
@@ -614,9 +630,17 @@ export default function GlCreateBookingForm() {
|
||||
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;
|
||||
});
|
||||
}, [isContainer, contract, containerLines]);
|
||||
}, [isContainer, contract, containerLines, contractWithReturn]);
|
||||
|
||||
const bulkUom = contract ? bulkUnitOfMeasure(contract) : "PER_TON";
|
||||
|
||||
@@ -653,7 +677,11 @@ export default function GlCreateBookingForm() {
|
||||
const cargoValid = isContainer
|
||||
? lineErrors.every(
|
||||
(e) =>
|
||||
!e.quantity && !e.units && !e.hazardousQuantity && !e.reeferQuantity,
|
||||
!e.quantity &&
|
||||
!e.units &&
|
||||
!e.hazardousQuantity &&
|
||||
!e.reeferQuantity &&
|
||||
!e.returnQuantity,
|
||||
) &&
|
||||
unitErrors.every((line) =>
|
||||
line.every((e) => !e.containerNumber && !e.vgmTons),
|
||||
@@ -675,8 +703,10 @@ export default function GlCreateBookingForm() {
|
||||
? { scheduledDate: new Date(scheduledDate).toISOString() }
|
||||
: {}),
|
||||
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||
// Equipment return is a container concern — bulk keeps the contract default.
|
||||
...(isContainer
|
||||
// Equipment return: WITH_RETURN contracts derive it server-side from the
|
||||
// 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" }
|
||||
: {}),
|
||||
};
|
||||
@@ -689,6 +719,9 @@ export default function GlCreateBookingForm() {
|
||||
quantity: Number(l.quantity),
|
||||
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
|
||||
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
||||
...(contractWithReturn
|
||||
? { returnQuantity: Number(l.returnQuantity || 0) }
|
||||
: {}),
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber.trim().toUpperCase(),
|
||||
...(u.sealNumber ? { sealNumber: u.sealNumber } : {}),
|
||||
@@ -1168,6 +1201,28 @@ export default function GlCreateBookingForm() {
|
||||
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>
|
||||
|
||||
<StepLabel>Per-container details</StepLabel>
|
||||
@@ -1323,7 +1378,9 @@ export default function GlCreateBookingForm() {
|
||||
</StepCard>
|
||||
)}
|
||||
|
||||
{isContainer ? (
|
||||
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
|
||||
return quantities above, WITHOUT_RETURN contracts locked it off. */}
|
||||
{legacyReturnToggle ? (
|
||||
<StepCard>
|
||||
<StepHeader
|
||||
icon={<Repeat size={22} />}
|
||||
|
||||
@@ -136,6 +136,7 @@ const RATE_TRIGGERS = [
|
||||
{ label: "Hazardous cargo", value: "HAZARDOUS" },
|
||||
{ label: "Overweight (per excess ton)", value: "OVERWEIGHT" },
|
||||
{ label: "Reefer cargo", value: "REEFER" },
|
||||
{ label: "Empty container return", value: "WITH_RETURN" },
|
||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||
{ label: "Consolidation", value: "CONSOLIDATION" },
|
||||
{ label: "Cancellation", value: "CANCELLATION" },
|
||||
@@ -161,6 +162,9 @@ const allowedRateUnits = (appliesTo: string, trigger: string): string[] => {
|
||||
case "HAZARDOUS":
|
||||
case "DEMURRAGE":
|
||||
return ["PER_CONTAINER", "PER_TON"];
|
||||
case "WITH_RETURN":
|
||||
// Container-only service — bills per returned container.
|
||||
return ["PER_CONTAINER", "FLAT"];
|
||||
case "CANCELLATION":
|
||||
return ["FLAT", "PER_INVOICE"];
|
||||
case "CUSTOMS_CLEARANCE":
|
||||
|
||||
@@ -592,8 +592,17 @@ export default function NewContractPage({
|
||||
: Freight.ContractFreightType.Bulk,
|
||||
serviceTypeId: data.serviceTypeId,
|
||||
paymentCurrency: data.paymentCurrency,
|
||||
// Equipment return is decided at booking time, not on the contract. Omit
|
||||
// it here so we don't send a value the contract API rejects.
|
||||
// Empty-container return is a contract-level opt-in (container freight
|
||||
// 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,
|
||||
// Reefer is a contract-level flag for both container and bulk.
|
||||
isReefer: data.isRefrigerated,
|
||||
|
||||
@@ -258,6 +258,9 @@ function NewShipmentBookingForm({
|
||||
isContainer: contract.freightType === "CONTAINER",
|
||||
isHazardous: contract.isHazardous ?? false,
|
||||
isReefer: contract.isReefer ?? false,
|
||||
withReturnService:
|
||||
contract.freightType === "CONTAINER" &&
|
||||
contract.equipmentReturn === "WITH_RETURN",
|
||||
unitOfMeasure: bulkUnitOfMeasure(contract),
|
||||
// Intercity rides a passing train staff pick later — no date to choose.
|
||||
requiresDate: contract.tradeDirection !== "DOMESTIC",
|
||||
@@ -296,6 +299,12 @@ function NewShipmentBookingForm({
|
||||
values: ShipmentFormValues,
|
||||
): Freight.CreateBookingUnderContractDto {
|
||||
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 {
|
||||
...(values.contractRouteId
|
||||
? { contractRouteId: values.contractRouteId }
|
||||
@@ -304,10 +313,16 @@ function NewShipmentBookingForm({
|
||||
...(values.scheduledDate
|
||||
? { 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.
|
||||
...(isContainer
|
||||
? {
|
||||
equipmentReturn: values.withReturn ? "WITH_RETURN" : "WITHOUT_RETURN",
|
||||
containers: values.containers
|
||||
.filter((l) => Number(l.quantity) >= 1)
|
||||
.map((l) => ({
|
||||
@@ -315,6 +330,9 @@ function NewShipmentBookingForm({
|
||||
quantity: Number(l.quantity),
|
||||
hazardousQuantity: Number(l.hazardousQuantity || 0) || undefined,
|
||||
reeferQuantity: Number(l.reeferQuantity || 0) || undefined,
|
||||
...(withReturnService
|
||||
? { returnQuantity: Number(l.returnQuantity || 0) }
|
||||
: {}),
|
||||
units: l.units.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber || undefined,
|
||||
@@ -443,9 +461,10 @@ function NewShipmentBookingForm({
|
||||
<Stack gap="lg" className="mx-auto max-w-4xl">
|
||||
<RouteStep form={form} contract={contract} routes={routes} />
|
||||
<CargoStep form={form} contract={contract} />
|
||||
{contract.freightType === "CONTAINER" && (
|
||||
<EquipmentReturnStep form={form} />
|
||||
)}
|
||||
{/* Legacy contracts only — WITH_RETURN contracts capture per-line
|
||||
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} />
|
||||
<NotesSection form={form} />
|
||||
</Stack>
|
||||
@@ -1075,6 +1094,7 @@ function CargoStep({
|
||||
quantity: "1",
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
returnQuantity: "0",
|
||||
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
||||
})),
|
||||
{ shouldValidate: false },
|
||||
@@ -1117,6 +1137,7 @@ function CargoStep({
|
||||
quantity: "1",
|
||||
hazardousQuantity: "0",
|
||||
reeferQuantity: "0",
|
||||
returnQuantity: "0",
|
||||
units: [{ containerNumber: "", sealNumber: "", vgmTons: "" }],
|
||||
}
|
||||
);
|
||||
@@ -1126,6 +1147,8 @@ function CargoStep({
|
||||
quantity: String(imported.length),
|
||||
hazardousQuantity: String(imported.filter((r) => r.hazardous).length),
|
||||
reeferQuantity: String(imported.filter((r) => r.reefer).length),
|
||||
returnQuantity:
|
||||
current.find((l) => l.containerSize === size)?.returnQuantity ?? "0",
|
||||
units: imported.map((r) => ({
|
||||
containerNumber: r.containerNumber,
|
||||
sealNumber: r.sealNumber,
|
||||
@@ -1234,6 +1257,7 @@ function CargoStep({
|
||||
size={line.containerSize}
|
||||
isHazardous={contract.isHazardous}
|
||||
isReefer={contract.isReefer}
|
||||
withReturnService={contract.equipmentReturn === "WITH_RETURN"}
|
||||
/>
|
||||
))}
|
||||
{sizes.length === 0 && (
|
||||
@@ -1433,12 +1457,15 @@ function ContainerLineEditor({
|
||||
size,
|
||||
isHazardous,
|
||||
isReefer,
|
||||
withReturnService,
|
||||
}: {
|
||||
form: ShipmentForm;
|
||||
index: number;
|
||||
size: "20ft" | "40ft";
|
||||
isHazardous: boolean;
|
||||
isReefer: boolean;
|
||||
/** Contract opted into empty-container return — capture the per-line quantity. */
|
||||
withReturnService?: boolean;
|
||||
}) {
|
||||
const line = form.watch(`containers.${index}`);
|
||||
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>
|
||||
|
||||
<StepLabel>Per-container details</StepLabel>
|
||||
|
||||
@@ -162,7 +162,7 @@ export const contractFormSchema = z
|
||||
}),
|
||||
equipmentReturn: z
|
||||
.enum(["with_return", "without_return"])
|
||||
.default("with_return"),
|
||||
.default("without_return"),
|
||||
customsClearingEnabled: z.boolean().default(false),
|
||||
customsClearingAgent: z.string().default(""),
|
||||
|
||||
@@ -275,7 +275,7 @@ export const initialContractFormValues: DeepPartial<ContractFormValues> = {
|
||||
paymentCurrency: undefined,
|
||||
firstMile: { enabled: false, pickUpAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
lastMile: { enabled: false, deliveryAddress: "", exactLocation: "", lat: null, lng: null },
|
||||
equipmentReturn: "with_return",
|
||||
equipmentReturn: "without_return",
|
||||
customsClearingEnabled: false,
|
||||
customsClearingAgent: "",
|
||||
|
||||
|
||||
@@ -126,11 +126,12 @@ export function Step1ContractType({
|
||||
});
|
||||
|
||||
// ── Equipment return / customs ──
|
||||
// Stored uppercase on the contract (WITH_RETURN / WITHOUT_RETURN).
|
||||
form.setValue(
|
||||
"equipmentReturn",
|
||||
contract.equipmentReturn === "without_return"
|
||||
? "without_return"
|
||||
: "with_return",
|
||||
(contract.equipmentReturn ?? "").toUpperCase() === "WITH_RETURN"
|
||||
? "with_return"
|
||||
: "without_return",
|
||||
);
|
||||
form.setValue(
|
||||
"customsClearingEnabled",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { Controller, type UseFormReturn } from "react-hook-form";
|
||||
import { Flame, Snowflake } from "lucide-react";
|
||||
import { Flame, RotateCcw, Snowflake } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
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>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
@@ -18,6 +18,12 @@ export interface ShipmentValidationContext {
|
||||
isContainer: boolean;
|
||||
isHazardous: 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";
|
||||
/**
|
||||
* 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."),
|
||||
hazardousQuantity: z.string().default("0"),
|
||||
reeferQuantity: z.string().default("0"),
|
||||
returnQuantity: z.string().default("0"),
|
||||
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 {
|
||||
const isPerItem = ctx.unitOfMeasure === "PER_ITEM";
|
||||
|
||||
@@ -733,6 +733,11 @@ export interface CreateBookingContainerLineDto {
|
||||
quantity: number;
|
||||
hazardousQuantity?: 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[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user