mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
add hard capacity ceiling to weight limit rules
This commit is contained in:
@@ -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;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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).",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})),
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|||||||
@@ -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",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 origin–destination 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user