add hard capacity ceiling to weight limit rules

This commit is contained in:
Marshal
2026-07-04 01:11:23 +00:00
parent 97cc9d76b1
commit 8ea2c8e95a
19 changed files with 340 additions and 196 deletions

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Add a hard per-unit weight ceiling to weight limit rules.
*
* maxVgmTons stays the soft "overweight" threshold (surcharge + warning);
* max_capacity_tons is the absolute ceiling above which a booking cannot be
* created at all. Null means no ceiling (existing behavior).
*/
export class AddMaxCapacityToWeightLimitRules1930000000000
implements MigrationInterface
{
name = "AddMaxCapacityToWeightLimitRules1930000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
ADD COLUMN IF NOT EXISTS max_capacity_tons numeric(8, 3);
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.weight_limit_rules
DROP COLUMN IF EXISTS max_capacity_tons;
`);
}
}

View File

@@ -1077,7 +1077,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
company: true, company: true,
originYard: true, originYard: true,
destinationYard: true, destinationYard: true,
bookingContainers: { containerType: true }, // units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
bookingContainers: { containerType: true, units: true },
cargoType: true, cargoType: true,
}, },
order: { priorityScore: 'DESC', createdAt: 'ASC' }, order: { priorityScore: 'DESC', createdAt: 'ASC' },

View File

@@ -133,6 +133,13 @@ export class ContractBookingService {
}); });
} }
// Hard capacity gate: a container line whose total weight exceeds the
// container type's max capacity can never be booked — no surcharge path,
// no override. Checked before any row is written.
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
}
// Denormalize route/direction/freight onto the booking for the scheduling engine. // Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({ const booking = await this.bookingsRepository.create({
reference, reference,
@@ -608,6 +615,7 @@ export class ContractBookingService {
overweightSurchargeAmount: number; overweightSurchargeAmount: number;
currency: string | null; currency: string | null;
pairingErrors: string[]; pairingErrors: string[];
capacityErrors: string[];
lineItems: PriceLineItemDto[]; lineItems: PriceLineItemDto[];
totalAmount: number; totalAmount: number;
}> { }> {
@@ -621,6 +629,7 @@ export class ContractBookingService {
overweightSurchargeAmount: 0, overweightSurchargeAmount: 0,
currency: null, currency: null,
pairingErrors: [], pairingErrors: [],
capacityErrors: [],
lineItems: [], lineItems: [],
totalAmount: 0, totalAmount: 0,
}; };
@@ -695,16 +704,63 @@ export class ContractBookingService {
(v) => v.message, (v) => v.message,
); );
// Hard capacity ceiling — a non-empty result means the create call will be
// rejected, so the form can block submit up front.
const capacityErrors = await this.ruleEngineService.capacityViolations(
resolved.map(({ line, ct, totalVgmTons }) => ({
containerTypeId: ct.id,
quantity: line.quantity,
totalVgmTons,
})),
contract.tradeDirection,
);
return { return {
overweightLines: computed.overweightLines, overweightLines: computed.overweightLines,
overweightSurchargeAmount, overweightSurchargeAmount,
currency: computed.currency, currency: computed.currency,
pairingErrors, pairingErrors,
capacityErrors,
lineItems: computed.lineItems, lineItems: computed.lineItems,
totalAmount: computed.totalAmount, totalAmount: computed.totalAmount,
}; };
} }
/**
* Throws when any container line's total weight exceeds the hard capacity
* ceiling of its weight limit rule. Mirrors validateShipment's line
* resolution so the gate matches what the form preview reported.
*/
private async assertWithinMaxCapacity(
contract: Contract,
dto: CreateBookingUnderContractDto,
): Promise<void> {
const lines = dto.containers ?? [];
if (!lines.length) return;
const containers = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const totalVgmTons = (line.units ?? []).reduce(
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { containerTypeId: ct.id, quantity: line.quantity, totalVgmTons };
}),
);
const violations = await this.ruleEngineService.capacityViolations(
containers,
contract.tradeDirection,
);
if (violations.length) {
throw new BadRequestException(violations.join('; '));
}
}
private async max20ftPairDiffTons(): Promise<number> { private async max20ftPairDiffTons(): Promise<number> {
const row = await this.dataSource const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules) .getRepository(TrainSchedulingGlobalRules)

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { IsIn, IsNumber, IsUUID, Min } from 'class-validator'; import { IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const; const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
@@ -21,4 +21,15 @@ export class CreateWeightLimitRuleDto {
@Min(0) @Min(0)
@Transform(({ value }) => Number(value)) @Transform(({ value }) => Number(value))
maxVgmTons!: number; maxVgmTons!: number;
@ApiPropertyOptional({
description:
'Hard per-unit weight ceiling in tons — above this the booking cannot be created. Null/omitted = no ceiling.',
minimum: 0,
})
@IsOptional()
@IsNumber()
@Min(0)
@Transform(({ value }) => (value === null || value === undefined || value === '' ? null : Number(value)))
maxCapacityTons?: number | null;
} }

View File

@@ -18,4 +18,12 @@ export class WeightLimitRule extends BaseEntity {
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true }) @Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxVgmTons!: number; maxVgmTons!: number;
/**
* Absolute per-unit weight ceiling in tons. Weight above maxVgmTons but at or
* below this is "overweight" (surcharge + warning); weight above this hard-
* blocks booking creation entirely. Null = no ceiling (overweight only).
*/
@Column({ name: 'max_capacity_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxCapacityTons!: number | null;
} }

View File

@@ -136,6 +136,10 @@ export class RuleEngineService {
} }
} }
hardBlocked.push(
...(await this.capacityViolations(input.containers, input.tradeDirection)),
);
for (const container of input.containers) { for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId( const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId, container.containerTypeId,
@@ -296,6 +300,40 @@ export class RuleEngineService {
}; };
} }
/**
* Messages for container lines whose total weight exceeds the hard capacity
* ceiling (weight_limit_rules.max_capacity_tons). Non-empty ⇒ the booking
* must not be created at all. Overweight (above maxVgmTons but within
* capacity) is NOT reported here — that is a surcharge, not a block.
*/
async capacityViolations(
containers: Array<{
containerTypeId: string;
quantity: number;
totalVgmTons: number;
}>,
tradeDirection: string,
): Promise<string[]> {
const violations: string[] = [];
for (const container of containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
tradeDirection,
);
const rule = rules[0];
if (!rule || rule.maxCapacityTons == null) continue;
const perUnit = Number(rule.maxCapacityTons);
const maxTotal = perUnit * container.quantity;
if (container.totalVgmTons > maxTotal) {
const label = rule.containerType?.code ?? container.containerTypeId;
violations.push(
`${label} total weight ${container.totalVgmTons}t exceeds the maximum capacity of ${maxTotal}t (${perUnit}t per unit) — the booking cannot be created; reduce the cargo weight`,
);
}
}
return violations;
}
/** /**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent. * Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/ */

View File

@@ -1,4 +1,10 @@
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common'; import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto'; import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto'; import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity'; import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
@@ -62,13 +68,31 @@ export class WeightLimitRulesService {
} }
} }
/**
* Capacity is the hard ceiling; the VGM limit is the soft overweight
* threshold. A ceiling below the threshold would make every overweight
* booking impossible to create, which is never what the operator means.
*/
private assertCapacityAboveVgmLimit(
maxVgmTons: number,
maxCapacityTons: number | null | undefined,
): void {
if (maxCapacityTons != null && Number(maxCapacityTons) < Number(maxVgmTons)) {
throw new BadRequestException(
'Max capacity must be greater than or equal to the max VGM limit.',
);
}
}
/** Create a new weight limit rule. */ /** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> { async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection); await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
this.assertCapacityAboveVgmLimit(dto.maxVgmTons, dto.maxCapacityTons);
return this.repository.create({ return this.repository.create({
containerTypeId: dto.containerTypeId, containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection, tradeDirection: dto.tradeDirection,
maxVgmTons: dto.maxVgmTons, maxVgmTons: dto.maxVgmTons,
maxCapacityTons: dto.maxCapacityTons ?? null,
}); });
} }
@@ -79,6 +103,12 @@ export class WeightLimitRulesService {
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId; if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection; if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons; if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
if (dto.maxCapacityTons !== undefined) patch.maxCapacityTons = dto.maxCapacityTons;
this.assertCapacityAboveVgmLimit(
patch.maxVgmTons ?? Number(existing.maxVgmTons),
patch.maxCapacityTons !== undefined ? patch.maxCapacityTons : existing.maxCapacityTons,
);
// Re-check uniqueness when the identity (container/direction) changes. // Re-check uniqueness when the identity (container/direction) changes.
if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) { if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) {

View File

@@ -605,16 +605,22 @@ export class TrainSchedulingService {
); );
if (!validation.valid) { if (!validation.valid) {
// Put the violation detail in the message itself — global exception
// filters flatten the body, and "Booking validation failed" alone tells
// staff nothing (e.g. which wagon type is missing at the yard).
throw new BadRequestException({ throw new BadRequestException({
message: 'Booking validation failed', message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations, violations: validation.violations,
warnings: validation.warnings, warnings: validation.warnings,
}); });
} }
if (!validation.bookings.length) { if (!validation.bookings.length) {
const shortfall = validation.deferredBookings
.map((d) => `${d.reference}: ${d.reason}`)
.join('; ');
throw new BadRequestException({ throw new BadRequestException({
message: 'No bookings fit on available fleet wagons', message: `No wagons available for the selected bookings${shortfall ? `${shortfall}` : ''}`,
violations: ['Insufficient fleet wagons for the selected bookings'], violations: ['Insufficient fleet wagons for the selected bookings'],
warnings: validation.warnings, warnings: validation.warnings,
deferredBookings: validation.deferredBookings, deferredBookings: validation.deferredBookings,
@@ -628,12 +634,14 @@ export class TrainSchedulingService {
if (!limitLoco) { if (!limitLoco) {
throw new BadRequestException('Schedule train set has no locomotives'); throw new BadRequestException('Schedule train set has no locomotives');
} }
if (limitLoco.maxPullWeightTons < totalWeightTons) { // forceAssign lets staff overload the locomotive set knowingly — the
// validator has already surfaced it as a warning in that case.
if (!dto.forceAssign && limitLoco.maxPullWeightTons < totalWeightTons) {
throw new BadRequestException( throw new BadRequestException(
`Train set locomotives cannot pull ${totalWeightTons}T`, `Train set locomotives cannot pull ${totalWeightTons}T`,
); );
} }
if (limitLoco.maxTrainLengthMeters < totalLengthMeters) { if (!dto.forceAssign && limitLoco.maxTrainLengthMeters < totalLengthMeters) {
throw new BadRequestException( throw new BadRequestException(
`Train set locomotives cannot support ${totalLengthMeters}m`, `Train set locomotives cannot support ${totalLengthMeters}m`,
); );
@@ -2251,9 +2259,16 @@ export class TrainSchedulingService {
max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons, max20ftPairWeightDiffTons: trainLimits.max20ftPairWeightDiffTons,
}; };
// With forceAssign, capacity-shaped rules (train limits, total weight,
// locomotive capability) become warnings — staff owns the override. Physical
// impossibilities (no wagon of the required type at the yard, wrong route,
// wrong status) can never be forced and stay violations.
const pushLimit = (issues: string[]) =>
forceAssign ? warnings.push(...issues) : violations.push(...issues);
if (resolvedMode === 'MIXED') { if (resolvedMode === 'MIXED') {
violations.push( pushLimit(
...validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits), validateMixedTrainLimits(wagonPlan, [containerWagonType, bulkWagonType], trainLimits),
); );
if (requireContainerPlacements) { if (requireContainerPlacements) {
const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER'); const containerBookings = fittingBookings.filter((b) => b.freightType === 'CONTAINER');
@@ -2270,7 +2285,7 @@ export class TrainSchedulingService {
); );
} }
} else { } else {
violations.push(...validateTrainLimits(wagonPlan, wagonType, trainLimits)); pushLimit(validateTrainLimits(wagonPlan, wagonType, trainLimits));
if (requireContainerPlacements && resolvedMode === 'CONTAINER') { if (requireContainerPlacements && resolvedMode === 'CONTAINER') {
violations.push( violations.push(
@@ -2293,8 +2308,8 @@ export class TrainSchedulingService {
); );
if (totalWeightTons > trainLimits.maxWeightTons) { if (totalWeightTons > trainLimits.maxWeightTons) {
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`; const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
if (!violations.includes(message)) { if (!violations.includes(message) && !warnings.includes(message)) {
violations.push(message); pushLimit([message]);
} }
} }
@@ -2321,9 +2336,9 @@ export class TrainSchedulingService {
(setLimits.maxPullWeightTons < totalWeightTons || (setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters) setLimits.maxTrainLengthMeters < totalLengthMeters)
) { ) {
violations.push( pushLimit([
'Assigned locomotives cannot support the total train weight and length', 'Assigned locomotives cannot support the total train weight and length',
); ]);
} }
} else { } else {
const inServiceLocomotives = await this.locomotivesRepository.findAll({ const inServiceLocomotives = await this.locomotivesRepository.findAll({
@@ -2341,7 +2356,7 @@ export class TrainSchedulingService {
Number(l.maxTrainLengthMeters) >= totalLengthMeters, Number(l.maxTrainLengthMeters) >= totalLengthMeters,
) )
) { ) {
violations.push('No locomotive can support the total train weight and length'); pushLimit(['No locomotive can support the total train weight and length']);
} }
} }
@@ -3740,7 +3755,7 @@ export class TrainSchedulingService {
if (!validation.valid) { if (!validation.valid) {
throw new BadRequestException({ throw new BadRequestException({
message: 'Booking validation failed', message: `Booking validation failed: ${validation.violations.join('; ')}`,
violations: validation.violations, violations: validation.violations,
warnings: validation.warnings, warnings: validation.warnings,
}); });

View File

@@ -210,7 +210,14 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5)); const wagonsPerUnit = Number(line.containerType?.wagonsPerUnit ?? (sizeFt >= 40 ? 1 : 0.5));
const perWagon = containersPerWagonFromType(wagonsPerUnit); const perWagon = containersPerWagonFromType(wagonsPerUnit);
const teuSlots = teuSlotsForSizeFt(sizeFt); const teuSlots = teuSlotsForSizeFt(sizeFt);
// The REAL per-container numbers/weights entered at booking time. Unit i of
// the line maps to units[i] (sortOrder order); the line-level number is only
// a legacy fallback — never invent numbers here.
const units = [...(line.units ?? [])].sort(
(a, b) => Number(a.sortOrder ?? 0) - Number(b.sortOrder ?? 0),
);
for (let i = 0; i < qty; i += 1) { for (let i = 0; i < qty; i += 1) {
const unit = units[i];
rows.push({ rows.push({
bookingId: booking.id, bookingId: booking.id,
bookingReference: booking.reference, bookingReference: booking.reference,
@@ -219,12 +226,13 @@ export function expandBookingContainerUnits(bookings: Booking[]): ContainerUnitR
containerTypeId: line.containerTypeId ?? '', containerTypeId: line.containerTypeId ?? '',
containerTypeCode: code, containerTypeCode: code,
label: `${booking.reference} · ${i + 1}/${qty} · ${code}`, label: `${booking.reference} · ${i + 1}/${qty} · ${code}`,
grossWeightTons: Number(line.vgmPerUnitTons), grossWeightTons: Number(unit?.vgmTons ?? line.vgmPerUnitTons),
sizeFt, sizeFt,
wagonsPerUnit, wagonsPerUnit,
containersPerWagon: perWagon, containersPerWagon: perWagon,
teuSlots, teuSlots,
containerNumber: line.containerNumber ?? null, containerNumber:
unit?.containerNumber?.trim() || line.containerNumber || null,
}); });
} }
} }

View File

@@ -444,6 +444,7 @@ export default function GlCreateBookingForm() {
const displayTotal = serverTotal ?? priceTotal; const displayTotal = serverTotal ?? priceTotal;
const pairingErrors = validation?.pairingErrors ?? []; const pairingErrors = validation?.pairingErrors ?? [];
const capacityErrors = validation?.capacityErrors ?? [];
const overweightLines = validation?.overweightLines ?? []; const overweightLines = validation?.overweightLines ?? [];
const openPriceModal = () => { const openPriceModal = () => {
@@ -459,6 +460,8 @@ export default function GlCreateBookingForm() {
if (!contract || !windowOpen) return; if (!contract || !windowOpen) return;
// Never book past unresolved 20ft pairing hard-blocks. // Never book past unresolved 20ft pairing hard-blocks.
if (pairingErrors.length > 0) return; if (pairingErrors.length > 0) return;
// A line above the container type's max capacity can never book.
if (capacityErrors.length > 0) return;
const payload = buildPayload(); const payload = buildPayload();
if (!payload) return; if (!payload) return;
@@ -938,6 +941,28 @@ export default function GlCreateBookingForm() {
</Alert> </Alert>
)} )}
{capacityErrors.length > 0 && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — over maximum capacity"
>
<Stack gap={6}>
{capacityErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Reduce the cargo weight or split it across more containers
to book this shipment.
</Text>
</Stack>
</Alert>
)}
{overweightLines.length > 0 && ( {overweightLines.length > 0 && (
<Alert <Alert
color="yellow" color="yellow"
@@ -1023,7 +1048,9 @@ export default function GlCreateBookingForm() {
leftSection={<CheckCircle2 size={16} />} leftSection={<CheckCircle2 size={16} />}
loading={mutations.createBooking.isPending} loading={mutations.createBooking.isPending}
disabled={ disabled={
validateShipmentMutation.isPending || pairingErrors.length > 0 validateShipmentMutation.isPending ||
pairingErrors.length > 0 ||
capacityErrors.length > 0
} }
onClick={handleSubmit} onClick={handleSubmit}
> >

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { isAxiosError } from "axios";
import { import {
Badge, Badge,
Box, Box,
@@ -47,6 +48,18 @@ interface ScheduleWorkspacePanelProps {
const GREEN = "var(--mantine-color-edr-green-6)"; const GREEN = "var(--mantine-color-edr-green-6)";
/** Pull the API's violation detail out of an error (e.g. "No CW3 wagon available…"). */
function apiErrorMessage(error: unknown, fallback: string): string {
if (isAxiosError(error)) {
const data = error.response?.data as Record<string, unknown> | undefined;
const violations = data?.violations;
if (Array.isArray(violations) && violations.length) return violations.join(", ");
if (typeof data?.message === "string") return data.message;
if (Array.isArray(data?.message)) return (data.message as string[]).join(", ");
}
return fallback;
}
/** /**
* Deadline + label for the window phase this schedule is currently in. * Deadline + label for the window phase this schedule is currently in.
* Phases run: window open (windowClosesAt) → document review (docReviewEndsAt) * Phases run: window open (windowClosesAt) → document review (docReviewEndsAt)
@@ -198,8 +211,12 @@ export function ScheduleWorkspacePanel({
onChanged(); onChanged();
void poolQuery.refetch(); void poolQuery.refetch();
}) })
.catch(() => .catch((error) =>
toast({ title: "Could not add booking", variant: "destructive" }), toast({
title: "Could not add booking",
description: apiErrorMessage(error, "Validation failed — check capacity and status."),
variant: "destructive",
}),
); );
}; };
@@ -211,8 +228,12 @@ export function ScheduleWorkspacePanel({
onChanged(); onChanged();
void poolQuery.refetch(); void poolQuery.refetch();
}) })
.catch(() => .catch((error) =>
toast({ title: "Could not remove booking", variant: "destructive" }), toast({
title: "Could not remove booking",
description: apiErrorMessage(error, "Please try again."),
variant: "destructive",
}),
); );
}; };
@@ -226,8 +247,12 @@ export function ScheduleWorkspacePanel({
onChanged(); onChanged();
void poolQuery.refetch(); void poolQuery.refetch();
}) })
.catch(() => .catch((error) =>
toast({ title: "Could not reassign booking", variant: "destructive" }), toast({
title: "Could not reassign booking",
description: apiErrorMessage(error, "Target train may be closed or full."),
variant: "destructive",
}),
); );
}; };

View File

@@ -341,6 +341,10 @@ const RuleEngineResourcePage = () => {
} else if (config.slug === "priority-configs") { } else if (config.slug === "priority-configs") {
// Label is required by the backend but hidden in the UI for now. // Label is required by the backend but hidden in the UI for now.
payload = { ...values, label: String(Date.now()) }; payload = { ...values, label: String(Date.now()) };
} else if (config.slug === "weight-limit-rules") {
// Empty max capacity means "no ceiling" — send null explicitly so an
// edit can clear a previously-set ceiling (omitting the key keeps it).
payload = { ...values, maxCapacityTons: values.maxCapacityTons ?? null };
} }
if (editing?.id) { if (editing?.id) {

View File

@@ -374,6 +374,12 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
}, },
{ id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" }, { id: "tradeDirection", header: "Direction", accessorKey: "tradeDirection" },
{ id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" }, { id: "maxVgmTons", header: "Max VGM (t)", accessorKey: "maxVgmTons", format: "number" },
{
id: "maxCapacityTons",
header: "Max capacity (t)",
accessorKey: "maxCapacityTons",
format: "number",
},
], ],
formFields: [ formFields: [
{ {
@@ -391,6 +397,14 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
options: TRADE_DIRECTIONS, options: TRADE_DIRECTIONS,
}, },
{ name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true }, { name: "maxVgmTons", label: "Max VGM (tons)", type: "number", required: true },
{
name: "maxCapacityTons",
label: "Max capacity (tons)",
type: "number",
optional: true,
description:
"Hard ceiling — a booking whose line weight exceeds this cannot be created at all. Leave empty for no ceiling (overweight surcharge only).",
},
], ],
}, },
{ {

View File

@@ -43,8 +43,8 @@ export interface ShipmentPriceLine {
* Pre-create validation + authoritative price preview for a booking under a * Pre-create validation + authoritative price preview for a booking under a
* contract. `lineItems`/`totalAmount` are the full server-computed breakdown — * contract. `lineItems`/`totalAmount` are the full server-computed breakdown —
* the same pricing pass the booking persists at create (rail freight, * the same pricing pass the booking persists at create (rail freight,
* first/last mile, overweight and every other surcharge). `pairingErrors` are * first/last mile, overweight and every other surcharge). `pairingErrors` and
* HARD BLOCKS; `overweightLines` are warnings. * `capacityErrors` are HARD BLOCKS; `overweightLines` are warnings.
*/ */
export interface ShipmentValidation { export interface ShipmentValidation {
overweightLines: Array<{ overweightLines: Array<{
@@ -56,6 +56,8 @@ export interface ShipmentValidation {
overweightSurchargeAmount: number; overweightSurchargeAmount: number;
currency: string | null; currency: string | null;
pairingErrors: string[]; pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[];
lineItems?: ShipmentPriceLine[]; lineItems?: ShipmentPriceLine[];
totalAmount?: number; totalAmount?: number;
} }

View File

@@ -524,11 +524,10 @@ export default function NewBookingPage() {
} }
: { customsClearingEnabled: false }), : { customsClearingEnabled: false }),
...(cargoFreeText ? { cargoFreeText } : {}), ...(cargoFreeText ? { cargoFreeText } : {}),
// Multi-route general contracts: routes are pure origin→destination lanes // A general contract covers exactly ONE route — the same single
// the contract covers — they carry NO quantity. Route #1 is the primary // origin→destination pair as a one-time booking (multi-route on bookings
// origin/destination; the rest come from the extra-routes step. The // was dropped). The contracted quantity lives in a single shared pool
// contracted quantity lives in a single shared pool (the container // (container quantities / bulk total), drawn down per order.
// quantities / bulk total), drawn down per order against a chosen lane.
...(isContract ...(isContract
? { ? {
routes: [ routes: [
@@ -536,12 +535,6 @@ export default function NewBookingPage() {
originYardId: data.originYard, originYardId: data.originYard,
destinationYardId: data.destinationYard, destinationYardId: data.destinationYard,
}, },
...(data.extraRoutes ?? [])
.filter((r) => r.originYard && r.destinationYard)
.map((r) => ({
originYardId: r.originYard,
destinationYardId: r.destinationYard,
})),
], ],
} }
: {}), : {}),

View File

@@ -144,10 +144,9 @@ export const bookingFormSchema = z
// The contracted quantity now comes from the cargo step (cargoWeight), the // The contracted quantity now comes from the cargo step (cargoWeight), the
// same as a one-time booking, so per-route quantity is no longer entered. // same as a one-time booking, so per-route quantity is no longer entered.
primaryRouteQuantity: z.string().default(""), primaryRouteQuantity: z.string().default(""),
// Additional routes for a GENERAL contract (the primary origin/destination // LEGACY — multi-route general contracts were dropped; a contract booking
// above is route #1). Each route is just an (origin, destination) pair — // now covers exactly one route, like a one-time booking. Field retained only
// identical to the one-time route — so a contract can cover several routes. // so previously saved drafts still hydrate; never collected or sent anymore.
// Ignored for one-time bookings. quantity/km kept for payload back-compat.
extraRoutes: z extraRoutes: z
.array( .array(
z.object({ z.object({
@@ -434,7 +433,6 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
"originYard", "originYard",
"destinationYard", "destinationYard",
"primaryRouteQuantity", "primaryRouteQuantity",
"extraRoutes",
// Estimated shipment date now lives in the Route step (one-time bookings only). // Estimated shipment date now lives in the Route step (one-time bookings only).
"scheduledDate", "scheduledDate",
], ],

View File

@@ -1,26 +1,9 @@
import type { Freight } from "@edr/types"; import type { Freight } from "@edr/types";
import { import { Box, Skeleton, Stack } from "@mantine/core";
Box,
Button,
Group,
Skeleton,
Stack,
Text,
} from "@mantine/core";
import { DatePickerInput } from "@mantine/dates"; import { DatePickerInput } from "@mantine/dates";
import { import { CalendarDays, MapPin, Route as RouteIcon } from "lucide-react";
CalendarDays,
MapPin,
Plus,
Route as RouteIcon,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo } from "react";
import { import { Controller, type UseFormReturn } from "react-hook-form";
Controller,
useFieldArray,
type UseFormReturn,
} from "react-hook-form";
import { import {
BookingFormInputValues, BookingFormInputValues,
type BookingFormValues, type BookingFormValues,
@@ -70,16 +53,6 @@ export function Step4Route({
} }
}, [operationType]); }, [operationType]);
const {
fields: extraRoutes,
append: appendRoute,
remove: removeRoute,
} = useFieldArray({ control: form.control, name: "extraRoutes" });
// useFieldArray's `fields` don't re-render on value change, so watch the live
// route values to filter each row's yard options by what it has selected.
const watchedExtraRoutes = form.watch("extraRoutes") ?? [];
const yardOptions = useMemo(() => { const yardOptions = useMemo(() => {
if (!referenceData?.yard) return []; if (!referenceData?.yard) return [];
return referenceData.yard.map((y) => ({ value: y.id, label: y.name })); return referenceData.yard.map((y) => ({ value: y.id, label: y.name }));
@@ -129,25 +102,6 @@ export function Step4Route({
} }
}, [destinationCountry, dest, form]); }, [destinationCountry, dest, form]);
// Same cleanup for the extra contract routes: when the operation type changes,
// clear any extra-route yard whose country no longer matches the required side
// so an added route can't contradict the operation either.
useEffect(() => {
watchedExtraRoutes.forEach((route, i) => {
const ro = referenceData?.yard.find((y) => y.id === route?.originYard);
if (originCountry && ro && ro.country !== originCountry) {
form.setValue(`extraRoutes.${i}.originYard`, "");
}
const rd = referenceData?.yard.find(
(y) => y.id === route?.destinationYard,
);
if (destinationCountry && rd && rd.country !== destinationCountry) {
form.setValue(`extraRoutes.${i}.destinationYard`, "");
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [originCountry, destinationCountry, referenceData, form]);
const directionStyle: Record<string, string> = { const directionStyle: Record<string, string> = {
EXPORT: "bg-sky-50 text-sky-800 border-sky-200", EXPORT: "bg-sky-50 text-sky-800 border-sky-200",
IMPORT: "bg-amber-50 text-amber-800 border-amber-200", IMPORT: "bg-amber-50 text-amber-800 border-amber-200",
@@ -161,11 +115,10 @@ export function Step4Route({
const stationSelectDisabled = yardOptions.length === 0; const stationSelectDisabled = yardOptions.length === 0;
// A general contract can cover several routes, but each route is just an // A general contract covers exactly ONE route — the same single
// (origin, destination) pair — the same shape as the one-time route. The // origin/destination pair as a one-time booking. (Multi-route contracts were
// contracted quantity comes from the cargo step, so no per-route quantity or // dropped; the multi-lane concept lives on the contracts module, not on
// distance is collected here. Cargo handling (hazardous / refrigerated) also // bookings.) The contracted quantity comes from the cargo step.
// lives in the Cargo step now, not here.
// Earliest selectable shipment date (today, local) for the date input's `min`. // Earliest selectable shipment date (today, local) for the date input's `min`.
const todayISODate = useMemo(() => { const todayISODate = useMemo(() => {
@@ -256,103 +209,6 @@ export function Step4Route({
</div> </div>
)} )}
{isGeneralContract && !isLoading && (
<Box mt={18}>
<Group justify="space-between" align="center" mb={8}>
<StepLabel>Additional contract routes</StepLabel>
<Button
variant="light"
color="edr-green"
size="xs"
radius="md"
leftSection={<Plus size={14} />}
disabled={stationSelectDisabled}
onClick={() =>
appendRoute({
originYard: "",
destinationYard: "",
quantity: "",
km: "",
})
}
>
Add route
</Button>
</Group>
<Text fz={12} c="#6B7C8E" mb={12}>
A general contract can cover several routes. The route above is your
primary route; add more origindestination routes the contract should
cover.
</Text>
<Stack gap={12}>
{extraRoutes.map((rf, i) => {
// Each extra route is constrained by the SAME operation type as the
// primary route: its origin must sit in originCountry and its
// destination in destinationCountry. Watch this row's current values
// so each side also excludes the yard picked on the other side.
const rowOrigin = watchedExtraRoutes[i]?.originYard ?? "";
const rowDestination =
watchedExtraRoutes[i]?.destinationYard ?? "";
const rowOriginData = yardsForSide(originCountry, rowDestination);
const rowDestData = yardsForSide(destinationCountry, rowOrigin);
return (
<Group
key={rf.id}
gap={10}
align="flex-start"
wrap="nowrap"
className="rounded-xl"
style={{ border: "1px solid #E6ECF2", padding: 12 }}
>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.originYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Origin"
placeholder="Origin..."
disabled={stationSelectDisabled}
data={rowOriginData}
/>
)}
/>
</Box>
<Box style={{ flex: 1 }}>
<Controller
name={`extraRoutes.${i}.destinationYard`}
control={form.control}
render={({ field, fieldState }) => (
<SelectField
field={field}
error={fieldState.error}
label="Destination"
placeholder="Destination..."
disabled={stationSelectDisabled}
data={rowDestData}
/>
)}
/>
</Box>
<Button
variant="subtle"
color="red"
size="xs"
mt={24}
px={6}
onClick={() => removeRoute(i)}
>
<Trash2 size={16} />
</Button>
</Group>
);
})}
</Stack>
</Box>
)}
</StepCard> </StepCard>
); );
} }

View File

@@ -299,6 +299,8 @@ function NewShipmentBookingForm({
if (!pendingValues) return; if (!pendingValues) return;
// Guard: never let a booking with unresolved 20ft pairing errors submit. // Guard: never let a booking with unresolved 20ft pairing errors submit.
if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return; if ((validateMutation.data?.pairingErrors.length ?? 0) > 0) return;
// Guard: a line above the container type's max capacity can never book.
if ((validateMutation.data?.capacityErrors?.length ?? 0) > 0) return;
submitMutation.mutate(buildDto(pendingValues)); submitMutation.mutate(buildDto(pendingValues));
}; };
@@ -444,7 +446,10 @@ function PriceConfirmModal({
const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0; const overweightSurchargeAmount = validation?.overweightSurchargeAmount ?? 0;
const pairingErrors = validation?.pairingErrors ?? []; const pairingErrors = validation?.pairingErrors ?? [];
const hasPairingBlock = pairingErrors.length > 0; const hasPairingBlock = pairingErrors.length > 0;
const confirmDisabled = loading || validationLoading || hasPairingBlock; const capacityErrors = validation?.capacityErrors ?? [];
const hasCapacityBlock = capacityErrors.length > 0;
const confirmDisabled =
loading || validationLoading || hasPairingBlock || hasCapacityBlock;
// Authoritative server breakdown — the SAME BookingPricingService pass that // Authoritative server breakdown — the SAME BookingPricingService pass that
// prices the booking on create, so it carries every line the booking will be // prices the booking on create, so it carries every line the booking will be
@@ -550,6 +555,28 @@ function PriceConfirmModal({
</Alert> </Alert>
)} )}
{hasCapacityBlock && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot create booking — over maximum capacity"
>
<Stack gap={6}>
{capacityErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Reduce the cargo weight or split it across more containers to
book this shipment.
</Text>
</Stack>
</Alert>
)}
{overweightLines.length > 0 && ( {overweightLines.length > 0 && (
<Alert <Alert
color="yellow" color="yellow"

View File

@@ -71,6 +71,8 @@ export interface ShipmentValidation {
overweightSurchargeAmount: number; overweightSurchargeAmount: number;
currency: string | null; currency: string | null;
pairingErrors: string[]; pairingErrors: string[];
/** Lines above the container type's hard max capacity — booking cannot be created. */
capacityErrors?: string[];
lineItems?: ShipmentPriceLine[]; lineItems?: ShipmentPriceLine[];
totalAmount?: number; totalAmount?: number;
} }