Merge pull request #1297 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-15 13:17:37 +03:00
committed by GitHub
49 changed files with 2370 additions and 380 deletions

View 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();

View File

@@ -66,6 +66,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) {

View File

@@ -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
@@ -206,6 +206,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;
@@ -229,31 +263,80 @@ 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: await this.attachShippingLineCompanies(items), total };
}
/**
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
* entity's doc comment — so this is a second query keyed off the ids
* already loaded, same shape as `company`.
*/
private async attachShippingLineCompanies<T extends Invoice>(
invoices: T[],
): Promise<T[]> {
const ids = [
...new Set(
invoices
.map((i) => i.shippingLineCompanyId)
.filter((id): id is string => id != null),
),
];
if (!ids.length) return invoices;
const lines = await this.dataSource
.getRepository(ShippingLineCompany)
.find({ where: { id: In(ids) } });
const byId = new Map(lines.map((l) => [l.id, l]));
return invoices.map((invoice) => {
const line = invoice.shippingLineCompanyId
? byId.get(invoice.shippingLineCompanyId)
: undefined;
return line
? ({
...invoice,
shippingLineCompany: {
id: line.id,
name: line.name,
email: line.email,
phoneNumber: line.phoneNumber,
},
} as T)
: invoice;
});
}
/**
* 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]),
);
} }
/** /**
@@ -402,11 +485,12 @@ export class BillingService {
relations: { company: true, companyProfile: true }, relations: { company: true, companyProfile: true },
}); });
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
const lines = await this.invoiceLines.findAll({ const lines = await this.invoiceLines.findAll({
where: { invoiceId: id }, where: { invoiceId: id },
order: { createdAt: "ASC" }, order: { createdAt: "ASC" },
}); });
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
} }
// ── Documents (central PDF) ────────────────────────────────────────────────── // ── Documents (central PDF) ──────────────────────────────────────────────────

View File

@@ -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);
});
});

View File

@@ -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 bookinguse 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 bookinguse 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 bookinguse 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) {

View File

@@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsService, BookingsService,
BookingsRepository, BookingsRepository,
BookingPricingService, BookingPricingService,
ContainerValidationService,
BookingInvoiceService, BookingInvoiceService,
BookingLifecycleNotifierService, BookingLifecycleNotifierService,
BookingTransitionService, BookingTransitionService,

View File

@@ -67,9 +67,16 @@ export class ContainerValidationService {
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
if (!has20ft) return []; if (!has20ft) return [];
const units = await this.load20ftUnits(booking); return this.validate20ftPairingUnits(await this.load20ftUnits(booking));
if (units.length < 2) return []; }
/**
* Same rule over units that are not (yet) persisted — a completion payload
* being previewed or submitted. Shipping-line completion uses this: its
* cargo only hits the DB after the check passes.
*/
async validate20ftPairingUnits(units: Container20ftUnit[]): Promise<PairingViolation[]> {
if (units.length < 2) return [];
const maxDiff = await this.maxPairDiffTons(); const maxDiff = await this.maxPairDiffTons();
return validate20ftWeightPairing(units, maxDiff); return validate20ftWeightPairing(units, maxDiff);
} }

View File

@@ -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])

View File

@@ -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);

View File

@@ -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.

View File

@@ -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,16 +774,15 @@ 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'));
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

View File

@@ -10,6 +10,8 @@ import { In, Repository } from "typeorm";
import { BookingPricingService } from "../bookings/booking-pricing.service"; import { BookingPricingService } from "../bookings/booking-pricing.service";
import { BookingTransitionService } from "../bookings/booking-transition.service"; import { BookingTransitionService } from "../bookings/booking-transition.service";
import { BookingsService } from "../bookings/bookings.service"; import { BookingsService } from "../bookings/bookings.service";
import type { Container20ftUnit } from "../bookings/container-pairing.util";
import { ContainerValidationService } from "../bookings/container-validation.service";
import { BookingContainer } from "../bookings/entities/booking-container.entity"; import { BookingContainer } from "../bookings/entities/booking-container.entity";
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity"; import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
import { Booking } from "../bookings/entities/booking.entity"; import { Booking } from "../bookings/entities/booking.entity";
@@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService {
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
private readonly creditsService: ShippingLineCreditsService, private readonly creditsService: ShippingLineCreditsService,
private readonly containerValidationService: ContainerValidationService,
) {} ) {}
/**
* 20ft weight-pairing check over the completion payload — the same rule the
* customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t):
* two 20ft sharing a wagon must be within the cap. Preview surfaces the
* messages; completion hard-blocks on them. Runs off the DTO so nothing is
* persisted before the check passes.
*/
private async pairingViolationMessages(
dto: CompleteShippingLineBookingDto,
): Promise<string[]> {
const units: Container20ftUnit[] = [];
for (const line of dto.containers ?? []) {
const containerType = await this.resolveContainerType(line);
if (containerType.sizeFt !== 20) continue;
(line.units ?? []).forEach((u, idx) =>
units.push({
label: u.containerNumber || `20ft-${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
}),
);
}
const violations =
await this.containerValidationService.validate20ftPairingUnits(units);
return violations.map((v) => v.message);
}
/** Same session→owner resolution every shipping-line entry point uses. */ /** Same session→owner resolution every shipping-line entry point uses. */
private async requireShippingLine(userId: string) { private async requireShippingLine(userId: string) {
const shippingLine = const shippingLine =
@@ -193,6 +222,17 @@ export class ShippingLineBookingCompletionService {
); );
} }
// Unbalanced 20ft pairs can never be planned onto wagons — refuse before
// any cargo/credit write below. Same block the contract path applies.
if (booking.freightType === "CONTAINER") {
const pairing = await this.pairingViolationMessages(dto);
if (pairing.length) {
throw new BadRequestException(
`Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`,
);
}
}
// Completion is booking time. A lane with trains DEDICATED to this line // Completion is booking time. A lane with trains DEDICATED to this line
// has no window concept at all: the line books whenever it wants until the // has no window concept at all: the line books whenever it wants until the
// train's close offset. Only a lane with no dedicated train falls back to // train's close offset. Only a lane with no dedicated train falls back to
@@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService {
computed.appliedModifiers, computed.appliedModifiers,
); );
// Pairing is reported, not thrown: the confirm modal shows it next to the
// price (as the customer form does) and disables confirm; /complete
// hard-blocks the same payload.
const pairingErrors =
booking.freightType === "CONTAINER"
? await this.pairingViolationMessages(dto)
: [];
return { return {
totalAmount: computed.totalAmount, totalAmount: computed.totalAmount,
currency: computed.currency, currency: computed.currency,
lineItems: computed.lineItems, lineItems: computed.lineItems,
warnings: computed.warnings, warnings: computed.warnings,
overweightLines: computed.overweightLines,
pairingErrors,
}; };
} }

View File

@@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
import { import {
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res, Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res,
} from "@nestjs/common"; } from "@nestjs/common";
import { CurrentUser } from "@edr/api-common"; import { CurrentUser } from "@edr/api-common";
import { import {
@@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto"; import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto"; import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto"; import {
DispatchScheduleDto,
RecordCheckpointDto,
UpdateCheckpointDto,
} from "../dto/record-checkpoint.dto";
import { import {
ImportDjiboutiActionDto, ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto, UploadImportDjiboutiDocumentDto,
@@ -516,9 +520,14 @@ export class TrainSchedulingController {
@Post("schedules/:id/dispatch") @Post("schedules/:id/dispatch")
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch) @BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
@ApiOperation({ summary: "Dispatch a scheduled train" }) @ApiOperation({
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) { summary: "Dispatch a scheduled train (optional actual departure time, past allowed)",
return this.trainSchedulingService.dispatchSchedule(id); })
dispatchSchedule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: DispatchScheduleDto,
) {
return this.trainSchedulingService.dispatchSchedule(id, dto);
} }
@Get("intercity/bookings") @Get("intercity/bookings")
@@ -956,6 +965,20 @@ export class TrainSchedulingController {
return this.trainSchedulingService.recordCheckpoint(id, dto); return this.trainSchedulingService.recordCheckpoint(id, dto);
} }
@Patch("schedules/:id/checkpoints/:sequenceNo")
@TrainSchedulingUpdate()
@ApiOperation({
summary:
"Edit a logged leg's time/note (no side effects; allowed while dispatched or after arrival)",
})
updateCheckpoint(
@Param("id", ParseUUIDPipe) id: string,
@Param("sequenceNo", ParseIntPipe) sequenceNo: number,
@Body() dto: UpdateCheckpointDto,
) {
return this.trainSchedulingService.updateCheckpoint(id, sequenceNo, dto);
}
@Post("schedules/:id/arrive") @Post("schedules/:id/arrive")
@TrainSchedulingUpdate() @TrainSchedulingUpdate()
@ApiOperation({ @ApiOperation({

View File

@@ -10,8 +10,6 @@ import {
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
export class RecordCheckpointDto { export class RecordCheckpointDto {
@ApiProperty({ description: 'Station position along the route (0 = origin).' }) @ApiProperty({ description: 'Station position along the route (0 = origin).' })
@IsInt() @IsInt()
@@ -24,17 +22,17 @@ export class RecordCheckpointDto {
kind?: TrainCheckpointKind; kind?: TrainCheckpointKind;
/** /**
* A checkpoint records where the train is as staff observe it, and the final * When the train was actually at the station — staff often log after the
* one arrives the schedule — so a backdated value rewrites the journey after * fact, so a past value is allowed. The service rejects the future and any
* the fact. Only "now" is accepted; omit the field and the service stamps it. * value out of order with the neighbouring legs.
*/ */
@ApiProperty({ @ApiProperty({
required: false, required: false,
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.', description:
'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.',
}) })
@IsOptional() @IsOptional()
@IsISO8601() @IsISO8601()
@IsNotBackdated()
occurredAt?: string; occurredAt?: string;
@ApiProperty({ required: false }) @ApiProperty({ required: false })
@@ -43,3 +41,30 @@ export class RecordCheckpointDto {
@MaxLength(500) @MaxLength(500)
note?: string; note?: string;
} }
/** Edit an already-logged leg's time/note — no side effects (no unload, no arrival). */
export class UpdateCheckpointDto {
@ApiProperty({
required: false,
description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.',
})
@IsOptional()
@IsISO8601()
occurredAt?: string;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
@MaxLength(500)
note?: string | null;
}
export class DispatchScheduleDto {
@ApiProperty({
required: false,
description: 'Actual departure time; defaults to now. Past allowed, future rejected.',
})
@IsOptional()
@IsISO8601()
actualDepartureAt?: string;
}

View File

@@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => {
let wagonBookingAllocationsRepository: Record<string, jest.Mock>; let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>; let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>; let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
beforeEach(() => { beforeEach(() => {
// findGroupSiblings runs a query builder off dataSource.manager; default it // findGroupSiblings runs a query builder off dataSource.manager; default it
@@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => {
findByIdWithFullGraph: jest.fn(), findByIdWithFullGraph: jest.fn(),
findAll: jest.fn(), findAll: jest.fn(),
updateStatus: jest.fn(), updateStatus: jest.fn(),
update: jest.fn(),
maxReferenceSequence: jest.fn().mockResolvedValue(0), maxReferenceSequence: jest.fn().mockResolvedValue(0),
}; };
trainScheduleBookingsRepository = { trainScheduleBookingsRepository = {
@@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => {
findAll: jest.fn().mockResolvedValue([]), findAll: jest.fn().mockResolvedValue([]),
}; };
const trainCheckpointEventsRepository = { trainCheckpointEventsRepository = {
findBySchedule: jest.fn().mockResolvedValue([]), findBySchedule: jest.fn().mockResolvedValue([]),
findAll: jest.fn().mockResolvedValue([]), findAll: jest.fn().mockResolvedValue([]),
create: jest.fn(), create: jest.fn(),
@@ -1638,6 +1640,61 @@ describe('TrainSchedulingService', () => {
}); });
}); });
describe('updateCheckpoint — leg time correction', () => {
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
const schedule = {
id: 'sch-track',
status: 'ARRIVED',
routeId: null,
originStationId: 'y0',
destinationStationId: 'y1',
actualDepartureAt: t(8),
};
const events = () => [
{ id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) },
{ id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) },
];
beforeEach(() => {
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events());
});
it('rejects a leg time earlier than the previous leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }),
).rejects.toThrow(/cannot be earlier than/);
expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled();
});
it('rejects a leg time later than the next leg', async () => {
await expect(
service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }),
).rejects.toThrow(/cannot be later than/);
});
it('rejects a future time', async () => {
const future = new Date(Date.now() + 3_600_000).toISOString();
await expect(
service.updateCheckpoint('sch-track', 1, { occurredAt: future }),
).rejects.toThrow(/future/);
});
it('accepts an in-order past time and re-stamps arrival for the final leg', async () => {
await service.updateCheckpoint('sch-track', 1, {
occurredAt: t(11).toISOString(),
note: 'late log',
});
expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', {
occurredAt: t(11),
note: 'late log',
});
expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', {
actualArrivalAt: t(11),
});
});
});
describe('effectiveWagonsRequired', () => { describe('effectiveWagonsRequired', () => {
const effective = (booking: unknown): number => const effective = (booking: unknown): number =>
(service as never as { effectiveWagonsRequired(b: unknown): number }) (service as never as { effectiveWagonsRequired(b: unknown): number })

View File

@@ -174,7 +174,11 @@ import {
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service'; import { BookingJourneyService } from '../booking-journey.service';
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository'; import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto'; import {
DispatchScheduleDto,
RecordCheckpointDto,
UpdateCheckpointDto,
} from '../dto/record-checkpoint.dto';
import { RouteMilestone } from '../../routes/entities/route-milestone.entity'; import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util'; import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service'; import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
@@ -2160,7 +2164,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 +2299,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.`,
); );
@@ -2635,7 +2649,7 @@ export class TrainSchedulingService {
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
async dispatchSchedule(scheduleId: string) { async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
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`);
@@ -2643,6 +2657,9 @@ export class TrainSchedulingService {
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
throw new BadRequestException('Only SCHEDULED trains can be dispatched'); throw new BadRequestException('Only SCHEDULED trains can be dispatched');
} }
// Staff may record the departure after the fact — past is fine, future is not.
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
this.assertNotFuture(now, 'Departure time');
await this.assertImportDjiboutiMayDepart(schedule); await this.assertImportDjiboutiMayDepart(schedule);
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon) // Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
// never blocks departure — the dispatch confirm dialog warns and staff decide. // never blocks departure — the dispatch confirm dialog warns and staff decide.
@@ -2667,7 +2684,6 @@ export class TrainSchedulingService {
} }
} }
const now = new Date();
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule); const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) { if (setLocomotiveIds.length) {
@@ -4150,6 +4166,7 @@ export class TrainSchedulingService {
? TrainCheckpointKind.Arrived ? TrainCheckpointKind.Arrived
: TrainCheckpointKind.Passed); : TrainCheckpointKind.Passed);
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date(); const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt);
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates. // Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
const [existing] = await this.trainCheckpointEventsRepository.findAll({ const [existing] = await this.trainCheckpointEventsRepository.findAll({
@@ -4173,8 +4190,14 @@ export class TrainSchedulingService {
}); });
} }
// The origin DEPARTED checkpoint IS the departure — keep the schedule's
// headline timestamp on the same clock the operator just entered.
if (dto.sequenceNo === 0) {
await this.trainSchedulesRepository.update(scheduleId, { actualDepartureAt: occurredAt });
}
if (dto.sequenceNo === finalSeq) { if (dto.sequenceNo === finalSeq) {
await this.arriveSchedule(scheduleId); await this.arriveSchedule(scheduleId, occurredAt);
} else { } else {
// Mid-corridor auto-unload: bookings destined for this yard alight the // Mid-corridor auto-unload: bookings destined for this yard alight the
// moment the train is recorded here — the yard operator no longer has to // moment the train is recorded here — the yard operator no longer has to
@@ -4210,11 +4233,133 @@ export class TrainSchedulingService {
return this.getScheduleCheckpoints(scheduleId); return this.getScheduleCheckpoints(scheduleId);
} }
/**
* Correct an already-logged leg's time/note. Pure edit: no auto-unload, no
* position fix, no arrival — those already happened when the leg was logged.
* Allowed on DISPATCHED and ARRIVED trains (a journey is corrected after the
* fact as often as during it). The origin/final legs also re-stamp the
* schedule's departure/arrival so the headline figures follow the edit.
*/
async updateCheckpoint(scheduleId: string, sequenceNo: number, dto: UpdateCheckpointDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (
schedule.status !== TrainScheduleStatusEnum.Dispatched &&
schedule.status !== TrainScheduleStatusEnum.Arrived
) {
throw new BadRequestException('Only DISPATCHED or ARRIVED trains have checkpoints to edit');
}
const stations = await this.buildScheduleStations(schedule);
const station = stations.find((s) => s.sequenceNo === sequenceNo);
if (!station) {
throw new BadRequestException(`Station ${sequenceNo} is not on this route`);
}
// Match by yard, like getScheduleCheckpoints — legacy rows may carry an
// older station numbering.
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const existing =
events.find((e) => e.yardId === station.yardId) ??
events.find((e) => e.sequenceNo === sequenceNo);
if (!existing) {
throw new BadRequestException(`Station ${station.label} has not been logged yet`);
}
const patch: Partial<TrainCheckpointEvent> = {};
if (dto.occurredAt) {
const occurredAt = new Date(dto.occurredAt);
await this.assertCheckpointTime(schedule, stations, sequenceNo, occurredAt, existing.id);
patch.occurredAt = occurredAt;
}
if (dto.note !== undefined) patch.note = dto.note;
if (Object.keys(patch).length) {
await this.trainCheckpointEventsRepository.update(existing.id, patch);
}
if (patch.occurredAt) {
const finalSeq = stations[stations.length - 1].sequenceNo;
if (sequenceNo === 0) {
await this.trainSchedulesRepository.update(scheduleId, {
actualDepartureAt: patch.occurredAt,
});
} else if (sequenceNo === finalSeq && schedule.status === TrainScheduleStatusEnum.Arrived) {
await this.trainSchedulesRepository.update(scheduleId, {
actualArrivalAt: patch.occurredAt,
});
}
}
return this.getScheduleCheckpoints(scheduleId);
}
private assertNotFuture(at: Date, what: string) {
if (Number.isNaN(at.getTime())) {
throw new BadRequestException(`${what} is not a valid date`);
}
// Small skew allowance so an honest "now" from a client clock passes.
if (at.getTime() > Date.now() + 60_000) {
throw new BadRequestException(`${what} cannot be in the future`);
}
}
/**
* A leg's time must not be in the future and must sit in corridor order:
* no earlier than every logged leg before it (and the dispatch time, for
* legs after the origin), no later than every logged leg after it.
* `ignoreEventId` excludes the row being edited from its own bounds.
*/
private async assertCheckpointTime(
schedule: TrainSchedule,
stations: { sequenceNo: number; yardId: string; label: string }[],
sequenceNo: number,
occurredAt: Date,
ignoreEventId?: string,
) {
this.assertNotFuture(occurredAt, 'Checkpoint time');
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
const labelBySeq = new Map(stations.map((s) => [s.sequenceNo, s.label]));
const events = (await this.trainCheckpointEventsRepository.findBySchedule(schedule.id)).filter(
(e) => e.id !== ignoreEventId,
);
const seqOf = (e: TrainCheckpointEvent) => seqByYard.get(e.yardId) ?? e.sequenceNo;
const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
let floor: { at: Date; label: string } | null = null;
let ceil: { at: Date; label: string } | null = null;
for (const e of events) {
const s = seqOf(e);
if (s < sequenceNo && (!floor || e.occurredAt > floor.at)) {
floor = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
}
if (s > sequenceNo && (!ceil || e.occurredAt < ceil.at)) {
ceil = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
}
}
// The origin leg rewrites the departure itself; every later leg must
// follow it.
if (sequenceNo > 0 && schedule.actualDepartureAt && (!floor || schedule.actualDepartureAt > floor.at)) {
floor = { at: schedule.actualDepartureAt, label: 'departure' };
}
if (floor && occurredAt < floor.at) {
throw new BadRequestException(
`Checkpoint time cannot be earlier than ${floor.label} (${fmt(floor.at)})`,
);
}
if (ceil && occurredAt > ceil.at) {
throw new BadRequestException(
`Checkpoint time cannot be later than ${ceil.label} (${fmt(ceil.at)})`,
);
}
}
/** /**
* Mark a dispatched train arrived: close out the schedule, move the locomotive * Mark a dispatched train arrived: close out the schedule, move the locomotive
* and wagons to the destination yard, and free the assets for re-use. * and wagons to the destination yard, and free the assets for re-use.
*/ */
async arriveSchedule(scheduleId: string) { async arriveSchedule(scheduleId: string, arrivedAt?: Date) {
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`);
@@ -4223,7 +4368,9 @@ export class TrainSchedulingService {
throw new BadRequestException('Only DISPATCHED trains can arrive'); throw new BadRequestException('Only DISPATCHED trains can arrive');
} }
const now = new Date(); // The arrival clock: the operator's entered time when arriving via the final
// checkpoint (already order/future-checked there), else now.
const now = arrivedAt ?? new Date();
await this.dataSource.transaction(async (manager) => { await this.dataSource.transaction(async (manager) => {
await this.trainSchedulesRepository.updateStatus( await this.trainSchedulesRepository.updateStatus(
@@ -4409,6 +4556,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 +6255,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,40 +9149,101 @@ 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');
} }
// Target: a slot of this train set, or an empty consist-only wagon of the // Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing —
// built train (physical wagon with no slot row yet). // Gelan→Adama and Adama→Doraleh loads ride the same wagon in two slots), so
// "the slot on that wagon" only means the one whose leg overlaps the moving
// load's leg. Null board/alight = the schedule's own endpoints.
const stops = await this.stopYardsForSchedule(schedule);
const spanOf = (slot: {
boardYardId?: string | null;
alightYardId?: string | null;
}): [number, number] => {
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const overlaps = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1];
const sourceSpan = spanOf(source);
// Target: a slot of this train set, or a physical wagon of this train —
// coupled-but-empty consist wagon (built train), or a wagon already pinned
// by another slot of this set (then: the overlapping-leg slot, or a fresh
// slot for a free leg).
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null; const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
const wagonForTarget = slotById let wagonForTarget: Wagon | null = null;
? null if (!slotById) {
: schedule.trainSet?.trainId const wagon = await this.dataSource.getRepository(Wagon).findOne({
? await this.dataSource.getRepository(Wagon).findOne({ where: { id: dto.targetWagonId },
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId }, relations: { wagonType: true },
relations: { wagonType: true }, });
}) const onThisTrain =
: null; !!wagon &&
((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) ||
slots.some((w) => w.physicalWagonId === wagon.id));
wagonForTarget = onThisTrain ? wagon : null;
}
if (!slotById && !wagonForTarget) { if (!slotById && !wagonForTarget) {
throw new NotFoundException('Target wagon is not part of this schedule'); throw new NotFoundException('Target wagon is not part of this schedule');
} }
// A physical wagon holds at most one slot. When the caller addressed the
// wagon directly but a slot is already pinned to it, move into that slot
// rather than minting a second one on the same wagon.
const targetSlot = const targetSlot =
slotById ?? slotById ??
(wagonForTarget (wagonForTarget
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null) ? (slots.find(
(w) =>
w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan),
) ?? null)
: null); : null);
const consistWagon = targetSlot ? null : wagonForTarget; const consistWagon = targetSlot ? null : wagonForTarget;
// Leg clash guard: after the move, no two slots on one physical wagon may
// ride the same edge. Source load → target wagon; on a swap, target load →
// source wagon.
const targetPhysicalId = targetSlot?.physicalWagonId ?? consistWagon?.id ?? null;
const clashOn = (
physicalWagonId: string | null,
excludeSlotId: string | null,
span: [number, number],
) =>
!!physicalWagonId &&
slots.some(
(w) =>
w.physicalWagonId === physicalWagonId &&
w.id !== excludeSlotId &&
w.id !== source.id &&
(w.allocations?.length ?? 0) > 0 &&
overlaps(spanOf(w), span),
);
if (clashOn(targetPhysicalId, targetSlot?.id ?? null, sourceSpan)) {
throw new BadRequestException(
'That wagon already carries another load on the same leg — pick a wagon free on that leg.',
);
}
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : []; const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
if (targetSlot && targetSlot.id === source.id) { if (targetSlot && targetSlot.id === source.id) {
return this.getTrainScheduleById(scheduleId); return this.getTrainScheduleById(scheduleId);
} }
if (
targetSlot &&
targetAllocs.length &&
clashOn(source.physicalWagonId ?? null, source.id, spanOf(targetSlot))
) {
throw new BadRequestException(
'Swap refused: the source wagon already carries another load on the incoming loads leg.',
);
}
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
@@ -9043,10 +9256,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 +9285,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(

View File

@@ -121,8 +121,46 @@ export class WagonsService {
* (yard workspace, coupling pickers) walk the pages client-side — see * (yard workspace, coupling pickers) walk the pages client-side — see
* `wagonService.listAll` in the backoffice. * `wagonService.listAll` in the backoffice.
*/ */
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> { async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
await this.attachStatusDates(page.items);
return page;
}
/**
* Latest status-flip dates from the audit log, for the wagons desk columns:
* when the wagon last went to MAINTENANCE and when it last became AVAILABLE.
* One grouped query per page; null when the log has no such flip.
*/
private async attachStatusDates(wagons: Wagon[]): Promise<void> {
if (!wagons.length) return;
const rows: Array<{
wagonId: string;
lastMaintenanceAt: Date | null;
lastAvailableAt: Date | null;
}> = await this.dataSource
.getRepository(WagonStatusLog)
.createQueryBuilder('l')
.select('l.wagon_id', 'wagonId')
.addSelect(
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`,
'lastMaintenanceAt',
)
.addSelect(
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`,
'lastAvailableAt',
)
.where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
.groupBy('l.wagon_id')
.getRawMany();
const byId = new Map(rows.map((r) => [r.wagonId, r]));
for (const w of wagons) {
const r = byId.get(w.id);
Object.assign(w, {
lastMaintenanceAt: r?.lastMaintenanceAt ?? null,
lastAvailableAt: r?.lastAvailableAt ?? null,
});
}
} }
async findById(id: string): Promise<Wagon> { async findById(id: string): Promise<Wagon> {

View File

@@ -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 = "";

View File

@@ -0,0 +1,103 @@
import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useEffect, useState } from "react";
/**
* Time + note for one leg of a train's journey — used both to log a pass
* (defaults to now) and to correct an already-logged leg (prefilled). Past
* times are allowed (staff record after the fact); the future is not, and the
* server additionally keeps legs in corridor order.
*/
export function CheckpointTimeModal({
opened,
onClose,
title,
icon,
description,
initialOccurredAt,
initialNote,
submitLabel,
submitColor = "edr-green",
loading,
onSubmit,
}: {
opened: boolean;
onClose: () => void;
title: string;
icon?: React.ReactNode;
description?: string;
/** ISO; omit to default to now. */
initialOccurredAt?: string | null;
initialNote?: string | null;
submitLabel: string;
submitColor?: string;
loading: boolean;
onSubmit: (values: { occurredAt: string; note: string }) => void;
}) {
const [at, setAt] = useState<Date | null>(null);
const [note, setNote] = useState("");
useEffect(() => {
if (!opened) return;
setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date());
setNote(initialNote ?? "");
}, [opened, initialOccurredAt, initialNote]);
return (
<Modal
opened={opened}
onClose={onClose}
centered
radius="lg"
title={
<Group gap={8}>
{icon}
<Text fw={700}>{title}</Text>
</Group>
}
>
<Stack gap="md">
{description ? (
<Text size="sm" c="dimmed">
{description}
</Text>
) : null}
<DateTimePicker
label="Time"
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={at}
onChange={(v) => setAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Textarea
label="Note"
placeholder="Optional"
value={note}
onChange={(e) => setNote(e.currentTarget.value)}
autosize
minRows={2}
maxRows={4}
maxLength={500}
radius="md"
/>
<Group justify="flex-end" gap="sm">
<Button variant="default" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button
color={submitColor}
loading={loading}
disabled={!at}
onClick={() =>
at && onSubmit({ occurredAt: at.toISOString(), note: note.trim() })
}
>
{submitLabel}
</Button>
</Group>
</Stack>
</Modal>
);
}

View File

@@ -0,0 +1,423 @@
import { Fragment, useEffect, useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { isAxiosError } from "axios";
import {
Alert,
Badge,
Button,
Group,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { ArrowLeftRight, Boxes, Info, MoveRight, Wheat, X } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type { TrainScheduleDetail } from "@/types/trainScheduling";
type Slot = NonNullable<TrainScheduleDetail["trainSet"]>["wagons"][number];
type Stop = { yardId: string; label: string };
type Span = [number, number];
/** One physical wagon of the consist with every slot (leg load) pinned to it. */
interface WagonRow {
key: string;
physicalWagonId: string | null;
label: string;
position: number;
typeCode: string | null;
capacityTons: number;
slots: Array<{ slot: Slot; span: Span; loaded: boolean }>;
}
const round1 = (n: number) => Math.round(n * 10) / 10;
const overlaps = (a: Span, b: Span) => a[0] < b[1] && b[0] < a[1];
/**
* Leg board: rows = physical wagons in coupling order, columns = corridor legs
* (A→B, B→C, …). A wagon reused on disjoint legs shows one load per leg on the
* same row, so a "53 full on A→B, 53 full on C→D" train reads at a glance.
* Loads move by click: pick a load, then click a wagon that is free on that
* load's legs (move) or another load (swap). Same API as the consist strip.
*/
export function LegLoadBoardPanel({
schedule,
onChanged,
}: {
schedule: TrainScheduleDetail;
onChanged?: () => void;
}) {
const { toast } = useToast();
const stops: Stop[] = schedule.stops ?? [];
const legs = useMemo(
() => stops.slice(0, -1).map((from, i) => ({ from, to: stops[i + 1], idx: i })),
[stops],
);
const canRearrange = !["DISPATCHED", "ARRIVED", "CANCELLED"].includes(schedule.status);
const spanOf = (slot: Slot): Span => {
const from = slot.boardYardId ? stops.findIndex((s) => s.yardId === slot.boardYardId) : 0;
const to = slot.alightYardId
? stops.findIndex((s) => s.yardId === slot.alightYardId)
: stops.length - 1;
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
};
const rows: WagonRow[] = useMemo(() => {
const byKey = new Map<string, WagonRow>();
for (const slot of schedule.trainSet?.wagons ?? []) {
const key = slot.physicalWagonId ?? `slot:${slot.id}`;
let row = byKey.get(key);
if (!row) {
row = {
key,
physicalWagonId: slot.physicalWagonId ?? null,
label: slot.physicalWagonNumber ?? `#${slot.position ?? slot.sequenceNo}`,
position: slot.position ?? slot.sequenceNo,
typeCode: slot.wagonType?.code ?? null,
capacityTons: slot.capacityTons ?? 0,
slots: [],
};
byKey.set(key, row);
}
row.position = Math.min(row.position, slot.position ?? slot.sequenceNo);
// Coupled-but-empty consist wagons carry no slot row: they are a target only.
if (!slot.consistOnly) {
row.slots.push({
slot,
span: spanOf(slot),
loaded: (slot.allocations?.length ?? 0) > 0,
});
}
}
return [...byKey.values()].sort((a, b) => a.position - b.position);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schedule.trainSet?.wagons, stops]);
const [picked, setPicked] = useState<{ slotId: string; rowKey: string; span: Span } | null>(
null,
);
useEffect(() => {
if (!picked) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setPicked(null);
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [picked]);
const moveMutation = useMutation(api.trainScheduling.moveWagonLoad.mutationOptions());
const doMove = async (targetWagonId: string, swap: boolean) => {
if (!picked || moveMutation.isPending) return;
try {
await moveMutation.mutateAsync({
scheduleId: schedule.id,
wagonId: picked.slotId,
targetWagonId,
});
toast({ title: swap ? "Loads swapped" : "Load moved" });
setPicked(null);
onChanged?.();
} catch (error) {
const message = isAxiosError(error)
? ((error.response?.data as { message?: string | string[] } | undefined)?.message ??
null)
: null;
toast({
title: "Could not move the load",
description: Array.isArray(message)
? message.join(", ")
: (message ?? "The move was rejected — check wagon type, payload and leg."),
variant: "destructive",
});
}
};
if (stops.length < 2) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
This schedule has no corridor stops yet the leg board needs a route with at least
two stops.
</Alert>
);
}
if (!rows.length) {
return (
<Alert color="gray" icon={<Info size={16} />} radius="md">
No wagons on this train yet.
</Alert>
);
}
const sharedRows = rows.filter((r) => r.slots.filter((s) => s.loaded).length > 1).length;
return (
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Stack gap={2}>
<Text fw={700} size="sm">
Loads per wagon per leg
</Text>
<Text size="xs" c="dimmed">
One row per physical wagon, one column per leg. A wagon reused on different legs
shows one load per leg.{" "}
{canRearrange
? "Click a load to pick it up, then click a wagon free on those legs to move it, or another load to swap."
: "Read-only — the train has departed."}
</Text>
</Stack>
<Group gap="xs">
{sharedRows > 0 ? (
<Badge variant="light" color="violet" radius="sm">
{sharedRows} wagon{sharedRows === 1 ? "" : "s"} shared across legs
</Badge>
) : null}
{picked ? (
<Button
size="xs"
variant="default"
leftSection={<X size={14} />}
onClick={() => setPicked(null)}
>
Cancel move (Esc)
</Button>
) : null}
</Group>
</Group>
<Paper withBorder radius="md" style={{ overflowX: "auto" }}>
<Table verticalSpacing={6} horizontalSpacing="sm" style={{ minWidth: 640 }}>
<Table.Thead>
<Table.Tr>
<Table.Th style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1, width: 180 }}>
Wagon
</Table.Th>
{legs.map((leg) => (
<Table.Th key={leg.idx} style={{ minWidth: 200 }}>
<Group gap={4} wrap="nowrap">
<Text size="xs" fw={700} truncate>
{leg.from.label}
</Text>
<MoveRight size={12} />
<Text size="xs" fw={700} truncate>
{leg.to.label}
</Text>
</Group>
</Table.Th>
))}
<Table.Th style={{ width: 110 }}>Cargo</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{rows.map((row) => {
const cargoTons = row.slots.reduce(
(s, x) =>
s +
((x.slot.allocations ?? []).reduce(
(a, al) => a + (al.allocatedWeightTons ?? 0),
0,
) || x.slot.assignedWeightTons || 0),
0,
);
const isPickedRow = picked?.rowKey === row.key;
// A row can take the picked load when nothing loaded on it rides
// any of the picked load's legs.
const rowFreeForPicked =
!!picked &&
!isPickedRow &&
!row.slots.some((s) => s.loaded && overlaps(s.span, picked.span));
// Where a "move here" lands: an existing empty slot on those legs,
// else the physical wagon itself (the API mints the slot).
const emptyTargetSlot = picked
? row.slots.find((s) => !s.loaded && overlaps(s.span, picked.span))
: undefined;
const moveTargetId = emptyTargetSlot?.slot.id ?? row.physicalWagonId ?? null;
// Lay slots into leg columns; uncovered legs render as empty cells.
const cells: React.ReactNode[] = [];
let col = 0;
const sorted = [...row.slots].sort((a, b) => a.span[0] - b.span[0]);
// Empty cell = uncovered leg (target: the physical wagon) or an
// empty slot (target: that slot). Both take the picked load when
// the row is free on its legs.
const emptyCell = (from: number, to: number, targetId = moveTargetId) => {
const droppable = rowFreeForPicked && canRearrange && !!targetId &&
!!picked && overlaps([from, to], picked.span);
return (
<Table.Td
key={`e-${from}`}
colSpan={Math.max(1, to - from)}
onClick={droppable ? () => void doMove(targetId!, false) : undefined}
style={{
cursor: droppable ? "pointer" : "default",
background: droppable ? "var(--mantine-color-teal-0)" : undefined,
outline: droppable ? "1px dashed var(--mantine-color-teal-5)" : undefined,
outlineOffset: -3,
borderRadius: 6,
}}
>
{droppable ? (
<Text size="xs" c="teal.7" fw={600} ta="center">
Move here
</Text>
) : (
<Text size="xs" c="dimmed" ta="center">
</Text>
)}
</Table.Td>
);
};
for (const s of sorted) {
if (s.span[0] > col) cells.push(emptyCell(col, s.span[0]));
if (!s.loaded) {
cells.push(emptyCell(s.span[0], s.span[1], s.slot.id));
col = Math.max(col, s.span[1]);
continue;
}
const isPicked = picked?.slotId === s.slot.id;
const swappable =
!!picked && !isPicked && !isPickedRow && s.loaded && canRearrange;
const allocs = s.slot.allocations ?? [];
const bulk = allocs.some((a) => (a.loadType ?? "CONTAINER").toUpperCase() === "BULK");
const containers = allocs.flatMap((a) => a.containerItems ?? []);
cells.push(
<Table.Td
key={s.slot.id}
colSpan={Math.max(1, s.span[1] - s.span[0])}
onClick={
!canRearrange
? undefined
: s.loaded && !picked
? () => setPicked({ slotId: s.slot.id, rowKey: row.key, span: s.span })
: swappable
? () => void doMove(s.slot.id, true)
: isPicked
? () => setPicked(null)
: undefined
}
style={{
cursor: canRearrange && (s.loaded || swappable) ? "pointer" : "default",
padding: 4,
}}
>
{s.loaded ? (
<Paper
radius="sm"
px={8}
py={6}
style={{
background: bulk
? "var(--mantine-color-orange-0)"
: "var(--mantine-color-cyan-0)",
borderLeft: `4px solid ${
bulk ? "var(--mantine-color-orange-6)" : "var(--mantine-color-cyan-6)"
}`,
outline: isPicked
? "2px solid var(--mantine-color-edr-green-6)"
: swappable
? "1px dashed var(--mantine-color-orange-6)"
: undefined,
outlineOffset: 1,
}}
>
<Group gap={6} wrap="nowrap" justify="space-between">
<Group gap={6} wrap="nowrap">
{bulk ? <Wheat size={13} /> : <Boxes size={13} />}
<Text size="xs" fw={700} truncate>
{[...new Set(allocs.map((a) => a.bookingReference ?? "—"))].join(", ")}
</Text>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{round1(
allocs.reduce((a, al) => a + (al.allocatedWeightTons ?? 0), 0),
)}{" "}
t
</Text>
</Group>
<Group gap={4} mt={2} wrap="wrap">
{bulk
? allocs.map((a) =>
a.bulkLoad ? (
<Badge key={a.id} size="xs" variant="light" color="orange" radius="sm">
{a.bulkLoad.cargoDescription ?? "Bulk"} · {round1(a.bulkLoad.weightTons)} t
</Badge>
) : null,
)
: containers.map((c) => (
<Badge key={c.id} size="xs" variant="light" color="cyan" radius="sm">
{c.containerNumber ?? "no number"}
</Badge>
))}
{swappable ? (
<Badge size="xs" color="orange" radius="sm" leftSection={<ArrowLeftRight size={10} />}>
swap
</Badge>
) : null}
</Group>
</Paper>
) : (
<Text size="xs" c="dimmed" ta="center">
empty
</Text>
)}
</Table.Td>,
);
col = Math.max(col, s.span[1]);
}
if (col < legs.length) cells.push(emptyCell(col, legs.length));
return (
<Table.Tr
key={row.key}
style={{
background: isPickedRow
? "var(--mantine-color-green-0)"
: rowFreeForPicked
? undefined
: picked
? "var(--mantine-color-gray-0)"
: undefined,
opacity: picked && !isPickedRow && !rowFreeForPicked ? 0.55 : 1,
}}
>
<Table.Td style={{ position: "sticky", left: 0, background: "var(--mantine-color-body)", zIndex: 1 }}>
<Group gap={6} wrap="nowrap">
<Badge variant="outline" color="gray" radius="sm" size="sm">
#{row.position}
</Badge>
<Stack gap={0}>
<Text size="sm" fw={700}>
{row.label}
</Text>
<Text size="xs" c="dimmed">
{row.typeCode ?? "—"} · {round1(row.capacityTons)} t
</Text>
</Stack>
{row.slots.filter((s) => s.loaded).length > 1 ? (
<Tooltip label="This wagon carries different loads on different legs">
<Badge size="xs" color="violet" variant="light" radius="sm">
shared
</Badge>
</Tooltip>
) : null}
</Group>
</Table.Td>
{cells.map((c, i) => (
<Fragment key={i}>{c}</Fragment>
))}
<Table.Td>
<Text size="xs" fw={600} c={cargoTons > row.capacityTons + 0.001 ? "red.7" : undefined}>
{round1(cargoTons)} / {round1(row.capacityTons)} t
</Text>
</Table.Td>
</Table.Tr>
);
})}
</Table.Tbody>
</Table>
</Paper>
</Stack>
);
}

View File

@@ -12,6 +12,7 @@ import {
ThemeIcon, ThemeIcon,
Tooltip, Tooltip,
} from "@mantine/core"; } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
CheckCircle2, CheckCircle2,
@@ -111,7 +112,12 @@ export function LogPassYardWorkModal({
}) { }) {
const { toast } = useToast(); const { toast } = useToast();
const [justLogged, setJustLogged] = useState(false); const [justLogged, setJustLogged] = useState(false);
useEffect(() => setJustLogged(false), [station?.sequenceNo, opened]); // When the train was here — defaults to now, past allowed (recorded after the fact).
const [passAt, setPassAt] = useState<Date | null>(null);
useEffect(() => {
setJustLogged(false);
setPassAt(new Date());
}, [station?.sequenceNo, opened]);
const logged = alreadyLogged || justLogged; const logged = alreadyLogged || justLogged;
const yardWorkQuery = useQuery( const yardWorkQuery = useQuery(
@@ -133,7 +139,13 @@ export function LogPassYardWorkModal({
const doLogPass = () => { const doLogPass = () => {
if (!station) return; if (!station) return;
recordCheckpoint.mutate( recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo: station.sequenceNo } }, {
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
...(passAt ? { occurredAt: passAt.toISOString() } : {}),
},
},
{ {
onSuccess: () => { onSuccess: () => {
setJustLogged(true); setJustLogged(true);
@@ -373,6 +385,20 @@ export function LogPassYardWorkModal({
</> </>
)} )}
{!logged ? (
<DateTimePicker
label={isFinal ? "Arrival time" : "Time at station"}
description="Defaults to now — pick an earlier time if you are recording after the fact."
value={passAt}
onChange={(v) => setPassAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
maw={320}
/>
) : null}
<Group justify="space-between" mt="xs"> <Group justify="space-between" mt="xs">
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{logged && pendingBoarders.length > 0 {logged && pendingBoarders.length > 0

View File

@@ -1,6 +1,6 @@
import { Fragment } from "react"; import { Fragment } from "react";
import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core"; import { Badge, Box, Button, Group, Stack, Text } from "@mantine/core";
import { Check, Flag, MapPin, Train } from "lucide-react"; import { Check, Flag, MapPin, Pencil, Train } from "lucide-react";
import { freightBrand } from "@/theme/freight-brand"; import { freightBrand } from "@/theme/freight-brand";
import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling"; import type { TrainCheckpoint, TrackStation } from "@/types/trainScheduling";
@@ -14,6 +14,8 @@ export interface RouteCorridorTrackProps {
canLog: boolean; canLog: boolean;
loggingSeq?: number | null; loggingSeq?: number | null;
onLogCheckpoint?: (sequenceNo: number) => void; onLogCheckpoint?: (sequenceNo: number) => void;
/** Present when logged legs may be corrected (dispatched or arrived). */
onEditCheckpoint?: (checkpoint: TrainCheckpoint) => void;
} }
const COLUMN_WIDTH = 150; const COLUMN_WIDTH = 150;
@@ -31,6 +33,7 @@ export function RouteCorridorTrack({
canLog, canLog,
loggingSeq, loggingSeq,
onLogCheckpoint, onLogCheckpoint,
onEditCheckpoint,
}: RouteCorridorTrackProps) { }: RouteCorridorTrackProps) {
const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c])); const bySeq = new Map(checkpoints.map((c) => [c.sequenceNo, c]));
const lastIndex = stations.length - 1; const lastIndex = stations.length - 1;
@@ -160,14 +163,28 @@ export function RouteCorridorTrack({
{/* checkpoint time or action */} {/* checkpoint time or action */}
{checkpoint ? ( {checkpoint ? (
<Text size="10px" c="dimmed" ta="center"> <Stack gap={2} align="center">
{new Date(checkpoint.occurredAt).toLocaleString(undefined, { <Text size="10px" c="dimmed" ta="center">
month: "short", {new Date(checkpoint.occurredAt).toLocaleString(undefined, {
day: "numeric", month: "short",
hour: "2-digit", day: "numeric",
minute: "2-digit", hour: "2-digit",
})} minute: "2-digit",
</Text> })}
</Text>
{onEditCheckpoint ? (
<Button
size="compact-xs"
radius="md"
variant="subtle"
color="gray"
leftSection={<Pencil size={11} />}
onClick={() => onEditCheckpoint(checkpoint)}
>
Edit time
</Button>
) : null}
</Stack>
) : isNext ? ( ) : isNext ? (
<Button <Button
size="compact-xs" size="compact-xs"

View File

@@ -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,
eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const, eimsReceipts: (id: string) => ["invoices", "eims", id, "receipts"] as const,
}, },

View File

@@ -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`,
INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`, INVOICE_MEMO: (id: string) => `/billing/invoices/${id}/memo`,
@@ -489,6 +490,8 @@ export const URL_CONSTANTS = {
`/train-scheduling/schedules/${id}/intercity/marshalling/document`, `/train-scheduling/schedules/${id}/intercity/marshalling/document`,
CHECKPOINTS: (id: string) => CHECKPOINTS: (id: string) =>
`/train-scheduling/schedules/${id}/checkpoints`, `/train-scheduling/schedules/${id}/checkpoints`,
CHECKPOINT: (id: string, sequenceNo: number) =>
`/train-scheduling/schedules/${id}/checkpoints/${sequenceNo}`,
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`, ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
RESCHEDULE_PREVIEW: (id: string) => RESCHEDULE_PREVIEW: (id: string) =>
`/train-scheduling/schedules/${id}/reschedule/preview`, `/train-scheduling/schedules/${id}/reschedule/preview`,

View File

@@ -353,6 +353,10 @@ export const FLEET_RESOURCES: FleetResourceConfig[] = [
{ id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" }, { id: "importTrainNumber", header: "Import train no.", accessorKey: "importTrainNumber", format: "code" },
{ id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" }, { id: "currentYard", header: "Current Yard", accessorKey: "currentYard", format: "entityLabel" },
{ id: "status", header: "Status", accessorKey: "status", format: "statusBadge" }, { id: "status", header: "Status", accessorKey: "status", format: "statusBadge" },
// From the status-flip log: last time the wagon went to maintenance, and
// last time it became available again (dash = never logged).
{ id: "lastMaintenanceAt", header: "Last to maintenance", accessorKey: "lastMaintenanceAt", format: "date" },
{ id: "lastAvailableAt", header: "Available since", accessorKey: "lastAvailableAt", format: "date" },
], ],
formFields: [ formFields: [
// Run numbers are optional — a wagon sits in the fleet unassigned to any // Run numbers are optional — a wagon sits in the fleet unassigned to any

View File

@@ -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",

View File

@@ -79,9 +79,31 @@ function InfoField({
); );
} }
/** Billed-to company, with its contact/registration details as quick-info rows. */ /**
* Billed-to party: a customer company, or — for shipping-line credit invoices
* (`companyId` null) — the shipping line itself. The two payers are mutually
* exclusive (DB-enforced), so exactly one branch has data.
*/
function RecipientCard({ invoice }: { invoice: Invoice }) { function RecipientCard({ invoice }: { invoice: Invoice }) {
const company = invoice.company; const company = invoice.company;
const shippingLine = invoice.shippingLineCompany;
if (!company && shippingLine) {
const rows: FieldRowProps[] = [
{ label: "Phone", value: shippingLine.phoneNumber },
{ label: "Email", value: shippingLine.email },
];
return (
<LinkedEntityCard
icon={Building2}
title="Billed to"
name={shippingLine.name}
rows={rows}
emptyMessage="No additional shipping line details available."
/>
);
}
const rows: FieldRowProps[] = [ const rows: FieldRowProps[] = [
{ label: "Profile", value: invoice.companyProfile?.reference }, { label: "Profile", value: invoice.companyProfile?.reference },
{ label: "TIN", value: company?.tin }, { label: "TIN", value: company?.tin },
@@ -93,7 +115,7 @@ function RecipientCard({ invoice }: { invoice: Invoice }) {
return ( return (
<LinkedEntityCard <LinkedEntityCard
icon={Building2} icon={Building2}
title="Recipient" title="Billed to"
name={company?.name ?? "Unnamed company"} name={company?.name ?? "Unnamed company"}
to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null} to={invoice.companyId ? `/dashboard/customers/${invoice.companyId}` : null}
rows={rows} rows={rows}

View File

@@ -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(
() => [ () => [
{ {
@@ -95,7 +112,9 @@ export default function InvoicesPanel() {
header: "Billed to", header: "Billed to",
cell: ({ row }) => ( cell: ({ row }) => (
<Text size="sm" c="edr-text" truncate maw={200}> <Text size="sm" c="edr-text" truncate maw={200}>
{row.original.company?.name ?? "—"} {row.original.company?.name ??
row.original.shippingLineCompany?.name ??
"—"}
</Text> </Text>
), ),
}, },
@@ -170,7 +189,42 @@ export default function InvoicesPanel() {
); );
return ( return (
<Card p={0}> <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}>
<Stack gap={0}> <Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%"> <Box px="md" pt="md" pb="sm" w="100%">
<Group justify="space-between" gap="md" wrap="wrap"> <Group justify="space-between" gap="md" wrap="wrap">
@@ -264,6 +318,7 @@ export default function InvoicesPanel() {
</Box> </Box>
</Box> </Box>
</Stack> </Stack>
</Card> </Card>
</Stack>
); );
} }

View File

@@ -580,14 +580,10 @@ const RuleEngineResourcePage = () => {
config={config} config={config}
layout="row" layout="row"
readOnly={!canUpdateControls} readOnly={!canUpdateControls}
onEdit={ onEdit={(record) => {
config.slug === "container-types" setEditing(record);
? undefined setFormOpen(true);
: (record) => { }}
setEditing(record);
setFormOpen(true);
}
}
onDelete={setDeleteTarget} onDelete={setDeleteTarget}
onViewChain={ onViewChain={
config.slug === "approval-rules" config.slug === "approval-rules"
@@ -958,9 +954,7 @@ const RuleEngineResourcePage = () => {
totalCount={totalCount} totalCount={totalCount}
onPaginationChange={setPagination} onPaginationChange={setPagination}
readOnly={!canUpdate && !canDelete} readOnly={!canUpdate && !canDelete}
onEdit={ onEdit={canUpdate ? openEdit : undefined}
canUpdate && config.slug !== "container-types" ? openEdit : undefined
}
onDelete={canDelete ? setDeleteTarget : undefined} onDelete={canDelete ? setDeleteTarget : undefined}
onViewChain={ onViewChain={
config.slug === "approval-rules" config.slug === "approval-rules"

View File

@@ -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

View File

@@ -10,6 +10,7 @@ import {
MapPin, MapPin,
Navigation, Navigation,
PackageCheck, PackageCheck,
Pencil,
Train, Train,
} from "lucide-react"; } from "lucide-react";
import { import {
@@ -29,9 +30,10 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { PageContainer } from "@/components/page"; import { PageContainer } from "@/components/page";
import { CheckpointTimeModal } from "@/components/trainScheduling/CheckpointTimeModal";
import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal"; import { LogPassYardWorkModal } from "@/components/trainScheduling/LogPassYardWorkModal";
import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack"; import { RouteCorridorTrack } from "@/components/trainScheduling/RouteCorridorTrack";
import type { TrackStation } from "@/types/trainScheduling"; import type { TrackStation, TrainCheckpoint } from "@/types/trainScheduling";
import { import {
RouteCorridor, RouteCorridor,
StatusPill, StatusPill,
@@ -156,6 +158,16 @@ export default function TrainScheduleTrackPage() {
const recordCheckpoint = useMutation( const recordCheckpoint = useMutation(
api.trainScheduling.recordCheckpoint.mutationOptions(), api.trainScheduling.recordCheckpoint.mutationOptions(),
); );
const updateCheckpoint = useMutation(
api.trainScheduling.updateCheckpoint.mutationOptions(),
);
// Time-entry dialogs: logging a pass at a yard with no work (the yard-work
// modal carries its own picker), and correcting an already-logged leg.
const [logModal, setLogModal] = useState<{
station: TrackStation;
isFinal: boolean;
} | null>(null);
const [editModal, setEditModal] = useState<TrainCheckpoint | null>(null);
// Yard work drives the log-pass modal: which bookings board/alight per stop. // Yard work drives the log-pass modal: which bookings board/alight per stop.
const yardWorkQuery = useQuery( const yardWorkQuery = useQuery(
api.trainScheduling.yardWork.queryOptions({ api.trainScheduling.yardWork.queryOptions({
@@ -241,15 +253,30 @@ export default function TrainScheduleTrackPage() {
const handleLog = (sequenceNo: number) => { const handleLog = (sequenceNo: number) => {
const station = track.stations.find((s) => s.sequenceNo === sequenceNo); const station = track.stations.find((s) => s.sequenceNo === sequenceNo);
if (!station) return;
const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo; const isFinal = sequenceNo === track.stations[totalStations - 1]?.sequenceNo;
if (station && stationHasWork(station)) { if (stationHasWork(station)) {
setYardModal({ station, isFinal, alreadyLogged: false }); setYardModal({ station, isFinal, alreadyLogged: false });
return; return;
} }
setLogModal({ station, isFinal });
};
const submitLog = (values: { occurredAt: string; note: string }) => {
if (!logModal) return;
const { station, isFinal } = logModal;
recordCheckpoint.mutate( recordCheckpoint.mutate(
{ id: scheduleId, payload: { sequenceNo } }, {
id: scheduleId,
payload: {
sequenceNo: station.sequenceNo,
occurredAt: values.occurredAt,
...(values.note ? { note: values.note } : {}),
},
},
{ {
onSuccess: () => { onSuccess: () => {
setLogModal(null);
toast({ toast({
title: isFinal title: isFinal
? "Train arrived — assets freed, moved to destination yard" ? "Train arrived — assets freed, moved to destination yard"
@@ -266,6 +293,32 @@ export default function TrainScheduleTrackPage() {
); );
}; };
const submitEdit = (values: { occurredAt: string; note: string }) => {
if (!editModal) return;
updateCheckpoint.mutate(
{
id: scheduleId,
sequenceNo: editModal.sequenceNo,
payload: { occurredAt: values.occurredAt, note: values.note || null },
},
{
onSuccess: () => {
setEditModal(null);
toast({ title: "Checkpoint updated" });
},
onError: (err) =>
toast({
title: "Could not update checkpoint",
description: parseError(err, "Please try again"),
variant: "destructive",
}),
},
);
};
// Legs stay correctable for as long as the journey exists — while rolling
// and after arrival.
const canEdit = track.status === "DISPATCHED" || track.status === "ARRIVED";
// "Forgot to load" catch: while the train sits at the current station, any // "Forgot to load" catch: while the train sits at the current station, any
// boarder there that is still unloaded can be loaded until the next pass. // boarder there that is still unloaded can be loaded until the next pass.
const currentStationObj = track.stations.find( const currentStationObj = track.stations.find(
@@ -502,6 +555,7 @@ export default function TrainScheduleTrackPage() {
: null : null
} }
onLogCheckpoint={handleLog} onLogCheckpoint={handleLog}
onEditCheckpoint={canEdit ? setEditModal : undefined}
/> />
{/* Cargo the operator forgot: boarders at the CURRENT station stay {/* Cargo the operator forgot: boarders at the CURRENT station stay
@@ -595,24 +649,38 @@ export default function TrainScheduleTrackPage() {
) )
} }
title={ title={
<Group gap="sm"> <Group gap="sm" justify="space-between" wrap="nowrap">
<Text fw={700} size="sm"> <Group gap="sm">
{cp.label ?? `Station ${cp.sequenceNo}`} <Text fw={700} size="sm">
</Text> {cp.label ?? `Station ${cp.sequenceNo}`}
<Badge </Text>
size="xs" <Badge
radius="sm" size="xs"
variant="light" radius="sm"
color={ variant="light"
cp.kind === "ARRIVED" color={
? "teal" cp.kind === "ARRIVED"
: cp.kind === "DEPARTED" ? "teal"
? "blue" : cp.kind === "DEPARTED"
: "edr-green" ? "blue"
} : "edr-green"
> }
{cp.kind} >
</Badge> {cp.kind}
</Badge>
</Group>
{canEdit ? (
<Button
size="compact-xs"
radius="md"
variant="light"
color="gray"
leftSection={<Pencil size={12} />}
onClick={() => setEditModal(cp)}
>
Edit
</Button>
) : null}
</Group> </Group>
} }
> >
@@ -630,6 +698,39 @@ export default function TrainScheduleTrackPage() {
)} )}
</Paper> </Paper>
<CheckpointTimeModal
opened={logModal !== null}
onClose={() => setLogModal(null)}
title={
logModal?.isFinal
? `Mark arrived at ${logModal.station.label}`
: `Log pass at ${logModal?.station.label ?? "station"}`
}
icon={logModal?.isFinal ? <Flag size={18} /> : <MapPin size={18} />}
description={
logModal?.isFinal
? "Marks the train arrived: remaining bookings arrive, assets are freed."
: undefined
}
submitLabel={logModal?.isFinal ? "Mark arrived" : "Log pass"}
submitColor={logModal?.isFinal ? "teal" : "edr-green"}
loading={recordCheckpoint.isPending}
onSubmit={submitLog}
/>
<CheckpointTimeModal
opened={editModal !== null}
onClose={() => setEditModal(null)}
title={`Edit ${editModal?.label ?? "checkpoint"}`}
icon={<Pencil size={18} />}
description="Corrects this leg's time and note only — nothing else changes."
initialOccurredAt={editModal?.occurredAt}
initialNote={editModal?.note}
submitLabel="Save"
loading={updateCheckpoint.isPending}
onSubmit={submitEdit}
/>
<LogPassYardWorkModal <LogPassYardWorkModal
opened={yardModal !== null} opened={yardModal !== null}
onClose={() => setYardModal(null)} onClose={() => setYardModal(null)}

View File

@@ -34,6 +34,7 @@ import {
Navigation, Navigation,
Package, Package,
PackageCheck, PackageCheck,
Grid3x3,
Route as RouteIcon, Route as RouteIcon,
Ruler, Ruler,
Send, Send,
@@ -41,6 +42,7 @@ import {
Weight, Weight,
Workflow as WorkflowIcon, Workflow as WorkflowIcon,
} from "lucide-react"; } from "lucide-react";
import { DateTimePicker } from "@mantine/dates";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -57,6 +59,7 @@ import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPl
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel"; import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel"; import { LegCapacityPanel } from "@/components/trainScheduling/LegCapacityPanel";
import { LegLoadBoardPanel } from "@/components/trainScheduling/LegLoadBoardPanel";
import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal"; import { LoadEmptyContainersModal } from "@/components/trainScheduling/LoadEmptyContainersModal";
import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal"; import MergeScheduleTrainModal from "@/components/trainScheduling/MergeScheduleTrainModal";
import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel"; import ScheduleHistoryPanel from "@/components/trainScheduling/ScheduleHistoryPanel";
@@ -125,6 +128,13 @@ export default function TrainScheduleV2DetailPage() {
const [gatepassFileUrl, setGatepassFileUrl] = useState(""); const [gatepassFileUrl, setGatepassFileUrl] = useState("");
const [gatepassNotes, setGatepassNotes] = useState(""); const [gatepassNotes, setGatepassNotes] = useState("");
const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false); const [dispatchConfirmOpen, setDispatchConfirmOpen] = useState(false);
// Actual departure — staff often dispatch on paper first and record it later,
// so the time is picked (defaults to now when the dialog opens).
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
const openDispatchConfirm = () => {
setDispatchAt(new Date());
setDispatchConfirmOpen(true);
};
const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null); const [switchTarget, setSwitchTarget] = useState<EligibleContainerBooking | null>(null);
const [visualization3DOpen, setVisualization3DOpen] = useState(false); const [visualization3DOpen, setVisualization3DOpen] = useState(false);
const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false); const [loadEmptiesOpen, setLoadEmptiesOpen] = useState(false);
@@ -477,7 +487,10 @@ export default function TrainScheduleV2DetailPage() {
const runDispatch = async () => { const runDispatch = async () => {
setDispatchConfirmOpen(false); setDispatchConfirmOpen(false);
try { try {
await dispatch.mutateAsync(scheduleId); await dispatch.mutateAsync({
id: scheduleId,
payload: dispatchAt ? { actualDepartureAt: dispatchAt.toISOString() } : {},
});
await openMarshallingDocument({ await openMarshallingDocument({
title: "Train dispatched", title: "Train dispatched",
successDescription: "Marshalling document generated for the dispatched train.", successDescription: "Marshalling document generated for the dispatched train.",
@@ -873,7 +886,7 @@ export default function TrainScheduleV2DetailPage() {
radius="md" radius="md"
leftSection={<Send size={18} />} leftSection={<Send size={18} />}
loading={dispatch.isPending} loading={dispatch.isPending}
onClick={() => setDispatchConfirmOpen(true)} onClick={openDispatchConfirm}
> >
Dispatch train Dispatch train
</Button> </Button>
@@ -1271,6 +1284,9 @@ export default function TrainScheduleV2DetailPage() {
<Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}> <Tabs.Tab value="legs" leftSection={<RouteIcon size={16} />}>
Leg capacity Leg capacity
</Tabs.Tab> </Tabs.Tab>
<Tabs.Tab value="leg-board" leftSection={<Grid3x3 size={16} />}>
Leg board
</Tabs.Tab>
<Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}> <Tabs.Tab value="history" leftSection={<HistoryIcon size={16} />}>
History History
</Tabs.Tab> </Tabs.Tab>
@@ -1359,6 +1375,13 @@ export default function TrainScheduleV2DetailPage() {
<LegCapacityPanel schedule={schedule} /> <LegCapacityPanel schedule={schedule} />
</Tabs.Panel> </Tabs.Panel>
<Tabs.Panel value="leg-board">
<LegLoadBoardPanel
schedule={schedule}
onChanged={() => void detailQuery.refetch()}
/>
</Tabs.Panel>
<Tabs.Panel value="history"> <Tabs.Panel value="history">
{scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null} {scheduleId ? <ScheduleHistoryPanel scheduleId={scheduleId} /> : null}
</Tabs.Panel> </Tabs.Panel>
@@ -1444,6 +1467,17 @@ export default function TrainScheduleV2DetailPage() {
undone. undone.
</Text> </Text>
<DateTimePicker
label="Actual departure"
description="When the train left — defaults to now; a past time is fine."
value={dispatchAt}
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
{hasDispatchWarnings ? ( {hasDispatchWarnings ? (
<Alert <Alert
color="orange" color="orange"

View File

@@ -18,6 +18,7 @@ import {
TextInput, TextInput,
ThemeIcon, ThemeIcon,
} from "@mantine/core"; } from "@mantine/core";
import { DateTimePicker } from "@mantine/dates";
import { useDebouncedValue } from "@mantine/hooks"; import { useDebouncedValue } from "@mantine/hooks";
import { isAxiosError } from "axios"; import { isAxiosError } from "axios";
import { import {
@@ -134,6 +135,8 @@ export default function TrainScheduleV2ListPage() {
// confirmation. // confirmation.
const [dispatchTarget, setDispatchTarget] = const [dispatchTarget, setDispatchTarget] =
useState<TrainScheduleListItem | null>(null); useState<TrainScheduleListItem | null>(null);
// Actual departure — defaults to now when the dialog opens; past is fine.
const [dispatchAt, setDispatchAt] = useState<Date | null>(null);
// Cancelling is likewise irreversible — confirmed before the mutation fires. // Cancelling is likewise irreversible — confirmed before the mutation fires.
const [cancelTarget, setCancelTarget] = const [cancelTarget, setCancelTarget] =
useState<TrainScheduleListItem | null>(null); useState<TrainScheduleListItem | null>(null);
@@ -362,6 +365,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
@@ -507,7 +511,10 @@ export default function TrainScheduleV2ListPage() {
{canDispatch && schedule.status === "SCHEDULED" ? ( {canDispatch && schedule.status === "SCHEDULED" ? (
<Menu.Item <Menu.Item
leftSection={<Play size={15} />} leftSection={<Play size={15} />}
onClick={() => setDispatchTarget(schedule)} onClick={() => {
setDispatchAt(new Date());
setDispatchTarget(schedule);
}}
> >
Start (dispatch) train Start (dispatch) train
</Menu.Item> </Menu.Item>
@@ -742,7 +749,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
? { ? {
@@ -954,6 +965,16 @@ export default function TrainScheduleV2ListPage() {
wagons or cargo not yet marked loaded those warnings are shown wagons or cargo not yet marked loaded those warnings are shown
there, not here. there, not here.
</Text> </Text>
<DateTimePicker
label="Actual departure"
description="When the train left — defaults to now; a past time is fine."
value={dispatchAt}
onChange={(v) => setDispatchAt(v ? new Date(v) : null)}
maxDate={new Date()}
valueFormat="DD MMM YYYY HH:mm"
clearable={false}
radius="md"
/>
<Group justify="flex-end" gap="sm"> <Group justify="flex-end" gap="sm">
<Button variant="default" onClick={() => setDispatchTarget(null)}> <Button variant="default" onClick={() => setDispatchTarget(null)}>
Cancel Cancel
@@ -965,7 +986,12 @@ export default function TrainScheduleV2ListPage() {
onClick={async () => { onClick={async () => {
if (!dispatchTarget) return; if (!dispatchTarget) return;
try { try {
await dispatchSchedule.mutateAsync(dispatchTarget.id); await dispatchSchedule.mutateAsync({
id: dispatchTarget.id,
payload: dispatchAt
? { actualDepartureAt: dispatchAt.toISOString() }
: {},
});
toast({ title: "Train dispatched" }); toast({ title: "Train dispatched" });
setDispatchTarget(null); setDispatchTarget(null);
void schedulesQuery.refetch(); void schedulesQuery.refetch();
@@ -1052,6 +1078,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 +1173,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 +1223,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" />

View File

@@ -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,
@@ -80,6 +81,8 @@ import type {
LocomotiveRecord, LocomotiveRecord,
PinWagonsPayload, PinWagonsPayload,
RecordCheckpointPayload, RecordCheckpointPayload,
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow, StaffBookingWindow,
ScheduleMergePreview, ScheduleMergePreview,
TrainScheduleDetail, TrainScheduleDetail,
@@ -796,10 +799,13 @@ export const api = {
], ],
), ),
dispatchSchedule: endpoint<string, TrainScheduleDetail>( dispatchSchedule: endpoint<
{ id: string; payload?: DispatchSchedulePayload },
TrainScheduleDetail
>(
"train-scheduling", "train-scheduling",
"dispatch-schedule", "dispatch-schedule",
(id) => trainSchedulingService.dispatchSchedule(id), ({ id, payload }) => trainSchedulingService.dispatchSchedule(id, payload),
undefined, undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),
@@ -917,6 +923,18 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),
updateCheckpoint: endpoint<
{ id: string; sequenceNo: number; payload: UpdateCheckpointPayload },
TrainTrackResponse
>(
"train-scheduling",
"update-checkpoint",
({ id, sequenceNo, payload }) =>
trainSchedulingService.updateCheckpoint(id, sequenceNo, payload),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
arriveSchedule: endpoint<string, TrainScheduleDetail>( arriveSchedule: endpoint<string, TrainScheduleDetail>(
"train-scheduling", "train-scheduling",
"arrive-schedule", "arrive-schedule",
@@ -3137,6 +3155,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

View File

@@ -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))

View File

@@ -28,6 +28,8 @@ import type {
LocomotiveRecord, LocomotiveRecord,
PinWagonsPayload, PinWagonsPayload,
RecordCheckpointPayload, RecordCheckpointPayload,
UpdateCheckpointPayload,
DispatchSchedulePayload,
StaffBookingWindow, StaffBookingWindow,
ScheduleMergePreview, ScheduleMergePreview,
TrainScheduleDetail, TrainScheduleDetail,
@@ -524,10 +526,11 @@ export const trainSchedulingService = {
dispatchSchedule: async ( dispatchSchedule: async (
scheduleId: string, scheduleId: string,
payload: DispatchSchedulePayload = {},
): Promise<TrainScheduleDetail> => { ): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>( const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId), URL_CONSTANTS.TRAIN_SCHEDULING.DISPATCH(scheduleId),
{}, payload,
); );
return unwrap(response.data); return unwrap(response.data);
}, },
@@ -696,6 +699,18 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
updateCheckpoint: async (
scheduleId: string,
sequenceNo: number,
payload: UpdateCheckpointPayload,
): Promise<TrainTrackResponse> => {
const response = await client.patch<TrainTrackResponse>(
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINT(scheduleId, sequenceNo),
payload,
);
return unwrap(response.data);
},
arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => { arriveSchedule: async (scheduleId: string): Promise<TrainScheduleDetail> => {
const response = await client.post<TrainScheduleDetail>( const response = await client.post<TrainScheduleDetail>(
URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId), URL_CONSTANTS.TRAIN_SCHEDULING.ARRIVE(scheduleId),

View File

@@ -26,6 +26,9 @@ export interface Wagon {
lengthMeters?: number; lengthMeters?: number;
} | null; } | null;
status: Freight.WagonStatus; status: Freight.WagonStatus;
/** Latest status-log flip to MAINTENANCE / to AVAILABLE (list endpoint only). */
lastMaintenanceAt?: string | null;
lastAvailableAt?: string | null;
currentYardId: string | null; currentYardId: string | null;
currentYard?: { id: string; label?: string; code?: string } | null; currentYard?: { id: string; label?: string; code?: string } | null;
/** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */ /** Odd EXPORT run (Ethiopia → Djibouti); null when the wagon is not on a run. */

View File

@@ -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>;

View File

@@ -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;
@@ -907,10 +910,22 @@ export interface TrainTrackResponse {
export interface RecordCheckpointPayload { export interface RecordCheckpointPayload {
sequenceNo: number; sequenceNo: number;
kind?: TrainCheckpointKind; kind?: TrainCheckpointKind;
/** When the train was at the station; defaults to now. Past OK, future rejected. */
occurredAt?: string; occurredAt?: string;
note?: string; note?: string;
} }
/** Edit an already-logged leg — pure correction, no side effects. */
export interface UpdateCheckpointPayload {
occurredAt?: string;
note?: string | null;
}
export interface DispatchSchedulePayload {
/** Actual departure; defaults to now. Past OK, future rejected. */
actualDepartureAt?: string;
}
export interface TrainScheduleFilters { export interface TrainScheduleFilters {
originStationId?: string; originStationId?: string;
destinationStationId?: string; destinationStationId?: string;

View File

@@ -423,7 +423,7 @@ export function AppLayout({
)} )}
{/* Search pill */} {/* Search pill */}
<Group {/* <Group
gap={8} gap={8}
align="center" align="center"
visibleFrom="sm" visibleFrom="sm"
@@ -441,7 +441,7 @@ export function AppLayout({
<Text size="sm" style={{ color: mutedColor, userSelect: "none" }}> <Text size="sm" style={{ color: mutedColor, userSelect: "none" }}>
Search shipments, bookings… Search shipments, bookings…
</Text> </Text>
</Group> </Group> */}
{/* Notifications */} {/* Notifications */}
<NotificationBellContainer /> <NotificationBellContainer />
@@ -549,7 +549,7 @@ export function AppLayout({
navigate("/bookings/new", { state: { fresh: true } }) navigate("/bookings/new", { state: { fresh: true } })
} }
> >
New Booking New Contract
</Menu.Item> </Menu.Item>
<Divider /> <Divider />
<Menu.Item <Menu.Item

View File

@@ -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,7 +221,9 @@ export function ReadonlyBookingView({
subtitle={ subtitle={
status === "REJECTED" status === "REJECTED"
? "This booking request has been rejected." ? "This booking request has been rejected."
: "This booking process has been terminated." : 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."
} }
reason={booking.latestChangeRequestNote} reason={booking.latestChangeRequestNote}
onRebook={canSelfRebook ? onRebook : undefined} onRebook={canSelfRebook ? onRebook : undefined}
@@ -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" &&

View File

@@ -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&apos;s paid the wagons to cancel. A per-wagon fee applies; once it&apos;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) => {

View File

@@ -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>

View File

@@ -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
@@ -706,7 +742,7 @@ export default function BookingsListPage() {
Track every cargo booking from draft to delivery. Track every cargo booking from draft to delivery.
</Text> </Text>
</Box> </Box>
<Button {/* <Button
component={Link} component={Link}
to="/contracts" to="/contracts"
color="edr-green" color="edr-green"
@@ -714,7 +750,7 @@ export default function BookingsListPage() {
leftSection={<Plus size={16} />} leftSection={<Plus size={16} />}
> >
New booking New booking
</Button> </Button> */}
</Group> </Group>
{/* ── Summary stat cards ──────────────────────────────────────── */} {/* ── Summary stat cards ──────────────────────────────────────── */}

View File

@@ -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>
</>
);
}

View File

@@ -114,6 +114,17 @@ export default function ShippingLineBookingsPage() {
); );
}, },
}, },
{
id: "scheduledDate",
header: () => <ColHeader label="Shipment date" />,
cell: ({ row }) => (
<Text fz={13} c={row.original.scheduledDate ? undefined : "edr-muted"}>
{row.original.scheduledDate
? new Date(row.original.scheduledDate).toLocaleDateString()
: "—"}
</Text>
),
},
{ {
id: "status", id: "status",
header: () => <ColHeader label="Status" />, header: () => <ColHeader label="Status" />,

View File

@@ -17,6 +17,7 @@ import {
Box, Box,
Button, Button,
Center, Center,
Divider,
FileButton, FileButton,
Group, Group,
List, List,
@@ -26,14 +27,15 @@ import {
Select, Select,
Stack, Stack,
Switch, Switch,
Table,
Text, Text,
TextInput, TextInput,
Textarea, Textarea,
ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { import {
AlertCircle, AlertCircle,
AlertTriangle,
CalendarDays, CalendarDays,
CheckCircle2, CheckCircle2,
ChevronLeft, ChevronLeft,
@@ -43,6 +45,7 @@ import {
MapPin, MapPin,
Package, Package,
PackageCheck, PackageCheck,
Receipt,
Snowflake, Snowflake,
Train, Train,
X, X,
@@ -55,6 +58,7 @@ import {
StepLabel, StepLabel,
fieldStyles, fieldStyles,
} from "../contracts/new-contract-form/shared"; } from "../contracts/new-contract-form/shared";
import { formatRateUnit } from "../contracts/new-contract-form/unit-rates";
import { import {
ShipmentFormInputValues, ShipmentFormInputValues,
ShipmentFormValues, ShipmentFormValues,
@@ -65,6 +69,7 @@ import {
downloadContainerImportTemplate, downloadContainerImportTemplate,
parseContainerExcel, parseContainerExcel,
} from "../contracts/new-shipment-form/container-excel"; } from "../contracts/new-shipment-form/container-excel";
import { formatAmount } from "../contracts/new-shipment-form/total";
import { import {
shippingLineBookingsService, shippingLineBookingsService,
type CompleteBookingContainerLine, type CompleteBookingContainerLine,
@@ -213,21 +218,21 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
: 0; : 0;
const hasOdd20ft = ft20Total % 2 === 1; const hasOdd20ft = ft20Total % 2 === 1;
// Two-step submit: the payload is priced first (authoritative quote, saved // Two-step submit, same shape as the customer shipment form: the confirm
// server-side with fresh rate snapshots on every preview), the shipping line // modal opens at once with the payload pending, the server prices it (and
// confirms the figure, and only then does the booking submit. // runs the 20ft pairing check) while the modal shows a loader, the shipping
// line confirms the figure, and only then does the booking submit.
const [pendingPayload, setPendingPayload] = const [pendingPayload, setPendingPayload] =
useState<CompleteShippingLineBookingPayload | null>(null); useState<CompleteShippingLineBookingPayload | null>(null);
const [quote, setQuote] = useState<ShippingLinePriceQuote | null>(null);
const previewMutation = useMutation({ const previewMutation = useMutation({
mutationFn: (payload: CompleteShippingLineBookingPayload) => mutationFn: (payload: CompleteShippingLineBookingPayload) =>
shippingLineBookingsService.pricePreview(booking.id, payload), shippingLineBookingsService.pricePreview(booking.id, payload),
onSuccess: (result, payload) => { // A pricing failure (no rate configured) closes the confirm dialog — the
setPendingPayload(payload); // error modal takes over with the server's message.
setQuote(result); onError: () => setPendingPayload(null),
},
}); });
const quote = previewMutation.data ?? null;
const submitMutation = useMutation({ const submitMutation = useMutation({
mutationFn: (payload: CompleteShippingLineBookingPayload) => mutationFn: (payload: CompleteShippingLineBookingPayload) =>
@@ -241,11 +246,17 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
// A submit failure (day filled up, window closed meanwhile) must not leave // A submit failure (day filled up, window closed meanwhile) must not leave
// a stale confirm dialog on screen — the error modal takes over. // a stale confirm dialog on screen — the error modal takes over.
onError: () => { onError: () => {
setQuote(null); previewMutation.reset();
setPendingPayload(null); setPendingPayload(null);
}, },
}); });
const closeConfirm = () => {
if (submitMutation.isPending) return;
previewMutation.reset();
setPendingPayload(null);
};
/** /**
* Map a container size to the configured container type: reefer type when * Map a container size to the configured container type: reefer type when
* any container on the line is refrigerated, standard type otherwise — * any container on the line is refrigerated, standard type otherwise —
@@ -313,11 +324,21 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
return; return;
} }
setPayloadError(null); setPayloadError(null);
// Price first — the confirm dialog opens when the quote arrives; the // Open the confirm dialog now and price into it; the booking submits only
// booking submits only after the shipping line confirms the figure. // after the shipping line confirms the figure.
setPendingPayload(payload);
previewMutation.reset();
previewMutation.mutate(payload); previewMutation.mutate(payload);
}); });
const handleConfirm = () => {
if (!pendingPayload || !quote) return;
// Guard: never let unresolved 20ft pairing errors submit — the server
// rejects them anyway; the disabled button just says so first.
if (quote.pairingErrors.length > 0) return;
submitMutation.mutate(pendingPayload);
};
const showValidationSummary = const showValidationSummary =
form.formState.isSubmitted && !form.formState.isValid; form.formState.isSubmitted && !form.formState.isValid;
@@ -446,112 +467,14 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
</Stack> </Stack>
</Modal> </Modal>
{/* Price confirmation: the quote just computed (and snapshotted) <PriceConfirmModal
server-side. Nothing submits until the figure is confirmed. */} opened={Boolean(pendingPayload)}
<Modal quote={quote}
opened={Boolean(quote)} quoteLoading={previewMutation.isPending}
onClose={() => { loading={submitMutation.isPending}
setQuote(null); onConfirm={handleConfirm}
setPendingPayload(null); onReject={closeConfirm}
}} />
centered
radius="md"
size="lg"
title={
<Group gap={8}>
<PackageCheck size={18} color="var(--mantine-color-teal-6)" />
<Text fw={700} fz={16}>
Confirm your booking price
</Text>
</Group>
}
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
>
{quote && (
<Stack gap="md">
<Box style={{ overflowX: "auto" }}>
<Table verticalSpacing="xs" horizontalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Charge</Table.Th>
<Table.Th ta="right">Qty</Table.Th>
<Table.Th ta="right">Amount</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{quote.lineItems.map((item, i) => (
<Table.Tr key={i}>
<Table.Td>
<Text size="sm">{item.description}</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" c="dimmed">
{item.quantity ?? 1}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
{Number(item.amount).toLocaleString()}{" "}
{item.currency}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Box>
<Group
justify="space-between"
p="sm"
style={{
borderRadius: 10,
background: "var(--mantine-color-teal-0)",
}}
>
<Text fw={700}>Total</Text>
<Text fw={800} fz={18}>
{Number(quote.totalAmount).toLocaleString()} {quote.currency}
</Text>
</Group>
<Text size="xs" c="dimmed">
The amount is charged to your credit account no payment is
due now. EDR bills your accumulated charges periodically.
</Text>
{quote.warnings.length > 0 && (
<Alert
color="yellow"
radius="md"
icon={<AlertCircle size={16} />}
>
{quote.warnings.join(" ")}
</Alert>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="default"
radius="md"
onClick={() => {
setQuote(null);
setPendingPayload(null);
}}
>
Go back
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<PackageCheck size={16} />}
loading={submitMutation.isPending}
onClick={() =>
pendingPayload && submitMutation.mutate(pendingPayload)
}
>
Confirm & book
</Button>
</Group>
</Stack>
)}
</Modal>
<Box flex={1} p="24px"> <Box flex={1} p="24px">
<Stack gap="lg" className="mx-auto max-w-4xl"> <Stack gap="lg" className="mx-auto max-w-4xl">
@@ -618,6 +541,225 @@ function CompleteBookingForm({ booking }: { booking: ShippingLineBooking }) {
); );
} }
/**
* Price confirmation — the customer shipment form's modal, one-to-one: opens
* the moment the form submits, shows a loader while the server prices and
* checks 20ft pairing, then the authoritative breakdown. Pairing violations
* hard-block confirm (the server rejects them on /complete too); overweight
* containers only warn — the surcharge is already inside the total.
*/
function PriceConfirmModal({
opened,
quote,
quoteLoading,
loading,
onConfirm,
onReject,
}: {
opened: boolean;
quote: ShippingLinePriceQuote | null;
quoteLoading: boolean;
loading: boolean;
onConfirm: () => void;
onReject: () => void;
}) {
const pairingErrors = quote?.pairingErrors ?? [];
const hasPairingBlock = pairingErrors.length > 0;
const overweightLines = quote?.overweightLines ?? [];
const overweightSurchargeAmount =
quote?.lineItems.find((li) => li.code === "OVERWEIGHT_PER_TON")?.amount ??
0;
// Confirm waits for the authoritative price and a clean pairing check.
const confirmDisabled =
loading || quoteLoading || hasPairingBlock || !quote;
return (
<Modal
opened={opened}
onClose={onReject}
closeOnClickOutside={!loading}
closeOnEscape={!loading}
withCloseButton={!loading}
centered
radius="lg"
size="lg"
title={
<Group gap={10}>
<ThemeIcon variant="light" color="edr-green" radius="md" size={34}>
<Receipt size={18} />
</ThemeIcon>
<Box>
<Text fw={800} fz={16} c="#10202F">
Confirm shipment price
</Text>
<Text fz="xs" c="dimmed">
Review the total before booking this shipment.
</Text>
</Box>
</Group>
}
>
<Stack gap="md">
{quoteLoading && (
<Group gap={8} c="dimmed">
<Loader size="xs" color="edr-green" />
<Text fz="sm" c="dimmed">
Computing the final price breakdown and checking container
weights
</Text>
</Group>
)}
{hasPairingBlock && (
<Alert
color="red"
variant="light"
radius="md"
icon={<AlertCircle size={16} />}
title="Cannot complete booking — 20ft wagon pairing"
>
<Stack gap={6}>
{pairingErrors.map((msg, i) => (
<Text key={i} fz="sm" c="red.8">
{msg}
</Text>
))}
<Text fz="xs" c="red.7" mt={2}>
Adjust the 20ft container weights or quantities so pairs differ
by no more than 10 tons.
</Text>
</Stack>
</Alert>
)}
{overweightLines.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
title="Overweight containers"
>
<Stack gap={6}>
{overweightLines.map((line, i) => (
<Text key={i} fz="sm" c="#9A5B00">
{line.containerTypeCode}: {line.totalVgmTons}t exceeds limit{" "}
{line.maxAllowedTons}t (+{line.excessTons}t overweight)
</Text>
))}
<Text fz="xs" c="#9A5B00" mt={2}>
{overweightSurchargeAmount > 0
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
quote?.currency ?? ""
} applies (included in the total below). You can still submit, or go back and adjust weights.`
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
</Text>
</Stack>
</Alert>
)}
{quote && quote.warnings.length > 0 && (
<Alert
color="yellow"
variant="light"
radius="md"
icon={<AlertTriangle size={16} />}
>
{quote.warnings.join(" ")}
</Alert>
)}
{quote && (
<Paper
withBorder
radius={16}
p="lg"
style={{ borderColor: "#E6ECF2" }}
>
<Stack gap={10}>
{quote.lineItems.map((line, i) => (
<Group key={i} justify="space-between" wrap="nowrap" gap="sm">
<Box style={{ minWidth: 0 }}>
<Text fz="sm" c="#10202F" fw={500}>
{line.description}
</Text>
<Text fz="xs" c="dimmed">
{(line.quantity ?? 1).toLocaleString()} ×{" "}
{formatAmount(line.unitAmount ?? line.amount)}{" "}
{quote.currency}
{line.unit
? ` · ${formatRateUnit(line.unit.toLowerCase())}`
: ""}
</Text>
</Box>
<Text
fz="sm"
fw={600}
c="#10202F"
style={{ whiteSpace: "nowrap" }}
>
{formatAmount(line.amount)} {quote.currency}
</Text>
</Group>
))}
{quote.lineItems.length === 0 && (
<Text fz="sm" c="dimmed">
No priced lines check the cargo details.
</Text>
)}
</Stack>
<Divider my="md" />
<Group justify="space-between" align="flex-end">
<Text
fz="xs"
fw={700}
tt="uppercase"
c="edr-green"
style={{ letterSpacing: "0.06em" }}
>
Total
</Text>
<Text fw={800} fz={28} c="#10202F">
{formatAmount(quote.totalAmount)}{" "}
<Text span fz={16} fw={700} c="edr-muted">
{quote.currency}
</Text>
</Text>
</Group>
<Text fz="xs" c="dimmed" mt="sm">
The amount is charged to your credit account no payment is due
now. EDR bills your accumulated charges periodically.
</Text>
</Paper>
)}
<Group justify="space-between" mt="xs">
<Button
variant="default"
radius="md"
leftSection={<X size={16} />}
onClick={onReject}
disabled={loading}
>
Reject &amp; edit
</Button>
<Button
color="edr-green"
radius="md"
leftSection={<CheckCircle2 size={16} />}
onClick={onConfirm}
loading={loading}
disabled={confirmDisabled}
>
Confirm &amp; book
</Button>
</Group>
</Stack>
</Modal>
);
}
/** The booking's lane — fixed at initiate time from the chosen route. */ /** The booking's lane — fixed at initiate time from the chosen route. */
function RouteCard({ booking }: { booking: ShippingLineBooking }) { function RouteCard({ booking }: { booking: ShippingLineBooking }) {
return ( return (

View File

@@ -67,6 +67,15 @@ export interface ShippingLinePriceQuote {
currency: string; currency: string;
lineItems: Freight.PricingBreakdownLineItem[]; lineItems: Freight.PricingBreakdownLineItem[];
warnings: string[]; warnings: string[];
/** Containers over their type's weight limit — a surcharge, not a block. */
overweightLines: {
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}[];
/** 20ft wagon-pairing violations (pair weight diff over the cap) — hard block. */
pairingErrors: string[];
} }
/** One wagon the batch engine allocated to the booking. */ /** One wagon the batch engine allocated to the booking. */

View File

@@ -959,6 +959,14 @@ export interface IInvoiceCompanyProfile {
reference: string | null; reference: string | null;
} }
/** Shipping line an invoice is billed to, when `companyId` is null (see `IInvoice`). */
export interface IInvoiceShippingLineCompany {
id: string;
name: string;
email?: string | null;
phoneNumber?: string | null;
}
/** A single billed line on an invoice. */ /** A single billed line on an invoice. */
export interface IInvoiceLine extends BaseEntity { export interface IInvoiceLine extends BaseEntity {
invoiceId: string; invoiceId: string;
@@ -975,12 +983,15 @@ export interface IInvoiceLine extends BaseEntity {
export interface IInvoice extends BaseEntity { export interface IInvoice extends BaseEntity {
invoiceNumber: string; invoiceNumber: string;
/** Customer (company) the invoice is billed to. */ /** Customer (company) the invoice is billed to. Null on a shipping-line invoice — see `shippingLineCompanyId`. */
companyId: string; companyId: string | null;
company?: IInvoiceCompany; company?: IInvoiceCompany;
/** Specific company profile billed. */ /** Specific company profile billed. */
companyProfileId: string; companyProfileId: string;
companyProfile?: IInvoiceCompanyProfile; companyProfile?: IInvoiceCompanyProfile;
/** The shipping line billed, when this invoice bills batched shipping-line credits. Mutually exclusive with `companyId`. */
shippingLineCompanyId?: string | null;
shippingLineCompany?: IInvoiceShippingLineCompany;
totalAmount: number; totalAmount: number;
/** Cumulative amount settled so far (supports partial payment). */ /** Cumulative amount settled so far (supports partial payment). */
paidAmount: number; paidAmount: number;