Merge pull request #719 from Tria-plc/freight_feature/usermanagement

implement empty-container return service: add return quantity handlin…
This commit is contained in:
marshal
2026-07-16 02:06:08 +03:00
committed by GitHub
20 changed files with 324 additions and 20 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: "",

View File

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

View File

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

View File

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

View File

@@ -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[];
}