From 234a74e812b85da15f3bd80d2511af9cfe5758f2 Mon Sep 17 00:00:00 2001 From: Marshal Date: Wed, 15 Jul 2026 22:48:04 +0000 Subject: [PATCH] implement empty-container return service: add return quantity handling, update related DTOs, services, and UI components --- ...270000000000-AddContainerReturnQuantity.ts | 27 ++++++++ .../bookings/booking-pricing.service.ts | 5 ++ .../entities/booking-container.entity.ts | 4 ++ .../contracts/contract-booking.service.ts | 54 ++++++++++++++- .../contracts/contract-pricing.service.ts | 19 ++++++ .../dto/create-booking-under-contract.dto.ts | 12 ++++ .../rule-engine/entities/rate-unit.util.ts | 3 + .../rule-engine/entities/rate.entity.ts | 4 ++ .../rule-engine/rule-engine.service.ts | 9 +++ .../modules/trains/train-builder.service.ts | 3 +- .../src/seed/pricing-data.seeder.ts | 3 + .../contracts/GlCreateBookingForm.tsx | 67 +++++++++++++++++-- .../src/pages/ruleEngine/config/resources.ts | 4 ++ .../src/pages/contracts/NewContractPage.tsx | 13 +++- .../src/pages/contracts/NewShipmentPage.tsx | 54 +++++++++++++-- .../contracts/new-contract-form/schema.ts | 4 +- .../new-contract-form/step1-contract-type.tsx | 7 +- .../new-contract-form/step3-cargo-scope.tsx | 24 ++++++- .../contracts/new-shipment-form/schema.ts | 23 +++++++ packages/types/src/freight/contracts.ts | 5 ++ 20 files changed, 324 insertions(+), 20 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts diff --git a/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts b/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts new file mode 100644 index 000000000..4a37b5e98 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/2270000000000-AddContainerReturnQuantity.ts @@ -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 { + 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 { + await queryRunner.query(` + ALTER TABLE freight.booking_container + DROP COLUMN IF EXISTS return_quantity; + `); + } +} diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index 00cf55e17..58351c080 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -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, diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts index 182ff153d..24ec5db7c 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking-container.entity.ts @@ -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; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 6078dee0f..a0c2b42a4 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -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)), diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 1646d638c..235e1051f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -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 diff --git a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts index 870817365..256b11b63 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/create-booking-under-contract.dto.ts @@ -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 }) diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index b7ffdc485..9bbd7728d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts index d66358cc9..83c225ea1 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate.entity.ts @@ -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', diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index e451098fc..bc1a8095d 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -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': diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 50fa77afa..10b400993 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -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 = {}; + const patch: QueryDeepPartialEntity = {}; if (dto.trainName !== undefined) { patch.trainName = dto.trainName.trim() || null; } diff --git a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts index 6b6b5f198..872e909bb 100644 --- a/apps/edr-freight-api/src/seed/pricing-data.seeder.ts +++ b/apps/edr-freight-api/src/seed/pricing-data.seeder.ts @@ -415,6 +415,9 @@ private async seedWeightLimits(wlRepo: any, ctRepo: any): Promise { // 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 ── diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 0cd00d63b..9e7fa15ca 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -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 && ( + + patchLine(lineIdx, { + returnQuantity: e.currentTarget.value, + }) + } + radius={10} + styles={fieldStyles} + /> + )} Per-container details @@ -1323,7 +1378,9 @@ export default function GlCreateBookingForm() { )} - {isContainer ? ( + {/* Legacy contracts only — WITH_RETURN contracts capture per-line + return quantities above, WITHOUT_RETURN contracts locked it off. */} + {legacyReturnToggle ? ( } diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index 9e248eee2..c7c76b91b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -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": diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx index 81c42a3a0..67a1a9071 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewContractPage.tsx @@ -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, diff --git a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx index 8233b83d6..08fe35537 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/NewShipmentPage.tsx @@ -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({ - {contract.freightType === "CONTAINER" && ( - - )} + {/* 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 && } @@ -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 && ( + ( + + )} + /> + )} Per-container details diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts index 4a57c120f..1246f8050 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/schema.ts @@ -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 = { 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: "", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx index 19a441045..2bacf8074 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step1-contract-type.tsx @@ -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", diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx index 26b455258..3de8baad1 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-contract-form/step3-cargo-scope.tsx @@ -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" && ( + ( + } + 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") + } + /> + )} + /> + )} diff --git a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts index 80c04f081..8610753c9 100644 --- a/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts +++ b/apps/edr-freight-web/portal/src/pages/contracts/new-shipment-form/schema.ts @@ -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"; diff --git a/packages/types/src/freight/contracts.ts b/packages/types/src/freight/contracts.ts index ed4d6f0f4..28d503faf 100644 --- a/packages/types/src/freight/contracts.ts +++ b/packages/types/src/freight/contracts.ts @@ -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[]; }