mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 12:58:13 +00:00
booking cancellation
This commit is contained in:
@@ -63,6 +63,23 @@ export class BillingController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get("invoices/summary")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Total collected (paidAmount) across every filtered invoice, grouped by currency",
|
||||
})
|
||||
async collectedSummary(
|
||||
@Query() query: FilterInvoiceDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const allowed =
|
||||
await this.userTradeAccessService.resolveAllowedDirections(user);
|
||||
return this.billingService.collectedSummary({
|
||||
...query,
|
||||
tradeDirections: allowed ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("invoices/:id")
|
||||
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||
import { logCtx } from "@edr/api-common";
|
||||
import { DataSource, EntityManager, In } from "typeorm";
|
||||
import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
||||
@@ -192,6 +192,40 @@ export class BillingService {
|
||||
* company (customer detail "Invoices" tab) and/or status/search (global
|
||||
* invoices page).
|
||||
*/
|
||||
/** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */
|
||||
private applyInvoiceFilters(
|
||||
qb: SelectQueryBuilder<Invoice>,
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
tradeDirections?: string[];
|
||||
},
|
||||
) {
|
||||
if (filter.companyId) {
|
||||
qb.andWhere("invoice.companyId = :companyId", {
|
||||
companyId: filter.companyId,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
if (filter.tradeDirections) {
|
||||
applyBookingRefDirectionScope(
|
||||
qb,
|
||||
"invoice.source_id",
|
||||
filter.tradeDirections,
|
||||
);
|
||||
}
|
||||
return qb;
|
||||
}
|
||||
|
||||
async findAllPaginated(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
@@ -215,33 +249,43 @@ export class BillingService {
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.companyId) {
|
||||
qb.andWhere("invoice.companyId = :companyId", {
|
||||
companyId: filter.companyId,
|
||||
});
|
||||
}
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.tradeDirections) {
|
||||
applyBookingRefDirectionScope(
|
||||
qb,
|
||||
"invoice.source_id",
|
||||
filter.tradeDirections,
|
||||
);
|
||||
}
|
||||
this.applyInvoiceFilters(qb, filter);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Total collected (`paidAmount`) across every invoice matching the same
|
||||
* filters as `findAllPaginated`, grouped by currency — unpaginated, so the
|
||||
* invoices summary card reflects the whole filtered set, not just the
|
||||
* visible page.
|
||||
*/
|
||||
async collectedSummary(
|
||||
filter: {
|
||||
companyId?: string;
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
tradeDirections?: string[];
|
||||
} = {},
|
||||
): Promise<Record<string, number>> {
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.select("invoice.currency", "currency")
|
||||
.addSelect("SUM(invoice.paidAmount)", "collected")
|
||||
.groupBy("invoice.currency");
|
||||
|
||||
this.applyInvoiceFilters(qb, filter);
|
||||
|
||||
const rows: { currency: string; collected: string }[] =
|
||||
await qb.getRawMany();
|
||||
|
||||
return Object.fromEntries(
|
||||
rows.map((row) => [row.currency, Number(row.collected) || 0]),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway), open ones by default or a single status when
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
|
||||
/**
|
||||
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
|
||||
* cut is allowed and takes the exact cargo total; over-cut is rejected; a
|
||||
* partial cut stays proportional.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.resolveRequestedCut (bulk)', () => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as {
|
||||
resolveRequestedCut(booking: unknown, dto: unknown): Promise<{
|
||||
wagons: number;
|
||||
weightTons: number;
|
||||
quantities: { bulkTons?: number };
|
||||
}>;
|
||||
};
|
||||
const booking = {
|
||||
id: 'b1',
|
||||
freightType: 'BULK',
|
||||
wagonsRequired: 4,
|
||||
cargoTotalWeightVgm: 250.5,
|
||||
bulkTotalWeightTons: null,
|
||||
};
|
||||
|
||||
it('cancels every wagon with the exact total tonnage', async () => {
|
||||
const cut = await svc.resolveRequestedCut(booking, { wagons: 4 });
|
||||
expect(cut).toEqual({ wagons: 4, weightTons: 250.5, quantities: { bulkTons: 250.5 } });
|
||||
});
|
||||
|
||||
it('rejects more wagons than the booking has', async () => {
|
||||
await expect(svc.resolveRequestedCut(booking, { wagons: 5 })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('sizes a partial cut proportionally', async () => {
|
||||
const cut = await svc.resolveRequestedCut(booking, { wagons: 1 });
|
||||
expect(cut.wagons).toBe(1);
|
||||
expect(cut.weightTons).toBeCloseTo(62.625, 3);
|
||||
});
|
||||
});
|
||||
@@ -7,8 +7,9 @@ import {
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
import { ContractBookingService } from '../contracts/contract-booking.service';
|
||||
@@ -18,9 +19,11 @@ import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.en
|
||||
import { FirstMileService } from '../first-mile/first-mile.service';
|
||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerType } from '../rule-engine/entities/container-type.entity';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
@@ -46,8 +49,9 @@ import {
|
||||
/**
|
||||
* rates.rate_type of the cancellation fee — an existing rate-engine type
|
||||
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
|
||||
* configure it in the normal rates UI; the wagon flow requires the PER_WAGON
|
||||
* unit so the fee scales with the cancelled wagon count.
|
||||
* configure it in the normal rates UI, one PER_WAGON rate per trade direction
|
||||
* + cargo kind + type (20ft / 40ft container type, or bulk commodity), so the
|
||||
* fee scales with the cancelled wagon count and differs by what was booked.
|
||||
*/
|
||||
export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
|
||||
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
|
||||
@@ -62,8 +66,20 @@ interface RequestedCut {
|
||||
quantities: CancelledQuantities;
|
||||
}
|
||||
|
||||
/** The priced fee for a cut: total, currency and the rate(s) it came from. */
|
||||
interface PricedFee {
|
||||
amount: number;
|
||||
currency: string;
|
||||
/** Effective per-wagon fee (amount / wagons) — one number for the customer. */
|
||||
perWagon: number;
|
||||
/** Rate rows used; the first is recorded on the ledger row. */
|
||||
rates: Rate[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial wagon cancellation on a PAID booking, with a rebooking credit.
|
||||
* Wagon cancellation on a PAID booking (partial or whole), with a rebooking
|
||||
* credit. Cutting every wagon ends the source booking CANCELLED at T2; the
|
||||
* credit then rebooks as a fresh booking under the same contract.
|
||||
*
|
||||
* Lifecycle (one ledger row per cycle, see BookingWagonCancellation):
|
||||
* T1 request — validate + price the fee, open the fee invoice. Nothing else
|
||||
@@ -91,6 +107,7 @@ export class BookingWagonCancellationService {
|
||||
private readonly repo: BookingWagonCancellationsRepository,
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly billing: BillingService,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
@Inject(forwardRef(() => ContractBookingService))
|
||||
private readonly contractBooking: ContractBookingService,
|
||||
@Inject(forwardRef(() => ClearanceMilestoneService))
|
||||
@@ -120,14 +137,13 @@ export class BookingWagonCancellationService {
|
||||
}> {
|
||||
const booking = await this.loadCancellableBooking(bookingId);
|
||||
const cut = await this.resolveRequestedCut(booking, dto);
|
||||
const rate = await this.feeRate();
|
||||
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
return {
|
||||
wagons: cut.wagons,
|
||||
weightTons: cut.weightTons,
|
||||
feePerWagon: Number(rate.rateValue),
|
||||
feeAmount,
|
||||
feeCurrency: rate.currency,
|
||||
feePerWagon: fee.perWagon,
|
||||
feeAmount: fee.amount,
|
||||
feeCurrency: fee.currency,
|
||||
creditAmount: this.creditFor(booking, cut.wagons),
|
||||
};
|
||||
}
|
||||
@@ -146,8 +162,8 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, dto);
|
||||
const rate = await this.feeRate();
|
||||
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
|
||||
const fee = await this.priceFee(booking, cut);
|
||||
const feeAmount = fee.amount;
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
@@ -156,9 +172,11 @@ export class BookingWagonCancellationService {
|
||||
weightTons: cut.weightTons,
|
||||
cancelledQuantities: cut.quantities,
|
||||
creditAmount,
|
||||
feeRateId: rate.id,
|
||||
// ponytail: one FK for a mixed-size container cut records the first
|
||||
// size's rate; the invoice line carries the effective per-wagon fee.
|
||||
feeRateId: fee.rates[0].id,
|
||||
feeAmount,
|
||||
feeCurrency: rate.currency,
|
||||
feeCurrency: fee.currency,
|
||||
status: 'FEE_PENDING',
|
||||
reason: dto.reason ?? null,
|
||||
requestedByUserId: userId ?? null,
|
||||
@@ -173,15 +191,15 @@ export class BookingWagonCancellationService {
|
||||
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: rate.currency,
|
||||
currency: fee.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CANCELLATION_FEE',
|
||||
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
|
||||
quantity: cut.wagons,
|
||||
unitRate: Number(rate.rateValue),
|
||||
unitRate: fee.perWagon,
|
||||
amount: feeAmount,
|
||||
currency: rate.currency,
|
||||
currency: fee.currency,
|
||||
metadata: { wagonCancellationId: row.id },
|
||||
},
|
||||
],
|
||||
@@ -343,12 +361,28 @@ export class BookingWagonCancellationService {
|
||||
const preSplitQuantities =
|
||||
booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight));
|
||||
|
||||
// Whole-booking cut: nothing is left to ship, so the booking ends
|
||||
// CANCELLED (frees the contract slot/cap for the rebook) and drops off its
|
||||
// train. The credit row still points at it for T3.
|
||||
const wagonsLeft = round2(
|
||||
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
||||
);
|
||||
const isFull = wagonsLeft <= 0;
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)),
|
||||
cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
||||
totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)),
|
||||
wagonsRequired: Math.max(0, wagonsLeft),
|
||||
cargoTotalWeightVgm: Math.max(
|
||||
0,
|
||||
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
||||
),
|
||||
totalAmount: Math.max(
|
||||
0,
|
||||
round2(Number(booking.totalAmount) - Number(row.creditAmount)),
|
||||
),
|
||||
isSplit: true,
|
||||
preSplitQuantities,
|
||||
...(isFull
|
||||
? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null }
|
||||
: {}),
|
||||
} as never);
|
||||
|
||||
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
||||
@@ -360,11 +394,15 @@ export class BookingWagonCancellationService {
|
||||
});
|
||||
|
||||
const booking = await this.bookingsRepository.findById(row.bookingId);
|
||||
if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking);
|
||||
if (booking) {
|
||||
const whole = booking.status === 'CANCELLED';
|
||||
this.notifyCustomer(
|
||||
booking,
|
||||
'Wagon cancellation confirmed',
|
||||
`${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
|
||||
whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed',
|
||||
whole
|
||||
? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`
|
||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
@@ -372,6 +410,33 @@ export class BookingWagonCancellationService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whole-booking cut: take the cancelled booking OFF its train entirely —
|
||||
* schedule link, leftover wagon slots, window status — via the ops unassign
|
||||
* path (no "removed from train" notice: the customer cancelled it). A stale
|
||||
* link would keep showing the booking on the schedule AND poison every later
|
||||
* auto wagon allocation on that train (the whole-train re-plan rejects a
|
||||
* CANCELLED booking). Then re-run allocation so bookings held back by it
|
||||
* (e.g. the rebooked credit) get their wagons.
|
||||
*/
|
||||
private async detachFromSchedule(booking: Booking): Promise<void> {
|
||||
const links = await this.dataSource
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.find({ where: { bookingId: booking.id } });
|
||||
for (const link of links) {
|
||||
try {
|
||||
await this.trainScheduling.unassignBooking(link.trainScheduleId, booking.id, undefined, {
|
||||
notifyCustomer: false,
|
||||
});
|
||||
await this.trainScheduling.tryAutoWagonAllocation(link.trainScheduleId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Detach of cancelled booking ${booking.reference} from schedule ${link.trainScheduleId} failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── T3: rebook ──────────────────────────────────────────────────────────────
|
||||
|
||||
async rebook(
|
||||
@@ -401,6 +466,8 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
const createDto = this.buildRebookDto(row, dto.scheduledDate);
|
||||
// Same currency as the source booking — the credit is in it.
|
||||
createDto.paymentCurrency = source.paymentCurrency ?? undefined;
|
||||
const created = await this.contractBooking.createUnderContract(
|
||||
source.contractId,
|
||||
createDto,
|
||||
@@ -413,9 +480,13 @@ export class BookingWagonCancellationService {
|
||||
|
||||
// The freight is already paid (credit) — mark PAID and let the existing
|
||||
// paid-booking machinery place it. No invoice is generated for it.
|
||||
// Its price IS the credit (already paid, in the source currency) — not a
|
||||
// fresh live-rate quote; a later cut of the rebooked booking credits from it.
|
||||
await this.dataSource.getRepository(Booking).update(newBookingId, {
|
||||
paymentStatus: 'PAID',
|
||||
status: 'PAID',
|
||||
totalAmount: Number(row.creditAmount),
|
||||
paymentCurrency: source.paymentCurrency,
|
||||
});
|
||||
await this.copyClearanceState(source, newBookingId);
|
||||
|
||||
@@ -539,9 +610,9 @@ export class BookingWagonCancellationService {
|
||||
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
|
||||
}
|
||||
wagons = round2(wagons);
|
||||
if (wagons >= totalWagons) {
|
||||
if (wagons > totalWagons) {
|
||||
throw new BadRequestException(
|
||||
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
|
||||
`Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`,
|
||||
);
|
||||
}
|
||||
// Snapshot the LIFO-picked physical units up front (read-only — cargo is
|
||||
@@ -577,9 +648,11 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
const weightShare = round3(
|
||||
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
|
||||
);
|
||||
// Whole-booking cut takes the exact total, no ratio rounding.
|
||||
const weightShare =
|
||||
wagons >= totalWagons
|
||||
? round3(Number(booking.cargoTotalWeightVgm))
|
||||
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
|
||||
return {
|
||||
wagons,
|
||||
weightTons: weightShare,
|
||||
@@ -594,17 +667,19 @@ export class BookingWagonCancellationService {
|
||||
if (!wagons || wagons <= 0) {
|
||||
throw new BadRequestException('Specify how many wagons to cancel.');
|
||||
}
|
||||
if (wagons >= totalWagons) {
|
||||
if (wagons > totalWagons) {
|
||||
throw new BadRequestException(
|
||||
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
|
||||
`Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`,
|
||||
);
|
||||
}
|
||||
// Whole-booking cut: all cargo, exactly. Otherwise proportional sizing.
|
||||
// ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item
|
||||
// rounding happens here too; switch to items_per_wagon_map sizing if bulk
|
||||
// PER_ITEM cancels ever need to be exact per item.
|
||||
let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons);
|
||||
const isFull = wagons >= totalWagons;
|
||||
let tons = Number(booking.cargoTotalWeightVgm) * (isFull ? 1 : wagons / totalWagons);
|
||||
const isPerItem = booking.bulkTotalWeightTons != null;
|
||||
tons = isPerItem ? Math.floor(tons) : round3(tons);
|
||||
tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons);
|
||||
if (tons <= 0) {
|
||||
throw new BadRequestException('The requested cut is too small to release cargo.');
|
||||
}
|
||||
@@ -641,19 +716,23 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
const wagons = allocations.length;
|
||||
if (wagons >= totalWagons) {
|
||||
if (wagons > totalWagons) {
|
||||
throw new BadRequestException(
|
||||
'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.',
|
||||
`Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`,
|
||||
);
|
||||
}
|
||||
const isFull = wagons >= totalWagons;
|
||||
|
||||
if (booking.freightType !== 'CONTAINER') {
|
||||
const allocated = allocations.reduce(
|
||||
(s, a) => s + Number(a.allocatedWeightTons || 0),
|
||||
0,
|
||||
);
|
||||
const tons =
|
||||
allocated > 0
|
||||
// Whole-booking cut takes the exact total; partial takes the wagons'
|
||||
// allocated tonnage (ratio fallback when nothing is allocated yet).
|
||||
const tons = isFull
|
||||
? round3(Number(booking.cargoTotalWeightVgm))
|
||||
: allocated > 0
|
||||
? round3(allocated)
|
||||
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
|
||||
return {
|
||||
@@ -714,21 +793,83 @@ export class BookingWagonCancellationService {
|
||||
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
|
||||
}
|
||||
|
||||
private async feeRate(): Promise<Rate> {
|
||||
const rate = await this.dataSource.getRepository(Rate).findOne({
|
||||
/**
|
||||
* Price the cut off the LIVE per-wagon cancellation rates for the booking's
|
||||
* trade direction. Bulk bills the rate scoped to the booking's commodity ×
|
||||
* cancelled wagons; a container cut bills each size at its own container
|
||||
* type's rate × the wagons that size occupies (two 20ft share one). A
|
||||
* booking owned by a shipping line prices off that line's rates only —
|
||||
* standard rates are never a fallback, matching booking pricing.
|
||||
*/
|
||||
private async priceFee(booking: Booking, cut: RequestedCut): Promise<PricedFee> {
|
||||
const raw = await this.priceFeeInRateCurrency(booking, cut);
|
||||
// Bill in the booking's own currency (rates are configured in USD; ETB
|
||||
// bookings pay ETB) — same USD→ETB conversion booking pricing applies.
|
||||
const target = booking.paymentCurrency === 'ETB' ? 'ETB' : 'USD';
|
||||
const from = raw.currency === 'ETB' ? 'ETB' : 'USD';
|
||||
if (from === target) return raw;
|
||||
const fx = await this.exchangeService.getRate(from, target);
|
||||
return {
|
||||
...raw,
|
||||
amount: round2(raw.amount * fx),
|
||||
perWagon: round2(raw.perWagon * fx),
|
||||
currency: target,
|
||||
};
|
||||
}
|
||||
|
||||
private async priceFeeInRateCurrency(
|
||||
booking: Booking,
|
||||
cut: RequestedCut,
|
||||
): Promise<PricedFee> {
|
||||
const rates = await this.dataSource.getRepository(Rate).find({
|
||||
where: {
|
||||
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
|
||||
rateUnit: 'PER_WAGON',
|
||||
status: 'LIVE',
|
||||
tradeDirection: booking.tradeDirection,
|
||||
shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(),
|
||||
},
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
if (!rate) {
|
||||
throw new BadRequestException(
|
||||
'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).',
|
||||
const missing = (scope: string): BadRequestException =>
|
||||
new BadRequestException(
|
||||
`No LIVE per-wagon cancellation fee is configured for ${scope} on ${booking.tradeDirection} — ask EDR to set it in the rate engine (surcharge: Cancellation).`,
|
||||
);
|
||||
|
||||
if (booking.freightType !== 'CONTAINER') {
|
||||
const rate = rates.find(
|
||||
(r) => !r.containerTypeId && !!r.cargoTypeId && r.cargoTypeId === booking.cargoTypeId,
|
||||
);
|
||||
if (!rate) throw missing(`bulk cargo type ${booking.cargoType?.cargoTypeName ?? booking.cargoTypeId ?? '?'}`);
|
||||
const amount = round2(Number(rate.rateValue) * cut.wagons);
|
||||
return { amount, currency: rate.currency, perWagon: Number(rate.rateValue), rates: [rate] };
|
||||
}
|
||||
return rate;
|
||||
|
||||
// Container: split the cancelled wagons across sizes in proportion to the
|
||||
// wagon-space each size's units occupy, so the total always equals
|
||||
// cut.wagons (whole wagons on an allocation cut, fractional on a quantity cut).
|
||||
const bySize = Object.entries(cut.quantities.bySize ?? {}).filter(([, qty]) => qty > 0);
|
||||
const spaceOf = ([size, qty]: [string, number]) => qty * wagonsPerUnitForSize(Number(size));
|
||||
const totalSpace = bySize.reduce((s, e) => s + spaceOf(e), 0);
|
||||
if (!bySize.length || totalSpace <= 0) throw missing('containers');
|
||||
const containerTypes = await this.dataSource.getRepository(ContainerType).find();
|
||||
const used: Rate[] = [];
|
||||
let amount = 0;
|
||||
let currency = '';
|
||||
for (const entry of bySize) {
|
||||
const [size] = entry;
|
||||
const sizeFt = Number(size);
|
||||
const typeIds = new Set(
|
||||
containerTypes.filter((ct) => Number(ct.sizeFt) === sizeFt).map((ct) => ct.id),
|
||||
);
|
||||
const rate = rates.find((r) => !!r.containerTypeId && typeIds.has(r.containerTypeId));
|
||||
if (!rate) throw missing(`${sizeFt || '?'}ft containers`);
|
||||
currency = rate.currency;
|
||||
used.push(rate);
|
||||
amount += Number(rate.rateValue) * cut.wagons * (spaceOf(entry) / totalSpace);
|
||||
}
|
||||
amount = round2(amount);
|
||||
return { amount, currency, perWagon: round2(amount / cut.wagons), rates: used };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -902,9 +1043,9 @@ export class BookingWagonCancellationService {
|
||||
booking: Booking,
|
||||
tons: number,
|
||||
): Promise<void> {
|
||||
if (tons >= Number(booking.cargoTotalWeightVgm)) {
|
||||
if (tons > Number(booking.cargoTotalWeightVgm)) {
|
||||
throw new BadRequestException(
|
||||
'Booking changed since the request: the cut no longer leaves any cargo.',
|
||||
'Booking changed since the request: the cut exceeds the cargo left on the booking.',
|
||||
);
|
||||
}
|
||||
if (booking.bulkTotalWeightTons != null) {
|
||||
|
||||
@@ -53,7 +53,7 @@ export class CreateRateDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: CARGO_KINDS,
|
||||
description:
|
||||
'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.',
|
||||
'Whether a customs clearance / cancellation rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or CANCELLATION. Not stored — container fees carry a containerTypeId, bulk fees a cargoTypeId.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...CARGO_KINDS])
|
||||
|
||||
@@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => {
|
||||
).toEqual(["PER_TON"]);
|
||||
});
|
||||
|
||||
it("bills the wagon cancellation fee per wagon only, whatever the cargo kind", () => {
|
||||
for (const cargoKind of ["CONTAINER", "BULK"] as const) {
|
||||
expect(
|
||||
allowedRateUnits({ appliesTo: "OTHER", trigger: "CANCELLATION", cargoKind }),
|
||||
).toEqual(["PER_WAGON"]);
|
||||
}
|
||||
expect(
|
||||
allowedRateUnits({
|
||||
appliesTo: "OTHER",
|
||||
trigger: "CANCELLATION",
|
||||
cargoKind: "BULK",
|
||||
cargoUnitOfMeasure: "PER_ITEM",
|
||||
}),
|
||||
).toEqual(["PER_WAGON"]);
|
||||
});
|
||||
|
||||
it("treats per-ton and per-item as the same booking quantity", () => {
|
||||
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
|
||||
expect(isBulkQuantityUnit("PER_ITEM")).toBe(true);
|
||||
|
||||
@@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
|
||||
* Which rate units make sense for a given rate shape. The weighting basis is
|
||||
* driven by the *type* of thing being billed — a container leg bills per
|
||||
* container, bulk freight per ton, an intercity move can be per-km, a
|
||||
* cancellation is a flat/per-invoice fee, and overweight is always per excess
|
||||
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
|
||||
* cancellation is a per-wagon fee, and overweight is always per excess ton. This keeps the rate table dynamic yet non-conflicting: the admin can
|
||||
* only pick a unit the pricing engine knows how to apply.
|
||||
*
|
||||
* A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers
|
||||
@@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean =>
|
||||
export function allowedRateUnits(input: {
|
||||
appliesTo: RateAppliesTo;
|
||||
trigger: RateTrigger;
|
||||
/** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */
|
||||
/** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */
|
||||
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
|
||||
cargoUnitOfMeasure?: CargoUom;
|
||||
@@ -64,7 +63,9 @@ function unitsForShape(input: {
|
||||
// wagon the empties ride back on, or a flat fee.
|
||||
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
|
||||
case 'CANCELLATION':
|
||||
return ['FLAT', 'PER_INVOICE'];
|
||||
// Wagon cancellation fee — scales with the cancelled wagon count, so
|
||||
// per wagon is the only unit the wagon-cancel flow can apply.
|
||||
return ['PER_WAGON'];
|
||||
case 'CUSTOMS_CLEARANCE':
|
||||
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||
// fees per ton or per wagon. Billed on the booking invoice.
|
||||
|
||||
@@ -25,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito
|
||||
|
||||
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
||||
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
|
||||
/**
|
||||
* Surcharges sold per cargo kind: the admin says container or bulk, a
|
||||
* container fee then names its container type and a bulk fee its commodity.
|
||||
*/
|
||||
const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION'];
|
||||
/** Surcharges that keep a trade direction (everything else is direction-agnostic). */
|
||||
const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [
|
||||
'CUSTOMS_CLEARANCE',
|
||||
'CANCELLATION',
|
||||
'WITH_RETURN',
|
||||
'LASHING',
|
||||
'FUEL',
|
||||
];
|
||||
|
||||
/** The yard pair a rate scopes to, already validated against its direction. */
|
||||
interface YardScope {
|
||||
@@ -152,7 +165,11 @@ export class RatesService {
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
): boolean {
|
||||
return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING';
|
||||
return (
|
||||
this.isRouteScoped(appliesTo, trigger) ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'CANCELLATION'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -244,10 +261,13 @@ export class RatesService {
|
||||
}): void {
|
||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||
const { containerTypeId, cargoTypeId } = input;
|
||||
if (trigger === 'CUSTOMS_CLEARANCE') {
|
||||
if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') {
|
||||
// Both fees are sold per direction + cargo kind + type: customs clearance
|
||||
// per lane, the wagon cancellation fee per direction only.
|
||||
const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance';
|
||||
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||
throw new BadRequestException(
|
||||
'A customs clearance rate must say whether it covers IMPORT or EXPORT.',
|
||||
`A ${fee} rate must say whether it covers IMPORT or EXPORT.`,
|
||||
);
|
||||
}
|
||||
// Sold per cargo kind: a container fee names the container type it covers
|
||||
@@ -255,29 +275,29 @@ export class RatesService {
|
||||
// that absence is what marks it as the bulk fee.
|
||||
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||||
throw new BadRequestException(
|
||||
'A customs clearance rate must say whether it covers containers or bulk.',
|
||||
`A ${fee} rate must say whether it covers containers or bulk.`,
|
||||
);
|
||||
}
|
||||
if (cargoKind === 'CONTAINER' && !containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A container customs clearance rate must name the container type it covers.',
|
||||
`A container ${fee} rate must name the container type it covers.`,
|
||||
);
|
||||
}
|
||||
if (cargoKind === 'BULK' && containerTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A bulk customs clearance rate cannot be scoped to a container type.',
|
||||
`A bulk ${fee} rate cannot be scoped to a container type.`,
|
||||
);
|
||||
}
|
||||
// The bulk customs fee names the commodity it covers (sugar and
|
||||
// fertilizer clear differently).
|
||||
// The bulk fee names the commodity it covers (sugar and fertilizer
|
||||
// clear — and cancel — differently).
|
||||
if (cargoKind === 'BULK' && !cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A bulk customs clearance rate must name the bulk cargo type it covers.',
|
||||
`A bulk ${fee} rate must name the bulk cargo type it covers.`,
|
||||
);
|
||||
}
|
||||
if (cargoKind === 'CONTAINER' && cargoTypeId) {
|
||||
throw new BadRequestException(
|
||||
'A container customs clearance rate cannot be scoped to a bulk cargo type.',
|
||||
`A container ${fee} rate cannot be scoped to a bulk cargo type.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -547,22 +567,21 @@ export class RatesService {
|
||||
const trigger = dto.trigger as Rate['trigger'];
|
||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||
// the engine never accidentally narrows a surcharge by container/direction.
|
||||
// Exceptions: customs clearance and empty-container return keep direction +
|
||||
// container type — both are sold per lane (and per container type).
|
||||
// Exceptions: the directed surcharges (customs clearance, cancellation,
|
||||
// empty-container return, lashing, fuel) keep direction + cargo scope.
|
||||
const isSurcharge = trigger !== 'ALWAYS';
|
||||
const cargoKind =
|
||||
trigger === 'CUSTOMS_CLEARANCE'
|
||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||
: null;
|
||||
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
|
||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||
: null;
|
||||
const containerTypeId =
|
||||
trigger === 'WITH_RETURN' ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
||||
? (dto.containerTypeId ?? null)
|
||||
: isSurcharge
|
||||
? null
|
||||
: (dto.containerTypeId ?? null);
|
||||
const cargoTypeId =
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
? (dto.cargoTypeId ?? null)
|
||||
@@ -574,10 +593,7 @@ export class RatesService {
|
||||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||||
// nothing about the direction.)
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||
? (dto.tradeDirection ?? null)
|
||||
: isSurcharge || appliesTo === 'INTERCITY'
|
||||
? null
|
||||
@@ -758,16 +774,15 @@ export class RatesService {
|
||||
|
||||
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
||||
// has — read back off its container scope (container fees carry the type).
|
||||
const cargoKind =
|
||||
trigger !== 'CUSTOMS_CLEARANCE'
|
||||
? null
|
||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||
const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger)
|
||||
? null
|
||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||
|
||||
const keepsContainerType =
|
||||
!isSurcharge ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
||||
const containerTypeId = !keepsContainerType
|
||||
? null
|
||||
: dto.containerTypeId !== undefined
|
||||
@@ -775,7 +790,7 @@ export class RatesService {
|
||||
: existing.containerTypeId;
|
||||
const keepsCargoType =
|
||||
!isSurcharge ||
|
||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
||||
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL';
|
||||
const cargoTypeId = !keepsCargoType
|
||||
@@ -784,10 +799,7 @@ export class RatesService {
|
||||
? dto.cargoTypeId
|
||||
: existing.cargoTypeId;
|
||||
const tradeDirection =
|
||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
||||
trigger === 'WITH_RETURN' ||
|
||||
trigger === 'LASHING' ||
|
||||
trigger === 'FUEL'
|
||||
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||
? dto.tradeDirection !== undefined
|
||||
? dto.tradeDirection
|
||||
: existing.tradeDirection
|
||||
|
||||
@@ -2160,7 +2160,15 @@ export class TrainSchedulingService {
|
||||
return { ...detail, warnings, deferredBookings };
|
||||
}
|
||||
|
||||
async unassignBooking(scheduleId: string, bookingId: string, userId?: string) {
|
||||
async unassignBooking(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
userId?: string,
|
||||
opts: {
|
||||
/** false = system detach (e.g. booking cancelled) — no "removed from train, rebook" notice. */
|
||||
notifyCustomer?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -2287,7 +2295,9 @@ export class TrainSchedulingService {
|
||||
const removedBooking = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId }, relations: { company: true } });
|
||||
if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking);
|
||||
if (removedBooking && opts.notifyCustomer !== false) {
|
||||
this.bookingNotifier.removedFromTrain(removedBooking);
|
||||
}
|
||||
this.logger.log(
|
||||
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
|
||||
);
|
||||
@@ -4409,6 +4419,7 @@ export class TrainSchedulingService {
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
scheduleBookings: { booking: true },
|
||||
shippingLineCompany: true,
|
||||
},
|
||||
order: { [sortBy]: sortOrder } as never,
|
||||
skip,
|
||||
@@ -6107,6 +6118,10 @@ export class TrainSchedulingService {
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
// Dedicated shipping-line departure (hidden from customers) — the list
|
||||
// highlights these rows so staff can tell them apart at a glance.
|
||||
shippingLineCompanyId: schedule.shippingLineCompanyId ?? null,
|
||||
shippingLineCompanyName: schedule.shippingLineCompany?.name ?? null,
|
||||
// Built train (Train Builder) behind this departure, when scheduled by train.
|
||||
train: schedule.trainSet?.train
|
||||
? {
|
||||
@@ -8997,8 +9012,14 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
||||
// Cargo type → allowed wagon types rides along: for bulk, the commodity's
|
||||
// own wagon-type list (the planner's rule) decides, not only the wagon
|
||||
// type's generic supportedLoadTypes.
|
||||
const loadAllocations = (trainSetWagonId: string) =>
|
||||
allocRepo.find({ where: { trainSetWagonId } });
|
||||
allocRepo.find({
|
||||
where: { trainSetWagonId },
|
||||
relations: { booking: { cargoType: { wagonTypes: true } } },
|
||||
});
|
||||
const sourceAllocs = await loadAllocations(source.id);
|
||||
if (!sourceAllocs.length) {
|
||||
throw new BadRequestException('Source wagon has no load to move');
|
||||
@@ -9043,10 +9064,25 @@ export class TrainSchedulingService {
|
||||
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
|
||||
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
|
||||
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
|
||||
// Bulk is allowed on a wagon type when every bulk load's cargo type lists
|
||||
// it (cargo-type ↔ wagon-type config, same rule the wagon planner uses).
|
||||
const bulkCargoAllows = (allocs: WagonBookingAllocation[], wagonTypeId?: string) => {
|
||||
const bulk = allocs.filter((a) => (a.loadType ?? 'CONTAINER').toUpperCase() === 'BULK');
|
||||
return (
|
||||
!!wagonTypeId &&
|
||||
bulk.length > 0 &&
|
||||
bulk.every((a) =>
|
||||
(a.booking?.cargoType?.wagonTypes ?? []).some((wt) => wt.id === wagonTypeId),
|
||||
)
|
||||
);
|
||||
};
|
||||
const checkReceives = (
|
||||
allocs: WagonBookingAllocation[],
|
||||
label: string,
|
||||
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
|
||||
wagonType:
|
||||
| { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean }
|
||||
| null
|
||||
| undefined,
|
||||
capacityTons: number,
|
||||
) => {
|
||||
const incoming = loadTypesOf(allocs);
|
||||
@@ -9057,6 +9093,7 @@ export class TrainSchedulingService {
|
||||
const ok =
|
||||
supported.includes(loadType) ||
|
||||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
||||
(loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) ||
|
||||
supported.length === 0;
|
||||
if (!ok) {
|
||||
throw new BadRequestException(
|
||||
|
||||
Reference in New Issue
Block a user