mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
booking cancellation
This commit is contained in:
9
apps/edr-freight-api/.q.mjs
Normal file
9
apps/edr-freight-api/.q.mjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import pg from 'pg';
|
||||||
|
import fs from 'fs';
|
||||||
|
const env = Object.fromEntries(fs.readFileSync('.env','utf8').split('\n').filter(l=>/^[A-Z_]+=/.test(l)).map(l=>{const i=l.indexOf('=');return [l.slice(0,i),l.slice(i+1).replace(/^"|"$/g,'')]}));
|
||||||
|
const c = new pg.Client({host:env.DB_HOST,port:+env.DB_PORT,database:env.DB_NAME,user:env.DB_USER,password:env.DB_PASSWORD});
|
||||||
|
await c.connect();
|
||||||
|
const sql = process.argv[2];
|
||||||
|
const r = await c.query(sql);
|
||||||
|
console.log(JSON.stringify(r.rows,null,1));
|
||||||
|
await c.end();
|
||||||
@@ -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")
|
@Get("invoices/:id")
|
||||||
@ApiOperation({ summary: "Get an invoice with its line items" })
|
@ApiOperation({ summary: "Get an invoice with its line items" })
|
||||||
findById(@Param("id", ParseUUIDPipe) id: string) {
|
findById(@Param("id", ParseUUIDPipe) id: string) {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { EventEmitter2 } from "@nestjs/event-emitter";
|
import { EventEmitter2 } from "@nestjs/event-emitter";
|
||||||
import { logCtx } from "@edr/api-common";
|
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";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
// Entity-only import (no module edge): portal reads resolve shipping-line
|
// 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
|
* company (customer detail "Invoices" tab) and/or status/search (global
|
||||||
* invoices page).
|
* 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(
|
async findAllPaginated(
|
||||||
filter: {
|
filter: {
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
@@ -215,33 +249,43 @@ export class BillingService {
|
|||||||
.skip((page - 1) * pageSize)
|
.skip((page - 1) * pageSize)
|
||||||
.take(pageSize);
|
.take(pageSize);
|
||||||
|
|
||||||
if (filter.companyId) {
|
this.applyInvoiceFilters(qb, filter);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [items, total] = await qb.getManyAndCount();
|
const [items, total] = await qb.getManyAndCount();
|
||||||
return { items, total };
|
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,
|
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
|
||||||
* never through the gateway), open ones by default or a single status when
|
* 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,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { ExchangeService } from '@edr/api-common';
|
||||||
import { Freight, NotificationAudience, NotificationType } from '@edr/types';
|
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 { BillingService } from '../billing/billing.service';
|
||||||
import { ContractBookingService } from '../contracts/contract-booking.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 { FirstMileService } from '../first-mile/first-mile.service';
|
||||||
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
|
||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
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 { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.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 { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.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
|
* rates.rate_type of the cancellation fee — an existing rate-engine type
|
||||||
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
|
* (trigger CANCELLATION, never auto-applied to booking pricing). Staff
|
||||||
* configure it in the normal rates UI; the wagon flow requires the PER_WAGON
|
* configure it in the normal rates UI, one PER_WAGON rate per trade direction
|
||||||
* unit so the fee scales with the cancelled wagon count.
|
* + 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';
|
export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE';
|
||||||
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
|
/** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */
|
||||||
@@ -62,8 +66,20 @@ interface RequestedCut {
|
|||||||
quantities: CancelledQuantities;
|
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):
|
* Lifecycle (one ledger row per cycle, see BookingWagonCancellation):
|
||||||
* T1 request — validate + price the fee, open the fee invoice. Nothing else
|
* 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 repo: BookingWagonCancellationsRepository,
|
||||||
private readonly bookingsRepository: BookingsRepository,
|
private readonly bookingsRepository: BookingsRepository,
|
||||||
private readonly billing: BillingService,
|
private readonly billing: BillingService,
|
||||||
|
private readonly exchangeService: ExchangeService,
|
||||||
@Inject(forwardRef(() => ContractBookingService))
|
@Inject(forwardRef(() => ContractBookingService))
|
||||||
private readonly contractBooking: ContractBookingService,
|
private readonly contractBooking: ContractBookingService,
|
||||||
@Inject(forwardRef(() => ClearanceMilestoneService))
|
@Inject(forwardRef(() => ClearanceMilestoneService))
|
||||||
@@ -120,14 +137,13 @@ export class BookingWagonCancellationService {
|
|||||||
}> {
|
}> {
|
||||||
const booking = await this.loadCancellableBooking(bookingId);
|
const booking = await this.loadCancellableBooking(bookingId);
|
||||||
const cut = await this.resolveRequestedCut(booking, dto);
|
const cut = await this.resolveRequestedCut(booking, dto);
|
||||||
const rate = await this.feeRate();
|
const fee = await this.priceFee(booking, cut);
|
||||||
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
|
|
||||||
return {
|
return {
|
||||||
wagons: cut.wagons,
|
wagons: cut.wagons,
|
||||||
weightTons: cut.weightTons,
|
weightTons: cut.weightTons,
|
||||||
feePerWagon: Number(rate.rateValue),
|
feePerWagon: fee.perWagon,
|
||||||
feeAmount,
|
feeAmount: fee.amount,
|
||||||
feeCurrency: rate.currency,
|
feeCurrency: fee.currency,
|
||||||
creditAmount: this.creditFor(booking, cut.wagons),
|
creditAmount: this.creditFor(booking, cut.wagons),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -146,8 +162,8 @@ export class BookingWagonCancellationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cut = await this.resolveRequestedCut(booking, dto);
|
const cut = await this.resolveRequestedCut(booking, dto);
|
||||||
const rate = await this.feeRate();
|
const fee = await this.priceFee(booking, cut);
|
||||||
const feeAmount = round2(Number(rate.rateValue) * cut.wagons);
|
const feeAmount = fee.amount;
|
||||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||||
|
|
||||||
const row = await this.repo.create({
|
const row = await this.repo.create({
|
||||||
@@ -156,9 +172,11 @@ export class BookingWagonCancellationService {
|
|||||||
weightTons: cut.weightTons,
|
weightTons: cut.weightTons,
|
||||||
cancelledQuantities: cut.quantities,
|
cancelledQuantities: cut.quantities,
|
||||||
creditAmount,
|
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,
|
feeAmount,
|
||||||
feeCurrency: rate.currency,
|
feeCurrency: fee.currency,
|
||||||
status: 'FEE_PENDING',
|
status: 'FEE_PENDING',
|
||||||
reason: dto.reason ?? null,
|
reason: dto.reason ?? null,
|
||||||
requestedByUserId: userId ?? null,
|
requestedByUserId: userId ?? null,
|
||||||
@@ -173,15 +191,15 @@ export class BookingWagonCancellationService {
|
|||||||
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||||
companyId: booking.companyId,
|
companyId: booking.companyId,
|
||||||
companyProfileId: booking.companyProfileId,
|
companyProfileId: booking.companyProfileId,
|
||||||
currency: rate.currency,
|
currency: fee.currency,
|
||||||
lines: [
|
lines: [
|
||||||
{
|
{
|
||||||
chargeType: 'CANCELLATION_FEE',
|
chargeType: 'CANCELLATION_FEE',
|
||||||
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
|
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`,
|
||||||
quantity: cut.wagons,
|
quantity: cut.wagons,
|
||||||
unitRate: Number(rate.rateValue),
|
unitRate: fee.perWagon,
|
||||||
amount: feeAmount,
|
amount: feeAmount,
|
||||||
currency: rate.currency,
|
currency: fee.currency,
|
||||||
metadata: { wagonCancellationId: row.id },
|
metadata: { wagonCancellationId: row.id },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -343,12 +361,28 @@ export class BookingWagonCancellationService {
|
|||||||
const preSplitQuantities =
|
const preSplitQuantities =
|
||||||
booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight));
|
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, {
|
await manager.getRepository(Booking).update(booking.id, {
|
||||||
wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)),
|
wagonsRequired: Math.max(0, wagonsLeft),
|
||||||
cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
cargoTotalWeightVgm: Math.max(
|
||||||
totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)),
|
0,
|
||||||
|
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
||||||
|
),
|
||||||
|
totalAmount: Math.max(
|
||||||
|
0,
|
||||||
|
round2(Number(booking.totalAmount) - Number(row.creditAmount)),
|
||||||
|
),
|
||||||
isSplit: true,
|
isSplit: true,
|
||||||
preSplitQuantities,
|
preSplitQuantities,
|
||||||
|
...(isFull
|
||||||
|
? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null }
|
||||||
|
: {}),
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
||||||
@@ -360,11 +394,15 @@ export class BookingWagonCancellationService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const booking = await this.bookingsRepository.findById(row.bookingId);
|
const booking = await this.bookingsRepository.findById(row.bookingId);
|
||||||
|
if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking);
|
||||||
if (booking) {
|
if (booking) {
|
||||||
|
const whole = booking.status === 'CANCELLED';
|
||||||
this.notifyCustomer(
|
this.notifyCustomer(
|
||||||
booking,
|
booking,
|
||||||
'Wagon cancellation confirmed',
|
whole ? 'Booking cancelled — credit available' : '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
|
||||||
|
? `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(
|
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 ──────────────────────────────────────────────────────────────
|
// ── T3: rebook ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async rebook(
|
async rebook(
|
||||||
@@ -401,6 +466,8 @@ export class BookingWagonCancellationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const createDto = this.buildRebookDto(row, dto.scheduledDate);
|
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(
|
const created = await this.contractBooking.createUnderContract(
|
||||||
source.contractId,
|
source.contractId,
|
||||||
createDto,
|
createDto,
|
||||||
@@ -413,9 +480,13 @@ export class BookingWagonCancellationService {
|
|||||||
|
|
||||||
// The freight is already paid (credit) — mark PAID and let the existing
|
// The freight is already paid (credit) — mark PAID and let the existing
|
||||||
// paid-booking machinery place it. No invoice is generated for it.
|
// 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, {
|
await this.dataSource.getRepository(Booking).update(newBookingId, {
|
||||||
paymentStatus: 'PAID',
|
paymentStatus: 'PAID',
|
||||||
status: 'PAID',
|
status: 'PAID',
|
||||||
|
totalAmount: Number(row.creditAmount),
|
||||||
|
paymentCurrency: source.paymentCurrency,
|
||||||
});
|
});
|
||||||
await this.copyClearanceState(source, newBookingId);
|
await this.copyClearanceState(source, newBookingId);
|
||||||
|
|
||||||
@@ -539,9 +610,9 @@ export class BookingWagonCancellationService {
|
|||||||
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
|
wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize));
|
||||||
}
|
}
|
||||||
wagons = round2(wagons);
|
wagons = round2(wagons);
|
||||||
if (wagons >= totalWagons) {
|
if (wagons > totalWagons) {
|
||||||
throw new BadRequestException(
|
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
|
// Snapshot the LIFO-picked physical units up front (read-only — cargo is
|
||||||
@@ -577,9 +648,11 @@ export class BookingWagonCancellationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const weightShare = round3(
|
// Whole-booking cut takes the exact total, no ratio rounding.
|
||||||
Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons),
|
const weightShare =
|
||||||
);
|
wagons >= totalWagons
|
||||||
|
? round3(Number(booking.cargoTotalWeightVgm))
|
||||||
|
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
|
||||||
return {
|
return {
|
||||||
wagons,
|
wagons,
|
||||||
weightTons: weightShare,
|
weightTons: weightShare,
|
||||||
@@ -594,17 +667,19 @@ export class BookingWagonCancellationService {
|
|||||||
if (!wagons || wagons <= 0) {
|
if (!wagons || wagons <= 0) {
|
||||||
throw new BadRequestException('Specify how many wagons to cancel.');
|
throw new BadRequestException('Specify how many wagons to cancel.');
|
||||||
}
|
}
|
||||||
if (wagons >= totalWagons) {
|
if (wagons > totalWagons) {
|
||||||
throw new BadRequestException(
|
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
|
// ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item
|
||||||
// rounding happens here too; switch to items_per_wagon_map sizing if bulk
|
// rounding happens here too; switch to items_per_wagon_map sizing if bulk
|
||||||
// PER_ITEM cancels ever need to be exact per item.
|
// 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;
|
const isPerItem = booking.bulkTotalWeightTons != null;
|
||||||
tons = isPerItem ? Math.floor(tons) : round3(tons);
|
tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons);
|
||||||
if (tons <= 0) {
|
if (tons <= 0) {
|
||||||
throw new BadRequestException('The requested cut is too small to release cargo.');
|
throw new BadRequestException('The requested cut is too small to release cargo.');
|
||||||
}
|
}
|
||||||
@@ -641,19 +716,23 @@ export class BookingWagonCancellationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const wagons = allocations.length;
|
const wagons = allocations.length;
|
||||||
if (wagons >= totalWagons) {
|
if (wagons > totalWagons) {
|
||||||
throw new BadRequestException(
|
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') {
|
if (booking.freightType !== 'CONTAINER') {
|
||||||
const allocated = allocations.reduce(
|
const allocated = allocations.reduce(
|
||||||
(s, a) => s + Number(a.allocatedWeightTons || 0),
|
(s, a) => s + Number(a.allocatedWeightTons || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
const tons =
|
// Whole-booking cut takes the exact total; partial takes the wagons'
|
||||||
allocated > 0
|
// allocated tonnage (ratio fallback when nothing is allocated yet).
|
||||||
|
const tons = isFull
|
||||||
|
? round3(Number(booking.cargoTotalWeightVgm))
|
||||||
|
: allocated > 0
|
||||||
? round3(allocated)
|
? round3(allocated)
|
||||||
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
|
: round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons));
|
||||||
return {
|
return {
|
||||||
@@ -714,21 +793,83 @@ export class BookingWagonCancellationService {
|
|||||||
return round2(Number(booking.totalAmount) * (wagons / totalWagons));
|
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: {
|
where: {
|
||||||
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
|
rateType: WAGON_CANCELLATION_FEE_RATE_TYPE,
|
||||||
rateUnit: 'PER_WAGON',
|
rateUnit: 'PER_WAGON',
|
||||||
status: 'LIVE',
|
status: 'LIVE',
|
||||||
|
tradeDirection: booking.tradeDirection,
|
||||||
|
shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(),
|
||||||
},
|
},
|
||||||
order: { createdAt: 'DESC' },
|
order: { createdAt: 'DESC' },
|
||||||
});
|
});
|
||||||
if (!rate) {
|
const missing = (scope: string): BadRequestException =>
|
||||||
throw new BadRequestException(
|
new BadRequestException(
|
||||||
'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).',
|
`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,
|
booking: Booking,
|
||||||
tons: number,
|
tons: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (tons >= Number(booking.cargoTotalWeightVgm)) {
|
if (tons > Number(booking.cargoTotalWeightVgm)) {
|
||||||
throw new BadRequestException(
|
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) {
|
if (booking.bulkTotalWeightTons != null) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class CreateRateDto {
|
|||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: CARGO_KINDS,
|
enum: CARGO_KINDS,
|
||||||
description:
|
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()
|
@IsOptional()
|
||||||
@IsIn([...CARGO_KINDS])
|
@IsIn([...CARGO_KINDS])
|
||||||
|
|||||||
@@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => {
|
|||||||
).toEqual(["PER_TON"]);
|
).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", () => {
|
it("treats per-ton and per-item as the same booking quantity", () => {
|
||||||
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
|
expect(isBulkQuantityUnit("PER_TON")).toBe(true);
|
||||||
expect(isBulkQuantityUnit("PER_ITEM")).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
|
* 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
|
* 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
|
* 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
|
* 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
|
||||||
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
|
|
||||||
* only pick a unit the pricing engine knows how to apply.
|
* 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
|
* 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: {
|
export function allowedRateUnits(input: {
|
||||||
appliesTo: RateAppliesTo;
|
appliesTo: RateAppliesTo;
|
||||||
trigger: RateTrigger;
|
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;
|
cargoKind?: 'CONTAINER' | 'BULK' | null;
|
||||||
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
|
/** Unit of measure of the bulk commodity the rate is scoped to, when any. */
|
||||||
cargoUnitOfMeasure?: CargoUom;
|
cargoUnitOfMeasure?: CargoUom;
|
||||||
@@ -64,7 +63,9 @@ function unitsForShape(input: {
|
|||||||
// wagon the empties ride back on, or a flat fee.
|
// wagon the empties ride back on, or a flat fee.
|
||||||
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
|
return ['PER_CONTAINER', 'PER_WAGON', 'FLAT'];
|
||||||
case 'CANCELLATION':
|
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':
|
case 'CUSTOMS_CLEARANCE':
|
||||||
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
// Sold per cargo kind: container fees bill per box or per wagon, bulk
|
||||||
// fees per ton or per wagon. Billed on the booking invoice.
|
// 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. */
|
/** Categories priced per rail leg — they carry an origin → destination yard pair. */
|
||||||
const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY'];
|
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. */
|
/** The yard pair a rate scopes to, already validated against its direction. */
|
||||||
interface YardScope {
|
interface YardScope {
|
||||||
@@ -152,7 +165,11 @@ export class RatesService {
|
|||||||
appliesTo: Rate['appliesTo'],
|
appliesTo: Rate['appliesTo'],
|
||||||
trigger: Rate['trigger'],
|
trigger: Rate['trigger'],
|
||||||
): boolean {
|
): 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 {
|
}): void {
|
||||||
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input;
|
||||||
const { containerTypeId, cargoTypeId } = 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') {
|
if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') {
|
||||||
throw new BadRequestException(
|
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
|
// 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.
|
// that absence is what marks it as the bulk fee.
|
||||||
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') {
|
||||||
throw new BadRequestException(
|
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) {
|
if (cargoKind === 'CONTAINER' && !containerTypeId) {
|
||||||
throw new BadRequestException(
|
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) {
|
if (cargoKind === 'BULK' && containerTypeId) {
|
||||||
throw new BadRequestException(
|
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
|
// The bulk fee names the commodity it covers (sugar and fertilizer
|
||||||
// fertilizer clear differently).
|
// clear — and cancel — differently).
|
||||||
if (cargoKind === 'BULK' && !cargoTypeId) {
|
if (cargoKind === 'BULK' && !cargoTypeId) {
|
||||||
throw new BadRequestException(
|
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) {
|
if (cargoKind === 'CONTAINER' && cargoTypeId) {
|
||||||
throw new BadRequestException(
|
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;
|
return;
|
||||||
@@ -547,22 +567,21 @@ export class RatesService {
|
|||||||
const trigger = dto.trigger as Rate['trigger'];
|
const trigger = dto.trigger as Rate['trigger'];
|
||||||
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
// Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so
|
||||||
// the engine never accidentally narrows a surcharge by container/direction.
|
// the engine never accidentally narrows a surcharge by container/direction.
|
||||||
// Exceptions: customs clearance and empty-container return keep direction +
|
// Exceptions: the directed surcharges (customs clearance, cancellation,
|
||||||
// container type — both are sold per lane (and per container type).
|
// empty-container return, lashing, fuel) keep direction + cargo scope.
|
||||||
const isSurcharge = trigger !== 'ALWAYS';
|
const isSurcharge = trigger !== 'ALWAYS';
|
||||||
const cargoKind =
|
const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger)
|
||||||
trigger === 'CUSTOMS_CLEARANCE'
|
|
||||||
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null)
|
||||||
: null;
|
: null;
|
||||||
const containerTypeId =
|
const containerTypeId =
|
||||||
trigger === 'WITH_RETURN' ||
|
trigger === 'WITH_RETURN' ||
|
||||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER')
|
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER')
|
||||||
? (dto.containerTypeId ?? null)
|
? (dto.containerTypeId ?? null)
|
||||||
: isSurcharge
|
: isSurcharge
|
||||||
? null
|
? null
|
||||||
: (dto.containerTypeId ?? null);
|
: (dto.containerTypeId ?? null);
|
||||||
const cargoTypeId =
|
const cargoTypeId =
|
||||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||||
trigger === 'LASHING' ||
|
trigger === 'LASHING' ||
|
||||||
trigger === 'FUEL'
|
trigger === 'FUEL'
|
||||||
? (dto.cargoTypeId ?? null)
|
? (dto.cargoTypeId ?? null)
|
||||||
@@ -574,10 +593,7 @@ export class RatesService {
|
|||||||
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
// intercity lane is stored as DOMESTIC, since appliesTo = OTHER says
|
||||||
// nothing about the direction.)
|
// nothing about the direction.)
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||||
trigger === 'WITH_RETURN' ||
|
|
||||||
trigger === 'LASHING' ||
|
|
||||||
trigger === 'FUEL'
|
|
||||||
? (dto.tradeDirection ?? null)
|
? (dto.tradeDirection ?? null)
|
||||||
: isSurcharge || appliesTo === 'INTERCITY'
|
: isSurcharge || appliesTo === 'INTERCITY'
|
||||||
? null
|
? null
|
||||||
@@ -758,8 +774,7 @@ export class RatesService {
|
|||||||
|
|
||||||
// A patch that leaves the cargo kind unsaid keeps the one the rate already
|
// 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).
|
// has — read back off its container scope (container fees carry the type).
|
||||||
const cargoKind =
|
const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger)
|
||||||
trigger !== 'CUSTOMS_CLEARANCE'
|
|
||||||
? null
|
? null
|
||||||
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
: ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ??
|
||||||
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
(existing.containerTypeId ? 'CONTAINER' : 'BULK'));
|
||||||
@@ -767,7 +782,7 @@ export class RatesService {
|
|||||||
const keepsContainerType =
|
const keepsContainerType =
|
||||||
!isSurcharge ||
|
!isSurcharge ||
|
||||||
trigger === 'WITH_RETURN' ||
|
trigger === 'WITH_RETURN' ||
|
||||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER');
|
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER');
|
||||||
const containerTypeId = !keepsContainerType
|
const containerTypeId = !keepsContainerType
|
||||||
? null
|
? null
|
||||||
: dto.containerTypeId !== undefined
|
: dto.containerTypeId !== undefined
|
||||||
@@ -775,7 +790,7 @@ export class RatesService {
|
|||||||
: existing.containerTypeId;
|
: existing.containerTypeId;
|
||||||
const keepsCargoType =
|
const keepsCargoType =
|
||||||
!isSurcharge ||
|
!isSurcharge ||
|
||||||
(trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') ||
|
(CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') ||
|
||||||
trigger === 'LASHING' ||
|
trigger === 'LASHING' ||
|
||||||
trigger === 'FUEL';
|
trigger === 'FUEL';
|
||||||
const cargoTypeId = !keepsCargoType
|
const cargoTypeId = !keepsCargoType
|
||||||
@@ -784,10 +799,7 @@ export class RatesService {
|
|||||||
? dto.cargoTypeId
|
? dto.cargoTypeId
|
||||||
: existing.cargoTypeId;
|
: existing.cargoTypeId;
|
||||||
const tradeDirection =
|
const tradeDirection =
|
||||||
trigger === 'CUSTOMS_CLEARANCE' ||
|
DIRECTED_SURCHARGE_TRIGGERS.includes(trigger)
|
||||||
trigger === 'WITH_RETURN' ||
|
|
||||||
trigger === 'LASHING' ||
|
|
||||||
trigger === 'FUEL'
|
|
||||||
? dto.tradeDirection !== undefined
|
? dto.tradeDirection !== undefined
|
||||||
? dto.tradeDirection
|
? dto.tradeDirection
|
||||||
: existing.tradeDirection
|
: existing.tradeDirection
|
||||||
|
|||||||
@@ -2160,7 +2160,15 @@ export class TrainSchedulingService {
|
|||||||
return { ...detail, warnings, deferredBookings };
|
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);
|
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||||
if (!schedule) {
|
if (!schedule) {
|
||||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||||
@@ -2287,7 +2295,9 @@ export class TrainSchedulingService {
|
|||||||
const removedBooking = await this.dataSource
|
const removedBooking = await this.dataSource
|
||||||
.getRepository(Booking)
|
.getRepository(Booking)
|
||||||
.findOne({ where: { id: bookingId }, relations: { company: true } });
|
.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(
|
this.logger.log(
|
||||||
`Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`,
|
`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,
|
originStation: true,
|
||||||
destinationStation: true,
|
destinationStation: true,
|
||||||
scheduleBookings: { booking: true },
|
scheduleBookings: { booking: true },
|
||||||
|
shippingLineCompany: true,
|
||||||
},
|
},
|
||||||
order: { [sortBy]: sortOrder } as never,
|
order: { [sortBy]: sortOrder } as never,
|
||||||
skip,
|
skip,
|
||||||
@@ -6107,6 +6118,10 @@ export class TrainSchedulingService {
|
|||||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||||
destination:
|
destination:
|
||||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
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.
|
// Built train (Train Builder) behind this departure, when scheduled by train.
|
||||||
train: schedule.trainSet?.train
|
train: schedule.trainSet?.train
|
||||||
? {
|
? {
|
||||||
@@ -8997,8 +9012,14 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const allocRepo = this.dataSource.getRepository(WagonBookingAllocation);
|
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) =>
|
const loadAllocations = (trainSetWagonId: string) =>
|
||||||
allocRepo.find({ where: { trainSetWagonId } });
|
allocRepo.find({
|
||||||
|
where: { trainSetWagonId },
|
||||||
|
relations: { booking: { cargoType: { wagonTypes: true } } },
|
||||||
|
});
|
||||||
const sourceAllocs = await loadAllocations(source.id);
|
const sourceAllocs = await loadAllocations(source.id);
|
||||||
if (!sourceAllocs.length) {
|
if (!sourceAllocs.length) {
|
||||||
throw new BadRequestException('Source wagon has no load to move');
|
throw new BadRequestException('Source wagon has no load to move');
|
||||||
@@ -9043,10 +9064,25 @@ export class TrainSchedulingService {
|
|||||||
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
|
slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`;
|
||||||
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
|
const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) =>
|
||||||
slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon');
|
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 = (
|
const checkReceives = (
|
||||||
allocs: WagonBookingAllocation[],
|
allocs: WagonBookingAllocation[],
|
||||||
label: string,
|
label: string,
|
||||||
wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined,
|
wagonType:
|
||||||
|
| { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean }
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
capacityTons: number,
|
capacityTons: number,
|
||||||
) => {
|
) => {
|
||||||
const incoming = loadTypesOf(allocs);
|
const incoming = loadTypesOf(allocs);
|
||||||
@@ -9057,6 +9093,7 @@ export class TrainSchedulingService {
|
|||||||
const ok =
|
const ok =
|
||||||
supported.includes(loadType) ||
|
supported.includes(loadType) ||
|
||||||
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
(loadType === 'CONTAINER' && wagonType.supportsContainer) ||
|
||||||
|
(loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) ||
|
||||||
supported.length === 0;
|
supported.length === 0;
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({
|
|||||||
next.containerTypeId = "";
|
next.containerTypeId = "";
|
||||||
next.cargoTypeId = "";
|
next.cargoTypeId = "";
|
||||||
}
|
}
|
||||||
// Cargo kind (customs / lashing) decides both the container-type scope
|
// Cargo kind (customs / cancellation) decides both the container-type scope
|
||||||
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
// and the legal units (container → per box/wagon, bulk → per ton/wagon).
|
||||||
if (name === "cargoKind") {
|
if (name === "cargoKind") {
|
||||||
next.containerTypeId = "";
|
next.containerTypeId = "";
|
||||||
|
|||||||
@@ -95,6 +95,8 @@ export const QUERY_KEYS = {
|
|||||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||||
offlineUsd: (filter?: InvoiceListFilter) =>
|
offlineUsd: (filter?: InvoiceListFilter) =>
|
||||||
["invoices", "offline-usd", filter ?? {}] as const,
|
["invoices", "offline-usd", filter ?? {}] as const,
|
||||||
|
summary: (filter?: Omit<InvoiceListFilter, "page" | "pageSize">) =>
|
||||||
|
["invoices", "summary", filter ?? {}] as const,
|
||||||
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export const URL_CONSTANTS = {
|
|||||||
|
|
||||||
BILLING: {
|
BILLING: {
|
||||||
INVOICES: "/billing/invoices",
|
INVOICES: "/billing/invoices",
|
||||||
|
INVOICES_SUMMARY: "/billing/invoices/summary",
|
||||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||||
OFFLINE_USD: "/billing/offline-usd",
|
OFFLINE_USD: "/billing/offline-usd",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Tabs } from "@mantine/core";
|
import { Tabs } from "@mantine/core";
|
||||||
import { Landmark, Receipt, Wallet } from "lucide-react";
|
import { Landmark, Receipt } from "lucide-react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
|
||||||
import { useAuth } from "@/auth/useAuth";
|
import { useAuth } from "@/auth/useAuth";
|
||||||
@@ -8,13 +8,15 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
|||||||
|
|
||||||
import InvoicesPanel from "./InvoicesPage";
|
import InvoicesPanel from "./InvoicesPage";
|
||||||
import UsdPaymentsPanel from "./UsdPaymentsPage";
|
import UsdPaymentsPanel from "./UsdPaymentsPage";
|
||||||
import PaymentsPanel from "../payments/PaymentsPage";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Invoices, Payments, and USD Payments used to be three separate routes/pages
|
* Invoices and USD Payments used to be separate routes/pages with
|
||||||
* with near-identical chrome. They're merged here as URL-linkable tabs
|
* near-identical chrome. They're merged here as URL-linkable tabs (`?tab=`)
|
||||||
* (`?tab=`) on one page — each tab keeps the permission it was individually
|
* on one page — each tab keeps the permission it was individually gated on
|
||||||
* gated on before, and just doesn't render if the user lacks it.
|
* before, and just doesn't render if the user lacks it.
|
||||||
|
*
|
||||||
|
* The Payments tab was removed; its summary (total collected, ETB/USD) now
|
||||||
|
* lives as a card at the top of the Invoices tab instead.
|
||||||
*/
|
*/
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{
|
{
|
||||||
@@ -26,14 +28,6 @@ const TABS = [
|
|||||||
"Every invoice issued across bookings, warehouse fees and clearance charges.",
|
"Every invoice issued across bookings, warehouse fees and clearance charges.",
|
||||||
Panel: InvoicesPanel,
|
Panel: InvoicesPanel,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: "payments",
|
|
||||||
label: "Payments",
|
|
||||||
icon: Wallet,
|
|
||||||
permission: FREIGHT_PERMS.payments.view,
|
|
||||||
subtitle: "View and reconcile booking payment transactions.",
|
|
||||||
Panel: PaymentsPanel,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: "usd-payments",
|
key: "usd-payments",
|
||||||
label: "USD Payments",
|
label: "USD Payments",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Group,
|
Group,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
|
SimpleGrid,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
humanize,
|
humanize,
|
||||||
} from "@/components/customers";
|
} from "@/components/customers";
|
||||||
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions";
|
||||||
|
import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings";
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { Invoice } from "@/types/invoice";
|
import type { Invoice } from "@/types/invoice";
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +81,21 @@ export default function InvoicesPanel() {
|
|||||||
[pendingActions],
|
[pendingActions],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Summary card: total collected (paidAmount) across every invoice matching
|
||||||
|
// the current search/status filters, not just the visible page.
|
||||||
|
const { data: summary } = useQuery(
|
||||||
|
api.invoices.collectedSummary.queryOptions({
|
||||||
|
input: {
|
||||||
|
filter: { search: debouncedQuery, status: statusFilter || undefined },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { data: exchangeSettings } = useExchangeSettingsQuery();
|
||||||
|
const etbCollected = summary?.ETB ?? 0;
|
||||||
|
const usdCollected = summary?.USD ?? 0;
|
||||||
|
const rate = exchangeSettings?.feed?.rate ?? exchangeSettings?.fallbackRate;
|
||||||
|
const etbFromUsd = rate ? usdCollected * rate : null;
|
||||||
|
|
||||||
const columns: ColumnDef<Invoice>[] = useMemo(
|
const columns: ColumnDef<Invoice>[] = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
@@ -170,6 +187,41 @@ export default function InvoicesPanel() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<SimpleGrid cols={{ base: 1, sm: 3 }}>
|
||||||
|
<Card withBorder radius="md" padding="md">
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||||
|
Total collected
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" fw={700} c="edr-text">
|
||||||
|
{etbFromUsd !== null
|
||||||
|
? formatMoney(etbCollected + etbFromUsd, "ETB")
|
||||||
|
: formatMoney(etbCollected, "ETB")}
|
||||||
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{etbFromUsd !== null
|
||||||
|
? `Includes ${formatMoney(usdCollected, "USD")} converted @ ${rate} ETB/USD`
|
||||||
|
: "USD rate unavailable — ETB collected only"}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder radius="md" padding="md">
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||||
|
Collected — ETB only
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" fw={700} c="edr-text">
|
||||||
|
{formatMoney(etbCollected, "ETB")}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
<Card withBorder radius="md" padding="md">
|
||||||
|
<Text size="xs" c="dimmed" fw={600} tt="uppercase">
|
||||||
|
Collected — USD only
|
||||||
|
</Text>
|
||||||
|
<Text size="xl" fw={700} c="edr-text">
|
||||||
|
{formatMoney(usdCollected, "USD")}
|
||||||
|
</Text>
|
||||||
|
</Card>
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
<Card p={0}>
|
<Card p={0}>
|
||||||
<Stack gap={0}>
|
<Stack gap={0}>
|
||||||
<Box px="md" pt="md" pb="sm" w="100%">
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
@@ -265,5 +317,6 @@ export default function InvoicesPanel() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ const RATE_TRIGGERS = [
|
|||||||
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
{ label: "Shipping line mapped", value: "SHIPPING_LINE" },
|
||||||
{ label: "Penalty", value: "CONSOLIDATION" },
|
{ label: "Penalty", value: "CONSOLIDATION" },
|
||||||
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" },
|
{ label: "Lashing (bulk, per cargo type)", value: "LASHING" },
|
||||||
{ label: "Cancellation", value: "CANCELLATION" },
|
{ label: "Cancellation (per wagon, per direction + cargo type)", value: "CANCELLATION" },
|
||||||
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
{ label: "Shipping line extra fee (PIL)", value: "PIL_EXTRA_FEE" },
|
||||||
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
{ label: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" },
|
||||||
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
{ label: "Fuel (per lane + cargo type)", value: "FUEL" },
|
||||||
@@ -265,6 +265,13 @@ const isRouteScopedRate = (values: Record<string, unknown>) =>
|
|||||||
(String(values.appliesTo ?? "") === "OTHER" &&
|
(String(values.appliesTo ?? "") === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
["CUSTOMS_CLEARANCE", "WITH_RETURN", "FUEL"].includes(String(values.trigger ?? "")));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surcharges sold per cargo kind: the admin says container or bulk, then names
|
||||||
|
* the container type or bulk commodity the fee covers.
|
||||||
|
*/
|
||||||
|
const isCargoKindTrigger = (values: Record<string, unknown>) =>
|
||||||
|
["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? ""));
|
||||||
|
|
||||||
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
const unitOption = (value: string) => ({ label: value.replace(/_/g, " "), value });
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -304,7 +311,8 @@ const unitsForShape = (
|
|||||||
// Container-only service — per returned container, per wagon, or flat.
|
// Container-only service — per returned container, per wagon, or flat.
|
||||||
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
return ["PER_CONTAINER", "PER_WAGON", "FLAT"];
|
||||||
case "CANCELLATION":
|
case "CANCELLATION":
|
||||||
return ["FLAT", "PER_INVOICE"];
|
// Wagon cancellation fee — scales with the cancelled wagons only.
|
||||||
|
return ["PER_WAGON"];
|
||||||
case "CUSTOMS_CLEARANCE":
|
case "CUSTOMS_CLEARANCE":
|
||||||
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
// Per cargo kind: container fees per box/wagon, bulk per ton/wagon.
|
||||||
return cargoKind === "BULK"
|
return cargoKind === "BULK"
|
||||||
@@ -1054,9 +1062,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE",
|
||||||
},
|
},
|
||||||
// ── Trade direction — Bulk & Container base freight, plus the route-
|
// ── Trade direction — Bulk & Container base freight, plus the directed
|
||||||
// scoped surcharges (customs clearance; empty-container return, which is
|
// surcharges (customs clearance, cancellation, lashing, fuel; empty-
|
||||||
// import-only for now so export is not offered) ────────────────────────
|
// container return, which is import-only for now so export is not
|
||||||
|
// offered) ─────────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
name: "tradeDirection",
|
name: "tradeDirection",
|
||||||
label: "Trade direction",
|
label: "Trade direction",
|
||||||
@@ -1074,9 +1083,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
!isShippingLineRate(v) &&
|
!isShippingLineRate(v) &&
|
||||||
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
(["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) ||
|
||||||
(String(v.appliesTo ?? "") === "OTHER" &&
|
(String(v.appliesTo ?? "") === "OTHER" &&
|
||||||
["CUSTOMS_CLEARANCE", "WITH_RETURN", "LASHING", "FUEL"].includes(
|
[
|
||||||
String(v.trigger ?? ""),
|
"CUSTOMS_CLEARANCE",
|
||||||
))),
|
"CANCELLATION",
|
||||||
|
"WITH_RETURN",
|
||||||
|
"LASHING",
|
||||||
|
"FUEL",
|
||||||
|
].includes(String(v.trigger ?? "")))),
|
||||||
},
|
},
|
||||||
// Shipping lines only ever ship import — the export leg is sold through
|
// Shipping lines only ever ship import — the export leg is sold through
|
||||||
// the customer's contract — so the direction is stated, not asked. Shown
|
// the customer's contract — so the direction is stated, not asked. Shown
|
||||||
@@ -1097,8 +1110,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
computeValue: () => "IMPORT",
|
computeValue: () => "IMPORT",
|
||||||
showIf: hasShippingLine,
|
showIf: hasShippingLine,
|
||||||
},
|
},
|
||||||
// ── Cargo kind — customs clearance is priced separately for containers
|
// ── Cargo kind — customs clearance and the cancellation fee are priced
|
||||||
// (one rate per container type) and bulk ───────────────────────────────
|
// separately for containers (one rate per container type) and bulk (one
|
||||||
|
// rate per commodity) ──────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
name: "cargoKind",
|
name: "cargoKind",
|
||||||
label: "Cargo kind",
|
label: "Cargo kind",
|
||||||
@@ -1107,11 +1121,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
options: INTERCITY_KINDS,
|
options: INTERCITY_KINDS,
|
||||||
placeholder: "Is this fee for containers or bulk?",
|
placeholder: "Is this fee for containers or bulk?",
|
||||||
description:
|
description:
|
||||||
"Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.",
|
"Container fees are set per container type; bulk fees per commodity. Customs: container per box or wagon, bulk per ton or wagon. Cancellation: per wagon.",
|
||||||
showIf: (v) =>
|
showIf: (v) => v.appliesTo === "OTHER" && isCargoKindTrigger(v),
|
||||||
v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE",
|
|
||||||
// Not a stored column: a container fee carries its containerTypeId, a
|
// Not a stored column: a container fee carries its containerTypeId, a
|
||||||
// bulk fee carries none.
|
// bulk fee its cargoTypeId.
|
||||||
getInitialValue: (record) =>
|
getInitialValue: (record) =>
|
||||||
record.containerTypeId ? "CONTAINER" : "BULK",
|
record.containerTypeId ? "CONTAINER" : "BULK",
|
||||||
},
|
},
|
||||||
@@ -1124,10 +1137,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "Which container type this fee covers",
|
placeholder: "Which container type this fee covers",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "OTHER" &&
|
v.appliesTo === "OTHER" &&
|
||||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
isCargoKindTrigger(v) &&
|
||||||
v.cargoKind === "CONTAINER",
|
v.cargoKind === "CONTAINER",
|
||||||
},
|
},
|
||||||
// ── Bulk cargo type — the bulk customs fee names its commodity ────────
|
// ── Bulk cargo type — the bulk fee names its commodity ────────────────
|
||||||
{
|
{
|
||||||
name: "cargoTypeId",
|
name: "cargoTypeId",
|
||||||
label: "Bulk cargo type",
|
label: "Bulk cargo type",
|
||||||
@@ -1136,7 +1149,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
placeholder: "Which bulk commodity this fee covers",
|
placeholder: "Which bulk commodity this fee covers",
|
||||||
showIf: (v) =>
|
showIf: (v) =>
|
||||||
v.appliesTo === "OTHER" &&
|
v.appliesTo === "OTHER" &&
|
||||||
v.trigger === "CUSTOMS_CLEARANCE" &&
|
isCargoKindTrigger(v) &&
|
||||||
v.cargoKind === "BULK",
|
v.cargoKind === "BULK",
|
||||||
},
|
},
|
||||||
// ── Cargo type — a fuel rate names the commodity it covers (different
|
// ── Cargo type — a fuel rate names the commodity it covers (different
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
{row.original.direction}
|
{row.original.direction}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ShippingLineBadge schedule={row.original} />
|
||||||
</Group>
|
</Group>
|
||||||
<Box maw={220}>
|
<Box maw={220}>
|
||||||
<RouteCorridor
|
<RouteCorridor
|
||||||
@@ -742,7 +743,11 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
onRowClick={(schedule) =>
|
onRowClick={(schedule) =>
|
||||||
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
navigate(`/dashboard/operations/train-scheduling-v2/${schedule.id}`)
|
||||||
}
|
}
|
||||||
rowStyle={(schedule) => directionRowStyle(schedule.direction)}
|
rowStyle={(schedule) =>
|
||||||
|
schedule.shippingLineCompanyId
|
||||||
|
? SHIPPING_LINE_ROW_STYLE
|
||||||
|
: directionRowStyle(schedule.direction)
|
||||||
|
}
|
||||||
error={
|
error={
|
||||||
schedulesQuery.isError
|
schedulesQuery.isError
|
||||||
? {
|
? {
|
||||||
@@ -1052,6 +1057,20 @@ export default function TrainScheduleV2ListPage() {
|
|||||||
* bookings that have not paid yet — that space is claimed, so it is not
|
* bookings that have not paid yet — that space is claimed, so it is not
|
||||||
* bookable.
|
* bookable.
|
||||||
*/
|
*/
|
||||||
|
/** Green tint for departures dedicated to a shipping line (overrides direction tint). */
|
||||||
|
const SHIPPING_LINE_ROW_STYLE = {
|
||||||
|
backgroundColor: "var(--mantine-color-edr-green-0)",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function ShippingLineBadge({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||||
|
if (!schedule.shippingLineCompanyId) return null;
|
||||||
|
return (
|
||||||
|
<Badge size="xs" variant="light" color="edr-green">
|
||||||
|
{schedule.shippingLineCompanyName ?? "Shipping line"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) {
|
||||||
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
// Pre-deploy API rows carry only wagonCount; fall back so the chip still
|
||||||
// renders rather than reading 0 used on every train.
|
// renders rather than reading 0 used on every train.
|
||||||
@@ -1133,6 +1152,7 @@ function ScheduleCard({
|
|||||||
withBorder
|
withBorder
|
||||||
onClick={onOpen}
|
onClick={onOpen}
|
||||||
className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
className="cursor-pointer overflow-hidden transition-[transform,border-color] duration-150 hover:-translate-y-0.5 hover:border-edr-primary!"
|
||||||
|
style={schedule.shippingLineCompanyId ? SHIPPING_LINE_ROW_STYLE : undefined}
|
||||||
>
|
>
|
||||||
<Stack gap="sm" p="md">
|
<Stack gap="sm" p="md">
|
||||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||||
@@ -1182,6 +1202,7 @@ function ScheduleCard({
|
|||||||
{schedule.direction}
|
{schedule.direction}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ShippingLineBadge schedule={schedule} />
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap={6} wrap="nowrap">
|
<Group gap={6} wrap="nowrap">
|
||||||
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
<MetricChip value={schedule.bookingsCount} label="bkg" />
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import type {
|
|||||||
} from "@/types/fileUploadSettings";
|
} from "@/types/fileUploadSettings";
|
||||||
import type {
|
import type {
|
||||||
Invoice,
|
Invoice,
|
||||||
|
InvoiceCollectedSummary,
|
||||||
InvoiceListFilter,
|
InvoiceListFilter,
|
||||||
PaginatedInvoices,
|
PaginatedInvoices,
|
||||||
PaginatedOfflineUsdInvoices,
|
PaginatedOfflineUsdInvoices,
|
||||||
@@ -3137,6 +3138,16 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
collectedSummary: endpoint<
|
||||||
|
{ filter: Omit<InvoiceListFilter, "page" | "pageSize"> },
|
||||||
|
InvoiceCollectedSummary
|
||||||
|
>(
|
||||||
|
"invoices",
|
||||||
|
"collectedSummary",
|
||||||
|
({ filter }) => invoicesService.collectedSummary(filter),
|
||||||
|
({ filter }) => QUERY_KEYS.INVOICES.summary(filter),
|
||||||
|
),
|
||||||
|
|
||||||
listOfflineUsd: endpoint<
|
listOfflineUsd: endpoint<
|
||||||
{ filter: InvoiceListFilter },
|
{ filter: InvoiceListFilter },
|
||||||
PaginatedOfflineUsdInvoices
|
PaginatedOfflineUsdInvoices
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http";
|
|||||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
import type {
|
import type {
|
||||||
Invoice,
|
Invoice,
|
||||||
|
InvoiceCollectedSummary,
|
||||||
InvoiceListFilter,
|
InvoiceListFilter,
|
||||||
PaginatedInvoices,
|
PaginatedInvoices,
|
||||||
PaginatedOfflineUsdInvoices,
|
PaginatedOfflineUsdInvoices,
|
||||||
@@ -23,6 +24,17 @@ export const invoicesService = {
|
|||||||
.then((r) => r.data);
|
.then((r) => r.data);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Total collected (paidAmount) across every filtered invoice, by currency. */
|
||||||
|
collectedSummary(
|
||||||
|
filter: Omit<InvoiceListFilter, "page" | "pageSize">,
|
||||||
|
): Promise<InvoiceCollectedSummary> {
|
||||||
|
return apiClient
|
||||||
|
.get<InvoiceCollectedSummary>(URL_CONSTANTS.BILLING.INVOICES_SUMMARY, {
|
||||||
|
params: cleanParams(filter),
|
||||||
|
})
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
getById(id: string): Promise<Invoice> {
|
getById(id: string): Promise<Invoice> {
|
||||||
return apiClient
|
return apiClient
|
||||||
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
|
.get<Invoice>(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id))
|
||||||
|
|||||||
@@ -39,3 +39,6 @@ export interface PaginatedOfflineUsdInvoices {
|
|||||||
items: OfflineUsdInvoice[];
|
items: OfflineUsdInvoice[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */
|
||||||
|
export type InvoiceCollectedSummary = Record<string, number>;
|
||||||
|
|||||||
@@ -196,6 +196,9 @@ export interface TrainScheduleListItem {
|
|||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
freightType?: FreightType | null;
|
freightType?: FreightType | null;
|
||||||
|
/** Set when the departure is dedicated to one shipping line (hidden from customers). */
|
||||||
|
shippingLineCompanyId?: string | null;
|
||||||
|
shippingLineCompanyName?: string | null;
|
||||||
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
/** Built train (Train Builder) behind this departure, when scheduled by train. */
|
||||||
train?: {
|
train?: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ export function ReadonlyBookingView({
|
|||||||
// the customer. Hide the customer's rebook everywhere and tell them GL will
|
// the customer. Hide the customer's rebook everywhere and tell them GL will
|
||||||
// handle it. Non-customs bookings stay self-service.
|
// handle it. Non-customs bookings stay self-service.
|
||||||
const isCustoms = Boolean(booking.customsClearingEnabled);
|
const isCustoms = Boolean(booking.customsClearingEnabled);
|
||||||
const canSelfRebook = !isCustoms;
|
// A PAID booking cancelled through wagon cancellation rebooks via its credit
|
||||||
|
// (WagonCancellationCard), not the fresh-booking rebook link.
|
||||||
|
const canSelfRebook = !isCustoms && booking.paymentStatus !== "PAID";
|
||||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||||
const isClearance = [
|
const isClearance = [
|
||||||
"AWAITING_DOCUMENTS",
|
"AWAITING_DOCUMENTS",
|
||||||
@@ -219,6 +221,8 @@ export function ReadonlyBookingView({
|
|||||||
subtitle={
|
subtitle={
|
||||||
status === "REJECTED"
|
status === "REJECTED"
|
||||||
? "This booking request has been rejected."
|
? "This booking request has been rejected."
|
||||||
|
: booking.paymentStatus === "PAID"
|
||||||
|
? "All wagons were cancelled. Your paid freight is held as a credit — rebook it from the Wagon Cancellation card below."
|
||||||
: "This booking process has been terminated."
|
: "This booking process has been terminated."
|
||||||
}
|
}
|
||||||
reason={booking.latestChangeRequestNote}
|
reason={booking.latestChangeRequestNote}
|
||||||
@@ -348,6 +352,7 @@ export function ReadonlyBookingView({
|
|||||||
<Tabs.Panel value="wagons">
|
<Tabs.Panel value="wagons">
|
||||||
<WagonsTab
|
<WagonsTab
|
||||||
bookingId={booking.id}
|
bookingId={booking.id}
|
||||||
|
currency={booking.paymentCurrency}
|
||||||
cancellable={
|
cancellable={
|
||||||
booking.status === "PAID" &&
|
booking.status === "PAID" &&
|
||||||
booking.paymentStatus === "PAID" &&
|
booking.paymentStatus === "PAID" &&
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ const apiErrorMessage = (error: unknown, fallback: string) => {
|
|||||||
const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
const th = { color: "#9AA8B5", fontSize: 11 } as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Partial wagon cancellation on a PAID contract booking: request a cut (fee
|
* Wagon cancellation (partial or whole) on a PAID contract booking: request a cut (fee
|
||||||
* previewed first), pay the cancellation fee, then rebook the freed credit
|
* previewed first), pay the cancellation fee, then rebook the freed credit
|
||||||
* onto another shipment day — plus the booking's cancellation history.
|
* onto another shipment day — plus the booking's cancellation history.
|
||||||
* Wagons leave the schedule at request time; the fee settles the credit.
|
* Wagons leave the schedule at request time; the fee settles the credit.
|
||||||
@@ -90,10 +90,13 @@ export function WagonCancellationCard({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const status = booking.status as string;
|
const status = booking.status as string;
|
||||||
const eligible =
|
const paidContract =
|
||||||
status === "PAID" &&
|
(booking.paymentStatus as string) === "PAID" && !!booking.contractId;
|
||||||
(booking.paymentStatus as string) === "PAID" &&
|
// New cuts only on a live PAID booking; a booking fully cancelled through
|
||||||
!!booking.contractId;
|
// this flow (status CANCELLED) still shows the card so its credit can be
|
||||||
|
// paid for / rebooked.
|
||||||
|
const canRequest = status === "PAID" && paidContract;
|
||||||
|
const eligible = paidContract && (status === "PAID" || status === "CANCELLED");
|
||||||
|
|
||||||
const isBulk = booking.freightType === "BULK";
|
const isBulk = booking.freightType === "BULK";
|
||||||
const detail = booking as BookingDetail;
|
const detail = booking as BookingDetail;
|
||||||
@@ -226,12 +229,13 @@ export function WagonCancellationCard({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!eligible) return null;
|
if (!eligible) return null;
|
||||||
|
if (!canRequest && !ownRows.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard>
|
<SectionCard>
|
||||||
<Group justify="space-between" align="center" mb="sm">
|
<Group justify="space-between" align="center" mb="sm">
|
||||||
<CardTitle>Wagon Cancellation</CardTitle>
|
<CardTitle>Wagon Cancellation</CardTitle>
|
||||||
{!openRow && !creditRow && (
|
{canRequest && !openRow && !creditRow && (
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -305,9 +309,9 @@ export function WagonCancellationCard({
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text fz={13} c="#475569">
|
<Text fz={13} c="#475569">
|
||||||
Need fewer wagons than you paid for? Cancel part of this booking for
|
Need fewer wagons than you paid for — or none? Cancel part or all of
|
||||||
a per-wagon fee — the freed freight amount becomes a credit you can
|
this booking for a per-wagon fee — the freed freight amount becomes a
|
||||||
rebook onto another shipment day.
|
credit you can rebook onto another shipment day.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -401,16 +405,16 @@ export function WagonCancellationCard({
|
|||||||
{booking.reference}
|
{booking.reference}
|
||||||
</Text>{" "}
|
</Text>{" "}
|
||||||
to cancel. A per-wagon fee applies; once it's paid the wagons
|
to cancel. A per-wagon fee applies; once it's paid the wagons
|
||||||
are released and the freed amount becomes a rebooking credit. At
|
are released and the freed amount becomes a rebooking credit.
|
||||||
least one wagon must remain — to cancel everything, cancel the
|
Cancelling every wagon cancels the whole booking — the full freight
|
||||||
whole booking instead.
|
amount becomes your credit.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{isBulk ? (
|
{isBulk ? (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Wagons to cancel"
|
label="Wagons to cancel"
|
||||||
min={1}
|
min={1}
|
||||||
max={wagonsRequired > 1 ? wagonsRequired - 1 : undefined}
|
max={wagonsRequired > 0 ? wagonsRequired : undefined}
|
||||||
allowDecimal={false}
|
allowDecimal={false}
|
||||||
value={wagons}
|
value={wagons}
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
|
|||||||
@@ -462,10 +462,13 @@ function WagonCard({
|
|||||||
*/
|
*/
|
||||||
export function WagonsTab({
|
export function WagonsTab({
|
||||||
bookingId,
|
bookingId,
|
||||||
|
currency,
|
||||||
cancellable,
|
cancellable,
|
||||||
onCancellationRequested,
|
onCancellationRequested,
|
||||||
}: {
|
}: {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
/** Booking payment currency — labels the rebooking credit. */
|
||||||
|
currency?: string;
|
||||||
/** PAID contract booking — specific wagons may be selected for cancellation. */
|
/** PAID contract booking — specific wagons may be selected for cancellation. */
|
||||||
cancellable?: boolean;
|
cancellable?: boolean;
|
||||||
onCancellationRequested?: () => void;
|
onCancellationRequested?: () => void;
|
||||||
@@ -684,7 +687,7 @@ export function WagonsTab({
|
|||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
color="orange"
|
color="orange"
|
||||||
disabled={selected.size === 0 || selected.size >= wagons.length}
|
disabled={selected.size === 0}
|
||||||
onClick={openConfirm}
|
onClick={openConfirm}
|
||||||
>
|
>
|
||||||
Cancel selected ({selected.size})
|
Cancel selected ({selected.size})
|
||||||
@@ -692,8 +695,8 @@ export function WagonsTab({
|
|||||||
</Group>
|
</Group>
|
||||||
{selected.size >= wagons.length && selected.size > 0 && (
|
{selected.size >= wagons.length && selected.size > 0 && (
|
||||||
<Text fz={12} c="#B3362C" mt={6}>
|
<Text fz={12} c="#B3362C" mt={6}>
|
||||||
You cannot cancel every wagon here — to cancel the whole booking,
|
Every wagon is selected — this cancels the whole booking once the
|
||||||
use the booking cancellation instead.
|
fee is paid; the full freight amount becomes your rebooking credit.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
@@ -759,7 +762,7 @@ export function WagonsTab({
|
|||||||
Rebooking credit kept
|
Rebooking credit kept
|
||||||
</Text>
|
</Text>
|
||||||
<Text fz={14} fw={800} c="#0A6F4D">
|
<Text fz={14} fw={800} c="#0A6F4D">
|
||||||
{Number(preview.creditAmount).toLocaleString()}
|
{Number(preview.creditAmount).toLocaleString()} {currency ?? ""}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import {
|
|||||||
bookingIsSignable,
|
bookingIsSignable,
|
||||||
} from "./contract/ContractSignButton";
|
} from "./contract/ContractSignButton";
|
||||||
import { ApproveDeliveryButton } from "./delivery/ApproveDeliveryButton";
|
import { ApproveDeliveryButton } from "./delivery/ApproveDeliveryButton";
|
||||||
|
import { RebookWagonsButton } from "./RebookWagonsButton";
|
||||||
import {
|
import {
|
||||||
BookingStatusBadge as StatusBadge,
|
BookingStatusBadge as StatusBadge,
|
||||||
BookingTypeBadge,
|
BookingTypeBadge,
|
||||||
@@ -52,7 +53,10 @@ import {
|
|||||||
} from "./booking-display";
|
} from "./booking-display";
|
||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import type { BookingListFilter } from "@/services/bookings.service";
|
import type {
|
||||||
|
BookingListFilter,
|
||||||
|
WagonCancellation,
|
||||||
|
} from "@/services/bookings.service";
|
||||||
import type { Freight } from "@edr/types";
|
import type { Freight } from "@edr/types";
|
||||||
import {
|
import {
|
||||||
DataTable,
|
DataTable,
|
||||||
@@ -176,13 +180,25 @@ const STAT_CARDS: Array<{
|
|||||||
|
|
||||||
function PrimaryAction({
|
function PrimaryAction({
|
||||||
booking,
|
booking,
|
||||||
|
credit,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
}: {
|
}: {
|
||||||
booking: Freight.IBooking;
|
booking: Freight.IBooking;
|
||||||
|
/** CREDIT_AVAILABLE wagon cancellation opened by this booking, if any. */
|
||||||
|
credit?: WagonCancellation;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { status, id } = booking;
|
const { status, id } = booking;
|
||||||
const go = () => onNavigate(`/bookings/${id}`);
|
const go = () => onNavigate(`/bookings/${id}`);
|
||||||
|
// Cancelled wagons with a paid credit (partial or whole cancel) → rebook.
|
||||||
|
if (credit) {
|
||||||
|
return (
|
||||||
|
<RebookWagonsButton
|
||||||
|
cancellation={credit}
|
||||||
|
currency={booking.paymentCurrency}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
|
// A general contract is payable as soon as it's FULLY_EXECUTED (signed); a
|
||||||
// one-time booking only after it's SELECTED_FOR_BATCH.
|
// one-time booking only after it's SELECTED_FOR_BATCH.
|
||||||
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
const isGeneralContract = booking.bookingType === "GENERAL_CONTRACT";
|
||||||
@@ -423,6 +439,22 @@ export default function BookingsListPage() {
|
|||||||
api.bookings.list.queryOptions({ input: filter }),
|
api.bookings.list.queryOptions({ input: filter }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Rebookable credits (fee-paid wagon cancellations) keyed by source booking,
|
||||||
|
// so the row action can offer "Rebook wagons" in place of "View".
|
||||||
|
// ponytail: one page of 100 newest rows; paginate if a customer ever holds more.
|
||||||
|
const { data: myCancellations } = useQuery(
|
||||||
|
api.bookings.listMyWagonCancellations.queryOptions({
|
||||||
|
input: { pageSize: 100 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const creditByBooking = useMemo(() => {
|
||||||
|
const m = new Map<string, WagonCancellation>();
|
||||||
|
for (const r of myCancellations?.items ?? []) {
|
||||||
|
if (r.status === "CREDIT_AVAILABLE" && !m.has(r.bookingId)) m.set(r.bookingId, r);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [myCancellations]);
|
||||||
|
|
||||||
// Per-card lifecycle counts (one cheap query each, total-only).
|
// Per-card lifecycle counts (one cheap query each, total-only).
|
||||||
const allCount = useStatusCount(undefined);
|
const allCount = useStatusCount(undefined);
|
||||||
const activeCount = useStatusCount(
|
const activeCount = useStatusCount(
|
||||||
@@ -654,7 +686,11 @@ export default function BookingsListPage() {
|
|||||||
Track
|
Track
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
<PrimaryAction booking={booking} onNavigate={navigate} />
|
<PrimaryAction
|
||||||
|
booking={booking}
|
||||||
|
credit={creditByBooking.get(booking.id)}
|
||||||
|
onNavigate={navigate}
|
||||||
|
/>
|
||||||
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
<Menu position="bottom-end" withinPortal shadow="md" radius="md">
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import { bookingsService, type WagonCancellation } from "@/services/bookings.service";
|
||||||
|
import { OperationDatePicker } from "./clearance";
|
||||||
|
import { formatAmount } from "./BookingDetailPage/utils";
|
||||||
|
|
||||||
|
const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||||
|
const data = (error as { response?: { data?: { message?: string | string[] } } })
|
||||||
|
?.response?.data;
|
||||||
|
if (Array.isArray(data?.message)) return data.message.join(", ");
|
||||||
|
if (data?.message) return data.message;
|
||||||
|
return fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List-row action for a CREDIT_AVAILABLE wagon cancellation: pick a shipment
|
||||||
|
* day, rebook the credit as a new PAID booking, jump to it.
|
||||||
|
*/
|
||||||
|
export function RebookWagonsButton({
|
||||||
|
cancellation,
|
||||||
|
currency,
|
||||||
|
size = "xs",
|
||||||
|
}: {
|
||||||
|
cancellation: WagonCancellation;
|
||||||
|
currency?: string;
|
||||||
|
size?: "xs" | "sm";
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [date, setDate] = useState("");
|
||||||
|
|
||||||
|
const rebook = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
bookingsService.rebookWagonCancellation(cancellation.id, { scheduledDate: date }),
|
||||||
|
onSuccess: ({ bookingId }) => {
|
||||||
|
qc.invalidateQueries({ queryKey: api.bookings.list.queryKey() });
|
||||||
|
qc.invalidateQueries({ queryKey: api.bookings.listMyWagonCancellations.queryKey() });
|
||||||
|
toast.success("Wagons rebooked — taking you to the new booking.", { duration: 6000 });
|
||||||
|
setOpen(false);
|
||||||
|
navigate(`/bookings/${bookingId}`);
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast.error(apiErrorMessage(e, "Could not rebook the wagons. Please try again.")),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
radius="md"
|
||||||
|
color="edr-green"
|
||||||
|
fw={700}
|
||||||
|
fz={13}
|
||||||
|
leftSection={<RotateCcw size={14} />}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
Rebook wagons
|
||||||
|
</Button>
|
||||||
|
<Modal
|
||||||
|
opened={open}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
title={
|
||||||
|
<Text fw={800} fz={18} c="#10202F">
|
||||||
|
Rebook cancelled wagons
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
centered
|
||||||
|
radius={16}
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert color="teal" radius="md">
|
||||||
|
{Number(cancellation.wagonsCancelled)} wagon(s) — credit of{" "}
|
||||||
|
<Text span fw={700}>
|
||||||
|
{formatAmount(cancellation.creditAmount)} {currency ?? ""}
|
||||||
|
</Text>
|
||||||
|
. Pick a shipment day; the new booking is created already paid.
|
||||||
|
</Alert>
|
||||||
|
<OperationDatePicker
|
||||||
|
bookingId={cancellation.bookingId}
|
||||||
|
value={date}
|
||||||
|
onChange={setDate}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button variant="default" radius="md" onClick={() => setOpen(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
radius="md"
|
||||||
|
disabled={!date}
|
||||||
|
loading={rebook.isPending}
|
||||||
|
onClick={() => rebook.mutate()}
|
||||||
|
>
|
||||||
|
Rebook wagons
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user