mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 12:18:11 +00:00
feat: enhance train scheduling and contract management features
- Added StationWorkControls to manage loading/unloading phases in TrainScheduleV2DetailPage. - Implemented API endpoints for recording station work and managing wagon detach requests. - Updated contract templates to include Ethiopian customs handling options. - Enhanced shipment forms to collect customs clearing agent details for without-customs bookings. - Introduced NUMBER_OF_WAGONS as a unit of measure for bulk cargo, allowing customers to specify wagon counts. - Improved validation for customs clearing agent information in shipment forms. - Updated various components and services to accommodate new features and ensure data integrity.
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
import { DataSource } from 'typeorm';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
import { CargoUnitOfMeasure } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
@@ -28,6 +29,7 @@ import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-s
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
@@ -273,6 +275,8 @@ export class ContractBookingService {
|
||||
});
|
||||
}
|
||||
|
||||
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||||
|
||||
// Denormalize route/direction/freight onto the booking for the scheduling engine.
|
||||
// Retry past a concurrent insert that grabbed the same BK sequence number.
|
||||
const booking = await insertWithGeneratedReference(
|
||||
@@ -306,8 +310,7 @@ export class ContractBookingService {
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...bulkFields,
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
firstMilePickupLng: contract.firstMilePickupLng ?? null,
|
||||
@@ -853,6 +856,35 @@ export class ContractBookingService {
|
||||
if (!dto.scheduledDate) {
|
||||
throw new BadRequestException('A binding shipment day is required');
|
||||
}
|
||||
// Without-customs import/export: the customer's own clearing agent (name,
|
||||
// email, phone) is captured per booking at completion. A resubmit may omit
|
||||
// the fields and keep what the booking already stored. Customs contracts
|
||||
// (GL clears) and intercity (no border) never collect an agent.
|
||||
if (
|
||||
!contract.customsClearingEnabled &&
|
||||
contract.tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
const agentName =
|
||||
dto.customsClearingAgent?.trim() || booking.customsClearingAgent || null;
|
||||
const agentEmail =
|
||||
dto.customsClearingAgentEmail?.trim() ||
|
||||
booking.customsClearingAgentEmail ||
|
||||
null;
|
||||
const agentPhone =
|
||||
dto.customsClearingAgentPhone?.trim() ||
|
||||
booking.customsClearingAgentPhone ||
|
||||
null;
|
||||
if (!agentName || !agentEmail || !agentPhone) {
|
||||
throw new BadRequestException(
|
||||
'Customs clearing agent name, email and phone are required to complete this booking.',
|
||||
);
|
||||
}
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
customsClearingAgent: agentName,
|
||||
customsClearingAgentEmail: agentEmail,
|
||||
customsClearingAgentPhone: agentPhone,
|
||||
} as never);
|
||||
}
|
||||
// No expiry gate here on purpose: this booking was already initiated
|
||||
// before the contract lapsed (createUnderContract/initiateUnderContract
|
||||
// already checked expiry at start). Finishing an in-flight booking must
|
||||
@@ -955,8 +987,7 @@ export class ContractBookingService {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
cargoFreeText: dto.cargoFreeText?.trim() || null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...(await this.resolveBulkCargoFields(contract, dto)),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
// Completion is where the cargo — and therefore the price — is fixed, so
|
||||
// it is also where the billing currency is chosen. A bare instance was
|
||||
@@ -1533,8 +1564,10 @@ export class ContractBookingService {
|
||||
return probe;
|
||||
}
|
||||
|
||||
probe.cargoTotalWeightVgm = this.resolveBulkTons(dto);
|
||||
probe.bulkTotalWeightTons = this.resolveBulkWeightTons(dto);
|
||||
const bulkFields = await this.resolveBulkCargoFields(contract, dto);
|
||||
probe.cargoTotalWeightVgm = bulkFields.cargoTotalWeightVgm;
|
||||
probe.bulkTotalWeightTons = bulkFields.bulkTotalWeightTons;
|
||||
probe.bulkRequestedWagons = bulkFields.bulkRequestedWagons;
|
||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||
probe.cargoTypeId = cargoTypeId;
|
||||
if (cargoTypeId) {
|
||||
@@ -1938,6 +1971,107 @@ export class ContractBookingService {
|
||||
return tons > 0 ? tons : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk cargo columns for the booking row, resolved against the commodity's
|
||||
* unit of measure:
|
||||
*
|
||||
* - PER_TON: `cargoTotalWeightVgm` = tons (legacy behaviour).
|
||||
* - PER_ITEM: `cargoTotalWeightVgm` = item count, real tonnage in
|
||||
* `bulkTotalWeightTons` (legacy behaviour).
|
||||
* - NUMBER_OF_WAGONS: `cargoTotalWeightVgm` = tons, and the payload must fix
|
||||
* the wagon count (customer on the portal, GL in the backoffice). The
|
||||
* count is validated so each wagon's even share (tons ÷ wagons) fits what
|
||||
* one wagon of this cargo may carry; the optional item count is stored as
|
||||
* information only and never prices or sizes anything.
|
||||
*
|
||||
* Container contracts (and payloads without bulk lines) pass through with
|
||||
* the legacy zero/null values.
|
||||
*/
|
||||
private async resolveBulkCargoFields(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
): Promise<{
|
||||
cargoTotalWeightVgm: number;
|
||||
bulkTotalWeightTons: number | null;
|
||||
bulkRequestedWagons: number | null;
|
||||
bulkItemCount: number | null;
|
||||
}> {
|
||||
const legacy = {
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
bulkRequestedWagons: null as number | null,
|
||||
bulkItemCount: null as number | null,
|
||||
};
|
||||
if (contract.freightType === 'CONTAINER' || !dto.bulkLines?.length) {
|
||||
return legacy;
|
||||
}
|
||||
const cargoTypeId = this.resolveCargoTypeId(contract, dto);
|
||||
if (!cargoTypeId) return legacy;
|
||||
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
|
||||
where: { id: cargoTypeId },
|
||||
relations: { wagonTypes: true },
|
||||
});
|
||||
if (cargoType?.unitOfMeasure !== CargoUnitOfMeasure.NumberOfWagons) {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
const tons = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.cargoWeightTons ?? 0),
|
||||
0,
|
||||
);
|
||||
const items = dto.bulkLines.reduce(
|
||||
(sum, l) => sum + Number(l.itemCount ?? 0),
|
||||
0,
|
||||
);
|
||||
const wagons = Math.floor(Number(dto.requestedWagons ?? 0));
|
||||
if (!(wagons >= 1)) {
|
||||
throw new BadRequestException(
|
||||
`${cargoType.cargoTypeName} is booked by wagons — enter the number of wagons needed.`,
|
||||
);
|
||||
}
|
||||
if (!(tons > 0)) {
|
||||
throw new BadRequestException('Cargo weight in tons is required.');
|
||||
}
|
||||
this.assertWagonShareFits(cargoType, tons, wagons);
|
||||
return {
|
||||
cargoTotalWeightVgm: tons,
|
||||
bulkTotalWeightTons: null,
|
||||
bulkRequestedWagons: wagons,
|
||||
bulkItemCount: items > 0 ? Math.floor(items) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NUMBER_OF_WAGONS: block the booking outright when the even per-wagon share
|
||||
* (tons ÷ requested wagons) is heavier than what ANY of the cargo's allowed
|
||||
* wagon types may carry — 100T on 2 wagons is 50T each and fine on a 60T
|
||||
* wagon, but 100T on 1 wagon can never ride. Cargo types with no wagon types
|
||||
* configured skip the check (allocation falls back to the default rating).
|
||||
*/
|
||||
private assertWagonShareFits(
|
||||
cargoType: CargoType,
|
||||
tons: number,
|
||||
wagons: number,
|
||||
): void {
|
||||
const allowed = (cargoType.wagonTypes ?? []).filter(
|
||||
(wt) => Number(wt.capacityTons) > 0,
|
||||
);
|
||||
if (!allowed.length) return;
|
||||
const maxPerWagon = Math.max(
|
||||
...allowed.map((wt) =>
|
||||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
);
|
||||
const share = tons / wagons;
|
||||
if (share > maxPerWagon) {
|
||||
throw new BadRequestException(
|
||||
`${tons} tons across ${wagons} wagon(s) loads ${round3(share)}T per wagon, ` +
|
||||
`but a wagon of this cargo carries at most ${round3(maxPerWagon)}T — ` +
|
||||
`request at least ${Math.ceil(tons / maxPerWagon)} wagons.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line handling counts. Each physical container carries its own hazardous
|
||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||
@@ -2261,8 +2395,7 @@ export class ContractBookingService {
|
||||
contractRouteId: route?.id ?? null,
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
bulkTotalWeightTons: this.resolveBulkWeightTons(dto),
|
||||
...(await this.resolveBulkCargoFields(contract, dto)),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||
|
||||
@@ -428,6 +428,8 @@ export class ContractTransitionService {
|
||||
contract.customsClearingEnabled,
|
||||
// Bulk templates are keyed by the contract's cargo type.
|
||||
(contract.cargoScope ?? []).find((c) => c.cargoTypeId)?.cargoTypeId,
|
||||
// Ethiopian-customs-only service types resolve to the Ethiopian variant.
|
||||
contract.serviceType?.includesEthiopianCustomsOnly,
|
||||
);
|
||||
if (!active) return null;
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEmail,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -208,6 +210,19 @@ export class CreateBookingUnderContractDto {
|
||||
@Type(() => CreateBulkLineDto)
|
||||
bulkLines?: CreateBulkLineDto[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
minimum: 1,
|
||||
description:
|
||||
'NUMBER_OF_WAGONS bulk cargo only: how many wagons the shipment needs. ' +
|
||||
'The weight spreads evenly across them; a PER_WAGON rate bills this count. ' +
|
||||
'Required when the cargo type is measured by wagons, ignored otherwise.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => (value == null || value === '' ? undefined : Number(value)))
|
||||
requestedWagons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'What the containers carry — captured per booking (container freight).',
|
||||
})
|
||||
@@ -215,6 +230,29 @@ export class CreateBookingUnderContractDto {
|
||||
@IsString()
|
||||
cargoFreeText?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
maxLength: 200,
|
||||
description:
|
||||
'Customs clearing agent name. Required at completion of a without-customs ' +
|
||||
'import/export booking (the service enforces it); ignored on customs contracts.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
customsClearingAgent?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 200, description: 'Customs clearing agent email.' })
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
@MaxLength(200)
|
||||
customsClearingAgentEmail?: string;
|
||||
|
||||
@ApiPropertyOptional({ maxLength: 50, description: 'Customs clearing agent phone number.' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(50)
|
||||
customsClearingAgentPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -63,6 +63,12 @@ describe('shipment preview / created booking parity', () => {
|
||||
resolveShipmentEquipmentReturn: () => c.equipmentReturn,
|
||||
resolveBulkTons: () => 0,
|
||||
resolveBulkWeightTons: () => 0,
|
||||
resolveBulkCargoFields: async () => ({
|
||||
cargoTotalWeightVgm: 0,
|
||||
bulkTotalWeightTons: null,
|
||||
bulkRequestedWagons: null,
|
||||
bulkItemCount: null,
|
||||
}),
|
||||
resolveContainerTypeForSize: async () => ({ id: 'ct40', sizeFt: 40 }),
|
||||
handlingCounts: () => ({
|
||||
hazardousQuantity: 0,
|
||||
|
||||
Reference in New Issue
Block a user