From c9c2d4dcb3855576558166d68cc0e82422719105 Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 15 Aug 2026 08:53:24 +0000 Subject: [PATCH 1/2] booking cancellation --- apps/edr-freight-api/.q.mjs | 9 + .../src/modules/billing/billing.controller.ts | 17 ++ .../src/modules/billing/billing.service.ts | 90 +++++-- ...booking-wagon-cancellation.service.spec.ts | 42 ++++ .../booking-wagon-cancellation.service.ts | 225 ++++++++++++++---- .../rule-engine/dto/create-rate.dto.ts | 2 +- .../entities/rate-unit.util.spec.ts | 16 ++ .../rule-engine/entities/rate-unit.util.ts | 9 +- .../rule-engine/services/rates.service.ts | 78 +++--- .../services/train-scheduling.service.ts | 45 +++- .../ruleEngine/RuleEngineFormDialog.tsx | 2 +- .../backoffice/src/constants/QUERY_KEYS.ts | 2 + .../backoffice/src/constants/URLS.ts | 1 + .../src/pages/invoices/FinanceHubPage.tsx | 22 +- .../src/pages/invoices/InvoicesPage.tsx | 57 ++++- .../src/pages/ruleEngine/config/resources.ts | 47 ++-- .../TrainScheduleV2ListPage.tsx | 23 +- .../backoffice/src/services/api.ts | 11 + .../src/services/invoices.service.ts | 12 + .../backoffice/src/types/invoice.ts | 3 + .../backoffice/src/types/trainScheduling.ts | 3 + .../BookingDetailPage/ReadonlyBookingView.tsx | 9 +- .../components/WagonCancellationCard.tsx | 30 ++- .../components/WagonsTab.tsx | 11 +- .../src/pages/bookings/BookingsListPage.tsx | 40 +++- .../src/pages/bookings/RebookWagonsButton.tsx | 108 +++++++++ 26 files changed, 751 insertions(+), 163 deletions(-) create mode 100644 apps/edr-freight-api/.q.mjs create mode 100644 apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts create mode 100644 apps/edr-freight-web/portal/src/pages/bookings/RebookWagonsButton.tsx diff --git a/apps/edr-freight-api/.q.mjs b/apps/edr-freight-api/.q.mjs new file mode 100644 index 000000000..b2b454075 --- /dev/null +++ b/apps/edr-freight-api/.q.mjs @@ -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(); diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index 5bf1448fd..ad9bbe713 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -63,6 +63,23 @@ export class BillingController { }); } + @Get("invoices/summary") + @ApiOperation({ + summary: + "Total collected (paidAmount) across every filtered invoice, grouped by currency", + }) + async collectedSummary( + @Query() query: FilterInvoiceDto, + @CurrentUser() user: TCurrentUser, + ) { + const allowed = + await this.userTradeAccessService.resolveAllowedDirections(user); + return this.billingService.collectedSummary({ + ...query, + tradeDirections: allowed ?? undefined, + }); + } + @Get("invoices/:id") @ApiOperation({ summary: "Get an invoice with its line items" }) findById(@Param("id", ParseUUIDPipe) id: string) { diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 5afa30128..78b210a61 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -10,7 +10,7 @@ import { } from "@nestjs/common"; import { EventEmitter2 } from "@nestjs/event-emitter"; import { logCtx } from "@edr/api-common"; -import { DataSource, EntityManager, In } from "typeorm"; +import { DataSource, EntityManager, In, SelectQueryBuilder } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; // Entity-only import (no module edge): portal reads resolve shipping-line @@ -192,6 +192,40 @@ export class BillingService { * company (customer detail "Invoices" tab) and/or status/search (global * invoices page). */ + /** Same list filters `findAllPaginated` and `collectedSummary` both narrow by. */ + private applyInvoiceFilters( + qb: SelectQueryBuilder, + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + tradeDirections?: string[]; + }, + ) { + if (filter.companyId) { + qb.andWhere("invoice.companyId = :companyId", { + companyId: filter.companyId, + }); + } + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + if (filter.tradeDirections) { + applyBookingRefDirectionScope( + qb, + "invoice.source_id", + filter.tradeDirections, + ); + } + return qb; + } + async findAllPaginated( filter: { companyId?: string; @@ -215,33 +249,43 @@ export class BillingService { .skip((page - 1) * pageSize) .take(pageSize); - if (filter.companyId) { - qb.andWhere("invoice.companyId = :companyId", { - companyId: filter.companyId, - }); - } - if (filter.status) { - qb.andWhere("invoice.status = :status", { status: filter.status }); - } - if (filter.search) { - qb.andWhere( - "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", - { search: `%${filter.search}%` }, - ); - } - - if (filter.tradeDirections) { - applyBookingRefDirectionScope( - qb, - "invoice.source_id", - filter.tradeDirections, - ); - } + this.applyInvoiceFilters(qb, filter); const [items, total] = await qb.getManyAndCount(); return { items, total }; } + /** + * Total collected (`paidAmount`) across every invoice matching the same + * filters as `findAllPaginated`, grouped by currency — unpaginated, so the + * invoices summary card reflects the whole filtered set, not just the + * visible page. + */ + async collectedSummary( + filter: { + companyId?: string; + status?: Freight.InvoiceStatus; + search?: string; + tradeDirections?: string[]; + } = {}, + ): Promise> { + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .select("invoice.currency", "currency") + .addSelect("SUM(invoice.paidAmount)", "collected") + .groupBy("invoice.currency"); + + this.applyInvoiceFilters(qb, filter); + + const rows: { currency: string; collected: string }[] = + await qb.getRawMany(); + + return Object.fromEntries( + rows.map((row) => [row.currency, Number(row.collected) || 0]), + ); + } + /** * Finance's offline-settlement worklist: USD invoices (paid by bank transfer, * never through the gateway), open ones by default or a single status when diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts new file mode 100644 index 000000000..8babc8c2e --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.spec.ts @@ -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); + }); +}); diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index b62fdfca0..418fbd5c6 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -7,8 +7,9 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import { ExchangeService } from '@edr/api-common'; import { Freight, NotificationAudience, NotificationType } from '@edr/types'; -import { DataSource, EntityManager, In } from 'typeorm'; +import { DataSource, EntityManager, In, IsNull } from 'typeorm'; import { BillingService } from '../billing/billing.service'; import { ContractBookingService } from '../contracts/contract-booking.service'; @@ -18,9 +19,11 @@ import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.en import { FirstMileService } from '../first-mile/first-mile.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; +import { ContainerType } from '../rule-engine/entities/container-type.entity'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; +import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; @@ -46,8 +49,9 @@ import { /** * rates.rate_type of the cancellation fee — an existing rate-engine type * (trigger CANCELLATION, never auto-applied to booking pricing). Staff - * configure it in the normal rates UI; the wagon flow requires the PER_WAGON - * unit so the fee scales with the cancelled wagon count. + * configure it in the normal rates UI, one PER_WAGON rate per trade direction + * + cargo kind + type (20ft / 40ft container type, or bulk commodity), so the + * fee scales with the cancelled wagon count and differs by what was booked. */ export const WAGON_CANCELLATION_FEE_RATE_TYPE = 'CANCELLATION_FEE'; /** invoices.type of the fee invoice — the settlement branch key in BookingInvoiceService. */ @@ -62,8 +66,20 @@ interface RequestedCut { quantities: CancelledQuantities; } +/** The priced fee for a cut: total, currency and the rate(s) it came from. */ +interface PricedFee { + amount: number; + currency: string; + /** Effective per-wagon fee (amount / wagons) — one number for the customer. */ + perWagon: number; + /** Rate rows used; the first is recorded on the ledger row. */ + rates: Rate[]; +} + /** - * Partial wagon cancellation on a PAID booking, with a rebooking credit. + * Wagon cancellation on a PAID booking (partial or whole), with a rebooking + * credit. Cutting every wagon ends the source booking CANCELLED at T2; the + * credit then rebooks as a fresh booking under the same contract. * * Lifecycle (one ledger row per cycle, see BookingWagonCancellation): * T1 request — validate + price the fee, open the fee invoice. Nothing else @@ -91,6 +107,7 @@ export class BookingWagonCancellationService { private readonly repo: BookingWagonCancellationsRepository, private readonly bookingsRepository: BookingsRepository, private readonly billing: BillingService, + private readonly exchangeService: ExchangeService, @Inject(forwardRef(() => ContractBookingService)) private readonly contractBooking: ContractBookingService, @Inject(forwardRef(() => ClearanceMilestoneService)) @@ -120,14 +137,13 @@ export class BookingWagonCancellationService { }> { const booking = await this.loadCancellableBooking(bookingId); const cut = await this.resolveRequestedCut(booking, dto); - const rate = await this.feeRate(); - const feeAmount = round2(Number(rate.rateValue) * cut.wagons); + const fee = await this.priceFee(booking, cut); return { wagons: cut.wagons, weightTons: cut.weightTons, - feePerWagon: Number(rate.rateValue), - feeAmount, - feeCurrency: rate.currency, + feePerWagon: fee.perWagon, + feeAmount: fee.amount, + feeCurrency: fee.currency, creditAmount: this.creditFor(booking, cut.wagons), }; } @@ -146,8 +162,8 @@ export class BookingWagonCancellationService { } const cut = await this.resolveRequestedCut(booking, dto); - const rate = await this.feeRate(); - const feeAmount = round2(Number(rate.rateValue) * cut.wagons); + const fee = await this.priceFee(booking, cut); + const feeAmount = fee.amount; const creditAmount = this.creditFor(booking, cut.wagons); const row = await this.repo.create({ @@ -156,9 +172,11 @@ export class BookingWagonCancellationService { weightTons: cut.weightTons, cancelledQuantities: cut.quantities, creditAmount, - feeRateId: rate.id, + // ponytail: one FK for a mixed-size container cut records the first + // size's rate; the invoice line carries the effective per-wagon fee. + feeRateId: fee.rates[0].id, feeAmount, - feeCurrency: rate.currency, + feeCurrency: fee.currency, status: 'FEE_PENDING', reason: dto.reason ?? null, requestedByUserId: userId ?? null, @@ -173,15 +191,15 @@ export class BookingWagonCancellationService { type: WAGON_CANCEL_FEE_INVOICE_TYPE, companyId: booking.companyId, companyProfileId: booking.companyProfileId, - currency: rate.currency, + currency: fee.currency, lines: [ { chargeType: 'CANCELLATION_FEE', description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference}`, quantity: cut.wagons, - unitRate: Number(rate.rateValue), + unitRate: fee.perWagon, amount: feeAmount, - currency: rate.currency, + currency: fee.currency, metadata: { wagonCancellationId: row.id }, }, ], @@ -343,12 +361,28 @@ export class BookingWagonCancellationService { const preSplitQuantities = booking.preSplitQuantities ?? (await this.currentQuantities(manager, booking, droppedWeight)); + // Whole-booking cut: nothing is left to ship, so the booking ends + // CANCELLED (frees the contract slot/cap for the rebook) and drops off its + // train. The credit row still points at it for T3. + const wagonsLeft = round2( + Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled), + ); + const isFull = wagonsLeft <= 0; await manager.getRepository(Booking).update(booking.id, { - wagonsRequired: round2(Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled)), - cargoTotalWeightVgm: round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), - totalAmount: round2(Number(booking.totalAmount) - Number(row.creditAmount)), + wagonsRequired: Math.max(0, wagonsLeft), + cargoTotalWeightVgm: Math.max( + 0, + round3(Number(booking.cargoTotalWeightVgm) - droppedWeight), + ), + totalAmount: Math.max( + 0, + round2(Number(booking.totalAmount) - Number(row.creditAmount)), + ), isSplit: true, preSplitQuantities, + ...(isFull + ? { status: 'CANCELLED', trainScheduleId: null, requestedTrainScheduleId: null } + : {}), } as never); await manager.getRepository(BookingWagonCancellation).update(row.id, { @@ -360,11 +394,15 @@ export class BookingWagonCancellationService { }); const booking = await this.bookingsRepository.findById(row.bookingId); + if (booking?.status === 'CANCELLED') await this.detachFromSchedule(booking); if (booking) { + const whole = booking.status === 'CANCELLED'; this.notifyCustomer( booking, - 'Wagon cancellation confirmed', - `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, + whole ? 'Booking cancelled — credit available' : 'Wagon cancellation confirmed', + whole + ? `All wagons of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.` + : `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook any day while your contract is valid.`, ); } this.logger.log( @@ -372,6 +410,33 @@ export class BookingWagonCancellationService { ); } + /** + * Whole-booking cut: take the cancelled booking OFF its train entirely — + * schedule link, leftover wagon slots, window status — via the ops unassign + * path (no "removed from train" notice: the customer cancelled it). A stale + * link would keep showing the booking on the schedule AND poison every later + * auto wagon allocation on that train (the whole-train re-plan rejects a + * CANCELLED booking). Then re-run allocation so bookings held back by it + * (e.g. the rebooked credit) get their wagons. + */ + private async detachFromSchedule(booking: Booking): Promise { + const links = await this.dataSource + .getRepository(TrainScheduleBooking) + .find({ where: { bookingId: booking.id } }); + for (const link of links) { + try { + await this.trainScheduling.unassignBooking(link.trainScheduleId, booking.id, undefined, { + notifyCustomer: false, + }); + await this.trainScheduling.tryAutoWagonAllocation(link.trainScheduleId); + } catch (err) { + this.logger.error( + `Detach of cancelled booking ${booking.reference} from schedule ${link.trainScheduleId} failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + // ── T3: rebook ────────────────────────────────────────────────────────────── async rebook( @@ -401,6 +466,8 @@ export class BookingWagonCancellationService { } const createDto = this.buildRebookDto(row, dto.scheduledDate); + // Same currency as the source booking — the credit is in it. + createDto.paymentCurrency = source.paymentCurrency ?? undefined; const created = await this.contractBooking.createUnderContract( source.contractId, createDto, @@ -413,9 +480,13 @@ export class BookingWagonCancellationService { // The freight is already paid (credit) — mark PAID and let the existing // paid-booking machinery place it. No invoice is generated for it. + // Its price IS the credit (already paid, in the source currency) — not a + // fresh live-rate quote; a later cut of the rebooked booking credits from it. await this.dataSource.getRepository(Booking).update(newBookingId, { paymentStatus: 'PAID', status: 'PAID', + totalAmount: Number(row.creditAmount), + paymentCurrency: source.paymentCurrency, }); await this.copyClearanceState(source, newBookingId); @@ -539,9 +610,9 @@ export class BookingWagonCancellationService { wagons += cut.quantity * wagonsPerUnitForSize(Number(cut.containerSize)); } wagons = round2(wagons); - if (wagons >= totalWagons) { + if (wagons > totalWagons) { throw new BadRequestException( - 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } // Snapshot the LIFO-picked physical units up front (read-only — cargo is @@ -577,9 +648,11 @@ export class BookingWagonCancellationService { } } } - const weightShare = round3( - Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons), - ); + // Whole-booking cut takes the exact total, no ratio rounding. + const weightShare = + wagons >= totalWagons + ? round3(Number(booking.cargoTotalWeightVgm)) + : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); return { wagons, weightTons: weightShare, @@ -594,17 +667,19 @@ export class BookingWagonCancellationService { if (!wagons || wagons <= 0) { throw new BadRequestException('Specify how many wagons to cancel.'); } - if (wagons >= totalWagons) { + if (wagons > totalWagons) { throw new BadRequestException( - 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } + // Whole-booking cut: all cargo, exactly. Otherwise proportional sizing. // ponytail: proportional sizing (tons/wagon = total/wagons). PER_ITEM item // rounding happens here too; switch to items_per_wagon_map sizing if bulk // PER_ITEM cancels ever need to be exact per item. - let tons = Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons); + const isFull = wagons >= totalWagons; + let tons = Number(booking.cargoTotalWeightVgm) * (isFull ? 1 : wagons / totalWagons); const isPerItem = booking.bulkTotalWeightTons != null; - tons = isPerItem ? Math.floor(tons) : round3(tons); + tons = isPerItem && !isFull ? Math.floor(tons) : round3(tons); if (tons <= 0) { throw new BadRequestException('The requested cut is too small to release cargo.'); } @@ -641,19 +716,23 @@ export class BookingWagonCancellationService { } const wagons = allocations.length; - if (wagons >= totalWagons) { + if (wagons > totalWagons) { throw new BadRequestException( - 'That would cancel the whole booking — use booking cancellation instead of a partial wagon cancel.', + `Cannot cancel ${wagons} wagon(s) — the booking only has ${totalWagons}.`, ); } + const isFull = wagons >= totalWagons; if (booking.freightType !== 'CONTAINER') { const allocated = allocations.reduce( (s, a) => s + Number(a.allocatedWeightTons || 0), 0, ); - const tons = - allocated > 0 + // Whole-booking cut takes the exact total; partial takes the wagons' + // allocated tonnage (ratio fallback when nothing is allocated yet). + const tons = isFull + ? round3(Number(booking.cargoTotalWeightVgm)) + : allocated > 0 ? round3(allocated) : round3(Number(booking.cargoTotalWeightVgm) * (wagons / totalWagons)); return { @@ -714,21 +793,83 @@ export class BookingWagonCancellationService { return round2(Number(booking.totalAmount) * (wagons / totalWagons)); } - private async feeRate(): Promise { - 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 { + 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 { + const rates = await this.dataSource.getRepository(Rate).find({ where: { rateType: WAGON_CANCELLATION_FEE_RATE_TYPE, rateUnit: 'PER_WAGON', status: 'LIVE', + tradeDirection: booking.tradeDirection, + shippingLineCompanyId: booking.shippingLineCompanyId ?? IsNull(), }, order: { createdAt: 'DESC' }, }); - if (!rate) { - throw new BadRequestException( - 'No LIVE per-wagon CANCELLATION_FEE rate is configured — ask EDR to set it in the rate engine (unit PER_WAGON).', + const missing = (scope: string): BadRequestException => + new BadRequestException( + `No LIVE per-wagon cancellation fee is configured for ${scope} on ${booking.tradeDirection} — ask EDR to set it in the rate engine (surcharge: Cancellation).`, ); + + if (booking.freightType !== 'CONTAINER') { + const rate = rates.find( + (r) => !r.containerTypeId && !!r.cargoTypeId && r.cargoTypeId === booking.cargoTypeId, + ); + if (!rate) throw missing(`bulk cargo type ${booking.cargoType?.cargoTypeName ?? booking.cargoTypeId ?? '?'}`); + const amount = round2(Number(rate.rateValue) * cut.wagons); + return { amount, currency: rate.currency, perWagon: Number(rate.rateValue), rates: [rate] }; } - return rate; + + // Container: split the cancelled wagons across sizes in proportion to the + // wagon-space each size's units occupy, so the total always equals + // cut.wagons (whole wagons on an allocation cut, fractional on a quantity cut). + const bySize = Object.entries(cut.quantities.bySize ?? {}).filter(([, qty]) => qty > 0); + const spaceOf = ([size, qty]: [string, number]) => qty * wagonsPerUnitForSize(Number(size)); + const totalSpace = bySize.reduce((s, e) => s + spaceOf(e), 0); + if (!bySize.length || totalSpace <= 0) throw missing('containers'); + const containerTypes = await this.dataSource.getRepository(ContainerType).find(); + const used: Rate[] = []; + let amount = 0; + let currency = ''; + for (const entry of bySize) { + const [size] = entry; + const sizeFt = Number(size); + const typeIds = new Set( + containerTypes.filter((ct) => Number(ct.sizeFt) === sizeFt).map((ct) => ct.id), + ); + const rate = rates.find((r) => !!r.containerTypeId && typeIds.has(r.containerTypeId)); + if (!rate) throw missing(`${sizeFt || '?'}ft containers`); + currency = rate.currency; + used.push(rate); + amount += Number(rate.rateValue) * cut.wagons * (spaceOf(entry) / totalSpace); + } + amount = round2(amount); + return { amount, currency, perWagon: round2(amount / cut.wagons), rates: used }; } /** @@ -902,9 +1043,9 @@ export class BookingWagonCancellationService { booking: Booking, tons: number, ): Promise { - if (tons >= Number(booking.cargoTotalWeightVgm)) { + if (tons > Number(booking.cargoTotalWeightVgm)) { throw new BadRequestException( - 'Booking changed since the request: the cut no longer leaves any cargo.', + 'Booking changed since the request: the cut exceeds the cargo left on the booking.', ); } if (booking.bulkTotalWeightTons != null) { diff --git a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts index 73ae42a0d..bca8ab278 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/dto/create-rate.dto.ts @@ -53,7 +53,7 @@ export class CreateRateDto { @ApiPropertyOptional({ enum: CARGO_KINDS, description: - 'Whether a customs clearance rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE. Not stored — container fees carry a containerTypeId, bulk fees none.', + 'Whether a customs clearance / cancellation rate covers containers or bulk. Required when trigger = CUSTOMS_CLEARANCE or CANCELLATION. Not stored — container fees carry a containerTypeId, bulk fees a cargoTypeId.', }) @IsOptional() @IsIn([...CARGO_KINDS]) diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts index e44dcdb5f..39d6174de 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.spec.ts @@ -61,6 +61,22 @@ describe("allowedRateUnits — bulk unit of measure", () => { ).toEqual(["PER_TON"]); }); + it("bills the wagon cancellation fee per wagon only, whatever the cargo kind", () => { + for (const cargoKind of ["CONTAINER", "BULK"] as const) { + expect( + allowedRateUnits({ appliesTo: "OTHER", trigger: "CANCELLATION", cargoKind }), + ).toEqual(["PER_WAGON"]); + } + expect( + allowedRateUnits({ + appliesTo: "OTHER", + trigger: "CANCELLATION", + cargoKind: "BULK", + cargoUnitOfMeasure: "PER_ITEM", + }), + ).toEqual(["PER_WAGON"]); + }); + it("treats per-ton and per-item as the same booking quantity", () => { expect(isBulkQuantityUnit("PER_TON")).toBe(true); expect(isBulkQuantityUnit("PER_ITEM")).toBe(true); diff --git a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts index 3d5b1401e..5adf225ca 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/entities/rate-unit.util.ts @@ -16,8 +16,7 @@ export const isBulkQuantityUnit = (unit: string): boolean => * Which rate units make sense for a given rate shape. The weighting basis is * driven by the *type* of thing being billed — a container leg bills per * container, bulk freight per ton, an intercity move can be per-km, a - * cancellation is a flat/per-invoice fee, and overweight is always per excess - * ton. This keeps the rate table dynamic yet non-conflicting: the admin can + * cancellation is a per-wagon fee, and overweight is always per excess ton. This keeps the rate table dynamic yet non-conflicting: the admin can * only pick a unit the pricing engine knows how to apply. * * A rate scoped to a break-bulk commodity (unit_of_measure = PER_ITEM) offers @@ -29,7 +28,7 @@ export const isBulkQuantityUnit = (unit: string): boolean => export function allowedRateUnits(input: { appliesTo: RateAppliesTo; trigger: RateTrigger; - /** CUSTOMS_CLEARANCE only: which cargo kind the fee covers. */ + /** CUSTOMS_CLEARANCE / CANCELLATION only: which cargo kind the fee covers. */ cargoKind?: 'CONTAINER' | 'BULK' | null; /** Unit of measure of the bulk commodity the rate is scoped to, when any. */ cargoUnitOfMeasure?: CargoUom; @@ -64,7 +63,9 @@ function unitsForShape(input: { // wagon the empties ride back on, or a flat fee. return ['PER_CONTAINER', 'PER_WAGON', 'FLAT']; case 'CANCELLATION': - return ['FLAT', 'PER_INVOICE']; + // Wagon cancellation fee — scales with the cancelled wagon count, so + // per wagon is the only unit the wagon-cancel flow can apply. + return ['PER_WAGON']; case 'CUSTOMS_CLEARANCE': // Sold per cargo kind: container fees bill per box or per wagon, bulk // fees per ton or per wagon. Billed on the booking invoice. diff --git a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts index 1902a757a..977a31718 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/services/rates.service.ts @@ -25,6 +25,19 @@ import { IYardsRepository, YARDS_REPOSITORY } from '../interfaces/yards.reposito /** Categories priced per rail leg — they carry an origin → destination yard pair. */ const BASE_FREIGHT_CATEGORIES: readonly Rate['appliesTo'][] = ['BULK', 'CONTAINER', 'INTERCITY']; +/** + * Surcharges sold per cargo kind: the admin says container or bulk, a + * container fee then names its container type and a bulk fee its commodity. + */ +const CARGO_KIND_TRIGGERS: readonly Rate['trigger'][] = ['CUSTOMS_CLEARANCE', 'CANCELLATION']; +/** Surcharges that keep a trade direction (everything else is direction-agnostic). */ +const DIRECTED_SURCHARGE_TRIGGERS: readonly Rate['trigger'][] = [ + 'CUSTOMS_CLEARANCE', + 'CANCELLATION', + 'WITH_RETURN', + 'LASHING', + 'FUEL', +]; /** The yard pair a rate scopes to, already validated against its direction. */ interface YardScope { @@ -152,7 +165,11 @@ export class RatesService { appliesTo: Rate['appliesTo'], trigger: Rate['trigger'], ): boolean { - return this.isRouteScoped(appliesTo, trigger) || trigger === 'LASHING'; + return ( + this.isRouteScoped(appliesTo, trigger) || + trigger === 'LASHING' || + trigger === 'CANCELLATION' + ); } /** @@ -244,10 +261,13 @@ export class RatesService { }): void { const { appliesTo, trigger, tradeDirection, intercityKind, cargoKind } = input; const { containerTypeId, cargoTypeId } = input; - if (trigger === 'CUSTOMS_CLEARANCE') { + if (trigger === 'CUSTOMS_CLEARANCE' || trigger === 'CANCELLATION') { + // Both fees are sold per direction + cargo kind + type: customs clearance + // per lane, the wagon cancellation fee per direction only. + const fee = trigger === 'CANCELLATION' ? 'cancellation fee' : 'customs clearance'; if (tradeDirection !== 'IMPORT' && tradeDirection !== 'EXPORT') { throw new BadRequestException( - 'A customs clearance rate must say whether it covers IMPORT or EXPORT.', + `A ${fee} rate must say whether it covers IMPORT or EXPORT.`, ); } // Sold per cargo kind: a container fee names the container type it covers @@ -255,29 +275,29 @@ export class RatesService { // that absence is what marks it as the bulk fee. if (cargoKind !== 'CONTAINER' && cargoKind !== 'BULK') { throw new BadRequestException( - 'A customs clearance rate must say whether it covers containers or bulk.', + `A ${fee} rate must say whether it covers containers or bulk.`, ); } if (cargoKind === 'CONTAINER' && !containerTypeId) { throw new BadRequestException( - 'A container customs clearance rate must name the container type it covers.', + `A container ${fee} rate must name the container type it covers.`, ); } if (cargoKind === 'BULK' && containerTypeId) { throw new BadRequestException( - 'A bulk customs clearance rate cannot be scoped to a container type.', + `A bulk ${fee} rate cannot be scoped to a container type.`, ); } - // The bulk customs fee names the commodity it covers (sugar and - // fertilizer clear differently). + // The bulk fee names the commodity it covers (sugar and fertilizer + // clear — and cancel — differently). if (cargoKind === 'BULK' && !cargoTypeId) { throw new BadRequestException( - 'A bulk customs clearance rate must name the bulk cargo type it covers.', + `A bulk ${fee} rate must name the bulk cargo type it covers.`, ); } if (cargoKind === 'CONTAINER' && cargoTypeId) { throw new BadRequestException( - 'A container customs clearance rate cannot be scoped to a bulk cargo type.', + `A container ${fee} rate cannot be scoped to a bulk cargo type.`, ); } return; @@ -547,22 +567,21 @@ export class RatesService { const trigger = dto.trigger as Rate['trigger']; // Surcharges (trigger ≠ ALWAYS) carry no direction/scope — clear them so // the engine never accidentally narrows a surcharge by container/direction. - // Exceptions: customs clearance and empty-container return keep direction + - // container type — both are sold per lane (and per container type). + // Exceptions: the directed surcharges (customs clearance, cancellation, + // empty-container return, lashing, fuel) keep direction + cargo scope. const isSurcharge = trigger !== 'ALWAYS'; - const cargoKind = - trigger === 'CUSTOMS_CLEARANCE' - ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) - : null; + const cargoKind = CARGO_KIND_TRIGGERS.includes(trigger) + ? ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? null) + : null; const containerTypeId = trigger === 'WITH_RETURN' || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER') + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER') ? (dto.containerTypeId ?? null) : isSurcharge ? null : (dto.containerTypeId ?? null); const cargoTypeId = - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || trigger === 'LASHING' || trigger === 'FUEL' ? (dto.cargoTypeId ?? null) @@ -574,10 +593,7 @@ export class RatesService { // intercity lane is stored as DOMESTIC, since appliesTo = OTHER says // nothing about the direction.) const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || - trigger === 'WITH_RETURN' || - trigger === 'LASHING' || - trigger === 'FUEL' + DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) ? (dto.tradeDirection ?? null) : isSurcharge || appliesTo === 'INTERCITY' ? null @@ -758,16 +774,15 @@ export class RatesService { // A patch that leaves the cargo kind unsaid keeps the one the rate already // has — read back off its container scope (container fees carry the type). - const cargoKind = - trigger !== 'CUSTOMS_CLEARANCE' - ? null - : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? - (existing.containerTypeId ? 'CONTAINER' : 'BULK')); + const cargoKind = !CARGO_KIND_TRIGGERS.includes(trigger) + ? null + : ((dto.cargoKind as 'CONTAINER' | 'BULK' | undefined) ?? + (existing.containerTypeId ? 'CONTAINER' : 'BULK')); const keepsContainerType = !isSurcharge || trigger === 'WITH_RETURN' || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'CONTAINER'); + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'CONTAINER'); const containerTypeId = !keepsContainerType ? null : dto.containerTypeId !== undefined @@ -775,7 +790,7 @@ export class RatesService { : existing.containerTypeId; const keepsCargoType = !isSurcharge || - (trigger === 'CUSTOMS_CLEARANCE' && cargoKind === 'BULK') || + (CARGO_KIND_TRIGGERS.includes(trigger) && cargoKind === 'BULK') || trigger === 'LASHING' || trigger === 'FUEL'; const cargoTypeId = !keepsCargoType @@ -784,10 +799,7 @@ export class RatesService { ? dto.cargoTypeId : existing.cargoTypeId; const tradeDirection = - trigger === 'CUSTOMS_CLEARANCE' || - trigger === 'WITH_RETURN' || - trigger === 'LASHING' || - trigger === 'FUEL' + DIRECTED_SURCHARGE_TRIGGERS.includes(trigger) ? dto.tradeDirection !== undefined ? dto.tradeDirection : existing.tradeDirection diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 780d65305..73eb6fc31 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -2160,7 +2160,15 @@ export class TrainSchedulingService { return { ...detail, warnings, deferredBookings }; } - async unassignBooking(scheduleId: string, bookingId: string, userId?: string) { + async unassignBooking( + scheduleId: string, + bookingId: string, + userId?: string, + opts: { + /** false = system detach (e.g. booking cancelled) — no "removed from train, rebook" notice. */ + notifyCustomer?: boolean; + } = {}, + ) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -2287,7 +2295,9 @@ export class TrainSchedulingService { const removedBooking = await this.dataSource .getRepository(Booking) .findOne({ where: { id: bookingId }, relations: { company: true } }); - if (removedBooking) this.bookingNotifier.removedFromTrain(removedBooking); + if (removedBooking && opts.notifyCustomer !== false) { + this.bookingNotifier.removedFromTrain(removedBooking); + } this.logger.log( `Booking ${bookingReference} removed from schedule ${scheduleId} by user ${userId ?? 'unknown'} — customer notified to reschedule or cancel.`, ); @@ -4409,6 +4419,7 @@ export class TrainSchedulingService { originStation: true, destinationStation: true, scheduleBookings: { booking: true }, + shippingLineCompany: true, }, order: { [sortBy]: sortOrder } as never, skip, @@ -6107,6 +6118,10 @@ export class TrainSchedulingService { origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null, destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null, + // Dedicated shipping-line departure (hidden from customers) — the list + // highlights these rows so staff can tell them apart at a glance. + shippingLineCompanyId: schedule.shippingLineCompanyId ?? null, + shippingLineCompanyName: schedule.shippingLineCompany?.name ?? null, // Built train (Train Builder) behind this departure, when scheduled by train. train: schedule.trainSet?.train ? { @@ -8997,8 +9012,14 @@ export class TrainSchedulingService { } const allocRepo = this.dataSource.getRepository(WagonBookingAllocation); + // Cargo type → allowed wagon types rides along: for bulk, the commodity's + // own wagon-type list (the planner's rule) decides, not only the wagon + // type's generic supportedLoadTypes. const loadAllocations = (trainSetWagonId: string) => - allocRepo.find({ where: { trainSetWagonId } }); + allocRepo.find({ + where: { trainSetWagonId }, + relations: { booking: { cargoType: { wagonTypes: true } } }, + }); const sourceAllocs = await loadAllocations(source.id); if (!sourceAllocs.length) { throw new BadRequestException('Source wagon has no load to move'); @@ -9043,10 +9064,25 @@ export class TrainSchedulingService { slot.physicalWagon?.wagonNumber ?? `#${slot.sequenceNo}`; const wagonLabel = (slot: TrainSetWagon | null, wagon: Wagon | null) => slot ? slotLabel(slot) : (wagon?.wagonNumber ?? 'the target wagon'); + // Bulk is allowed on a wagon type when every bulk load's cargo type lists + // it (cargo-type ↔ wagon-type config, same rule the wagon planner uses). + const bulkCargoAllows = (allocs: WagonBookingAllocation[], wagonTypeId?: string) => { + const bulk = allocs.filter((a) => (a.loadType ?? 'CONTAINER').toUpperCase() === 'BULK'); + return ( + !!wagonTypeId && + bulk.length > 0 && + bulk.every((a) => + (a.booking?.cargoType?.wagonTypes ?? []).some((wt) => wt.id === wagonTypeId), + ) + ); + }; const checkReceives = ( allocs: WagonBookingAllocation[], label: string, - wagonType: { code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } | null | undefined, + wagonType: + | { id?: string; code?: string; supportedLoadTypes?: string[]; supportsContainer?: boolean } + | null + | undefined, capacityTons: number, ) => { const incoming = loadTypesOf(allocs); @@ -9057,6 +9093,7 @@ export class TrainSchedulingService { const ok = supported.includes(loadType) || (loadType === 'CONTAINER' && wagonType.supportsContainer) || + (loadType === 'BULK' && bulkCargoAllows(allocs, wagonType.id)) || supported.length === 0; if (!ok) { throw new BadRequestException( diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index e05e00917..8c67b9eb3 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -256,7 +256,7 @@ const RuleEngineFormDialog = ({ next.containerTypeId = ""; 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). if (name === "cargoKind") { next.containerTypeId = ""; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index f160a70ff..26db519ba 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -95,6 +95,8 @@ export const QUERY_KEYS = { byId: (id: string) => ["invoices", "detail", id] as const, offlineUsd: (filter?: InvoiceListFilter) => ["invoices", "offline-usd", filter ?? {}] as const, + summary: (filter?: Omit) => + ["invoices", "summary", filter ?? {}] as const, eimsStatus: (id: string) => ["invoices", "eims", id] as const, }, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 2a9f40ebd..df19cfd60 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -134,6 +134,7 @@ export const URL_CONSTANTS = { BILLING: { INVOICES: "/billing/invoices", + INVOICES_SUMMARY: "/billing/invoices/summary", INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`, INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`, OFFLINE_USD: "/billing/offline-usd", diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx index bd5169262..261889d0b 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/FinanceHubPage.tsx @@ -1,5 +1,5 @@ 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 { useAuth } from "@/auth/useAuth"; @@ -8,13 +8,15 @@ import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions"; import InvoicesPanel from "./InvoicesPage"; import UsdPaymentsPanel from "./UsdPaymentsPage"; -import PaymentsPanel from "../payments/PaymentsPage"; /** - * Invoices, Payments, and USD Payments used to be three separate routes/pages - * with near-identical chrome. They're merged here as URL-linkable tabs - * (`?tab=`) on one page — each tab keeps the permission it was individually - * gated on before, and just doesn't render if the user lacks it. + * Invoices and USD Payments used to be separate routes/pages with + * near-identical chrome. They're merged here as URL-linkable tabs (`?tab=`) + * on one page — each tab keeps the permission it was individually gated on + * 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 = [ { @@ -26,14 +28,6 @@ const TABS = [ "Every invoice issued across bookings, warehouse fees and clearance charges.", 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", label: "USD Payments", diff --git a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx index e93521a35..ce42e8953 100644 --- a/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/invoices/InvoicesPage.tsx @@ -5,6 +5,7 @@ import { Card, Group, SegmentedControl, + SimpleGrid, Stack, Text, TextInput, @@ -22,6 +23,7 @@ import { humanize, } from "@/components/customers"; import CreditInvoiceActions from "@/components/shipping-lines/CreditInvoiceActions"; +import { useExchangeSettingsQuery } from "@/hooks/useExchangeSettings"; import { api } from "@/services/api"; import type { Invoice } from "@/types/invoice"; import { @@ -79,6 +81,21 @@ export default function InvoicesPanel() { [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[] = useMemo( () => [ { @@ -170,7 +187,42 @@ export default function InvoicesPanel() { ); return ( - + + + + + Total collected + + + {etbFromUsd !== null + ? formatMoney(etbCollected + etbFromUsd, "ETB") + : formatMoney(etbCollected, "ETB")} + + + {etbFromUsd !== null + ? `Includes ${formatMoney(usdCollected, "USD")} converted @ ${rate} ETB/USD` + : "USD rate unavailable — ETB collected only"} + + + + + Collected — ETB only + + + {formatMoney(etbCollected, "ETB")} + + + + + Collected — USD only + + + {formatMoney(usdCollected, "USD")} + + + + + @@ -264,6 +316,7 @@ export default function InvoicesPanel() { - + + ); } diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index aaddfe926..4ff9aa3fb 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -187,7 +187,7 @@ const RATE_TRIGGERS = [ { label: "Shipping line mapped", value: "SHIPPING_LINE" }, { label: "Penalty", value: "CONSOLIDATION" }, { 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: "Customs clearance service fee (billed with the booking)", value: "CUSTOMS_CLEARANCE" }, { label: "Fuel (per lane + cargo type)", value: "FUEL" }, @@ -265,6 +265,13 @@ const isRouteScopedRate = (values: Record) => (String(values.appliesTo ?? "") === "OTHER" && ["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) => + ["CUSTOMS_CLEARANCE", "CANCELLATION"].includes(String(values.trigger ?? "")); + 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. return ["PER_CONTAINER", "PER_WAGON", "FLAT"]; case "CANCELLATION": - return ["FLAT", "PER_INVOICE"]; + // Wagon cancellation fee — scales with the cancelled wagons only. + return ["PER_WAGON"]; case "CUSTOMS_CLEARANCE": // Per cargo kind: container fees per box/wagon, bulk per ton/wagon. return cargoKind === "BULK" @@ -1054,9 +1062,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ showIf: (v) => hasShippingLine(v) && v.shippingLineRateKind === "SURCHARGE", }, - // ── Trade direction — Bulk & Container base freight, plus the route- - // scoped surcharges (customs clearance; empty-container return, which is - // import-only for now so export is not offered) ──────────────────────── + // ── Trade direction — Bulk & Container base freight, plus the directed + // surcharges (customs clearance, cancellation, lashing, fuel; empty- + // container return, which is import-only for now so export is not + // offered) ───────────────────────────────────────────────────────────── { name: "tradeDirection", label: "Trade direction", @@ -1074,9 +1083,13 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ !isShippingLineRate(v) && (["BULK", "CONTAINER"].includes(String(v.appliesTo ?? "")) || (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 // the customer's contract — so the direction is stated, not asked. Shown @@ -1097,8 +1110,9 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ computeValue: () => "IMPORT", showIf: hasShippingLine, }, - // ── Cargo kind — customs clearance is priced separately for containers - // (one rate per container type) and bulk ─────────────────────────────── + // ── Cargo kind — customs clearance and the cancellation fee are priced + // separately for containers (one rate per container type) and bulk (one + // rate per commodity) ────────────────────────────────────────────────── { name: "cargoKind", label: "Cargo kind", @@ -1107,11 +1121,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ options: INTERCITY_KINDS, placeholder: "Is this fee for containers or bulk?", description: - "Container fees bill per box or wagon (one rate per container type); bulk fees bill per ton or wagon.", - showIf: (v) => - v.appliesTo === "OTHER" && v.trigger === "CUSTOMS_CLEARANCE", + "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) => v.appliesTo === "OTHER" && isCargoKindTrigger(v), // Not a stored column: a container fee carries its containerTypeId, a - // bulk fee carries none. + // bulk fee its cargoTypeId. getInitialValue: (record) => record.containerTypeId ? "CONTAINER" : "BULK", }, @@ -1124,10 +1137,10 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Which container type this fee covers", showIf: (v) => v.appliesTo === "OTHER" && - v.trigger === "CUSTOMS_CLEARANCE" && + isCargoKindTrigger(v) && v.cargoKind === "CONTAINER", }, - // ── Bulk cargo type — the bulk customs fee names its commodity ──────── + // ── Bulk cargo type — the bulk fee names its commodity ──────────────── { name: "cargoTypeId", label: "Bulk cargo type", @@ -1136,7 +1149,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ placeholder: "Which bulk commodity this fee covers", showIf: (v) => v.appliesTo === "OTHER" && - v.trigger === "CUSTOMS_CLEARANCE" && + isCargoKindTrigger(v) && v.cargoKind === "BULK", }, // ── Cargo type — a fuel rate names the commodity it covers (different diff --git a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx index cfe1cc016..a659f31fa 100644 --- a/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/trainScheduling/TrainScheduleV2ListPage.tsx @@ -362,6 +362,7 @@ export default function TrainScheduleV2ListPage() { {row.original.direction} ) : null} + 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={ schedulesQuery.isError ? { @@ -1052,6 +1057,20 @@ export default function TrainScheduleV2ListPage() { * bookings that have not paid yet — that space is claimed, so it is not * 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 ( + + {schedule.shippingLineCompanyName ?? "Shipping line"} + + ); +} + function WagonChips({ schedule }: { schedule: TrainScheduleListItem }) { // Pre-deploy API rows carry only wagonCount; fall back so the chip still // renders rather than reading 0 used on every train. @@ -1133,6 +1152,7 @@ function ScheduleCard({ withBorder onClick={onOpen} 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} > @@ -1182,6 +1202,7 @@ function ScheduleCard({ {schedule.direction} ) : null} + diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index 78efb2e4b..2c06700d2 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -39,6 +39,7 @@ import type { } from "@/types/fileUploadSettings"; import type { Invoice, + InvoiceCollectedSummary, InvoiceListFilter, PaginatedInvoices, PaginatedOfflineUsdInvoices, @@ -3137,6 +3138,16 @@ export const api = { ({ id }) => QUERY_KEYS.INVOICES.byId(id), ), + collectedSummary: endpoint< + { filter: Omit }, + InvoiceCollectedSummary + >( + "invoices", + "collectedSummary", + ({ filter }) => invoicesService.collectedSummary(filter), + ({ filter }) => QUERY_KEYS.INVOICES.summary(filter), + ), + listOfflineUsd: endpoint< { filter: InvoiceListFilter }, PaginatedOfflineUsdInvoices diff --git a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts index 18febdf7b..073afca12 100644 --- a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts @@ -2,6 +2,7 @@ import { api as apiClient } from "@/auth/http"; import { URL_CONSTANTS } from "@/constants/URLS"; import type { Invoice, + InvoiceCollectedSummary, InvoiceListFilter, PaginatedInvoices, PaginatedOfflineUsdInvoices, @@ -23,6 +24,17 @@ export const invoicesService = { .then((r) => r.data); }, + /** Total collected (paidAmount) across every filtered invoice, by currency. */ + collectedSummary( + filter: Omit, + ): Promise { + return apiClient + .get(URL_CONSTANTS.BILLING.INVOICES_SUMMARY, { + params: cleanParams(filter), + }) + .then((r) => r.data); + }, + getById(id: string): Promise { return apiClient .get(URL_CONSTANTS.BILLING.INVOICE_BY_ID(id)) diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 14c99eb50..696a403ee 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -39,3 +39,6 @@ export interface PaginatedOfflineUsdInvoices { items: OfflineUsdInvoice[]; total: number; } + +/** Total collected (`paidAmount`) across every filtered invoice, keyed by currency. */ +export type InvoiceCollectedSummary = Record; diff --git a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts index 921f0c793..2c49769fc 100644 --- a/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts +++ b/apps/edr-freight-web/backoffice/src/types/trainScheduling.ts @@ -196,6 +196,9 @@ export interface TrainScheduleListItem { origin: string | null; destination: string | 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. */ train?: { id: string; diff --git a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx index 0ff719a7c..0ecd16ef4 100644 --- a/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx +++ b/apps/edr-freight-web/portal/src/pages/bookings/BookingDetailPage/ReadonlyBookingView.tsx @@ -158,7 +158,9 @@ export function ReadonlyBookingView({ // the customer. Hide the customer's rebook everywhere and tell them GL will // handle it. Non-customs bookings stay self-service. 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 isClearance = [ "AWAITING_DOCUMENTS", @@ -219,7 +221,9 @@ export function ReadonlyBookingView({ subtitle={ status === "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} onRebook={canSelfRebook ? onRebook : undefined} @@ -348,6 +352,7 @@ export function ReadonlyBookingView({ { 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 * onto another shipment day — plus the booking's cancellation history. * Wagons leave the schedule at request time; the fee settles the credit. @@ -90,10 +90,13 @@ export function WagonCancellationCard({ }) { const navigate = useNavigate(); const status = booking.status as string; - const eligible = - status === "PAID" && - (booking.paymentStatus as string) === "PAID" && - !!booking.contractId; + const paidContract = + (booking.paymentStatus as string) === "PAID" && !!booking.contractId; + // New cuts only on a live PAID booking; a booking fully cancelled through + // 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 detail = booking as BookingDetail; @@ -226,12 +229,13 @@ export function WagonCancellationCard({ }); if (!eligible) return null; + if (!canRequest && !ownRows.length) return null; return ( Wagon Cancellation - {!openRow && !creditRow && ( + {canRequest && !openRow && !creditRow && ( )} - + { + 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 ( + <> + + setOpen(false)} + title={ + + Rebook cancelled wagons + + } + centered + radius={16} + > + + + {Number(cancellation.wagonsCancelled)} wagon(s) — credit of{" "} + + {formatAmount(cancellation.creditAmount)} {currency ?? ""} + + . Pick a shipment day; the new booking is created already paid. + + + + + + + + + + ); +} From dc38e843a6dc73bbf2295e4e1e07bd6e8bc68a9d Mon Sep 17 00:00:00 2001 From: Marshal Date: Sat, 15 Aug 2026 10:12:37 +0000 Subject: [PATCH 2/2] feat: full wagon cancel, leg board, wagon dates feat(freight): editable train leg times, SL invoice payer --- .../src/modules/billing/billing.service.ts | 44 +- .../src/modules/bookings/bookings.module.ts | 1 + .../bookings/container-validation.service.ts | 11 +- ...hipping-line-booking-completion.service.ts | 50 +++ .../train-scheduling.controller.ts | 33 +- .../dto/record-checkpoint.dto.ts | 39 +- .../services/train-scheduling.service.spec.ts | 59 ++- .../services/train-scheduling.service.ts | 232 +++++++++- .../src/modules/wagons/wagons.service.ts | 42 +- .../trainScheduling/CheckpointTimeModal.tsx | 103 +++++ .../trainScheduling/LegLoadBoardPanel.tsx | 423 ++++++++++++++++++ .../trainScheduling/LogPassYardWorkModal.tsx | 30 +- .../trainScheduling/RouteCorridorTrack.tsx | 35 +- .../backoffice/src/constants/URLS.ts | 2 + .../src/pages/fleet/config/resources.ts | 4 + .../src/pages/invoices/InvoiceDetailPage.tsx | 26 +- .../src/pages/invoices/InvoicesPage.tsx | 4 +- .../ruleEngine/RuleEngineResourcePage.tsx | 16 +- .../TrainScheduleTrackPage.tsx | 143 +++++- .../TrainScheduleV2DetailPage.tsx | 38 +- .../TrainScheduleV2ListPage.tsx | 25 +- .../backoffice/src/services/api.ts | 21 +- .../src/services/trainScheduling.service.ts | 17 +- .../backoffice/src/services/wagon.service.ts | 3 + .../backoffice/src/types/trainScheduling.ts | 12 + .../portal/src/components/AppLayout.tsx | 6 +- .../src/pages/bookings/BookingsListPage.tsx | 4 +- .../ShippingLineBookingsPage.tsx | 11 + .../ShippingLineCompletePage.tsx | 378 +++++++++++----- .../shipping-line-bookings.service.ts | 9 + packages/types/src/freight/index.ts | 15 +- 31 files changed, 1619 insertions(+), 217 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx create mode 100644 apps/edr-freight-web/backoffice/src/components/trainScheduling/LegLoadBoardPanel.tsx diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.ts b/apps/edr-freight-api/src/modules/billing/billing.service.ts index 78b210a61..720bad774 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -252,7 +252,46 @@ export class BillingService { this.applyInvoiceFilters(qb, filter); 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( + invoices: T[], + ): Promise { + 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; + }); } /** @@ -432,11 +471,12 @@ export class BillingService { relations: { company: true, companyProfile: true }, }); if (!invoice) throw new NotFoundException(`Invoice ${id} not found`); + const [hydrated] = await this.attachShippingLineCompanies([invoice]); const lines = await this.invoiceLines.findAll({ where: { invoiceId: id }, order: { createdAt: "ASC" }, }); - return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] }; + return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] }; } // ── Documents (central PDF) ────────────────────────────────────────────────── diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts index 658629592..447292117 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.module.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.module.ts @@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module"; BookingsService, BookingsRepository, BookingPricingService, + ContainerValidationService, BookingInvoiceService, BookingLifecycleNotifierService, BookingTransitionService, diff --git a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts index de4dca661..158ac14de 100644 --- a/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/container-validation.service.ts @@ -67,9 +67,16 @@ export class ContainerValidationService { const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20')); if (!has20ft) return []; - const units = await this.load20ftUnits(booking); - if (units.length < 2) return []; + return this.validate20ftPairingUnits(await this.load20ftUnits(booking)); + } + /** + * 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 { + if (units.length < 2) return []; const maxDiff = await this.maxPairDiffTons(); return validate20ftWeightPairing(units, maxDiff); } diff --git a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts index 0f7be3f18..ce5a7fad8 100644 --- a/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts +++ b/apps/edr-freight-api/src/modules/shipping-lines/shipping-line-booking-completion.service.ts @@ -10,6 +10,8 @@ import { In, Repository } from "typeorm"; import { BookingPricingService } from "../bookings/booking-pricing.service"; import { BookingTransitionService } from "../bookings/booking-transition.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 { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity"; import { Booking } from "../bookings/entities/booking.entity"; @@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService { private readonly trainSchedulingService: TrainSchedulingService, private readonly bookingBatchService: BookingBatchService, 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 { + 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. */ private async requireShippingLine(userId: string) { 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 // 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 @@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService { 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 { totalAmount: computed.totalAmount, currency: computed.currency, lineItems: computed.lineItems, warnings: computed.warnings, + overweightLines: computed.overweightLines, + pairingErrors, }; } diff --git a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 93a187a95..54056404a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; 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"; import { CurrentUser } from "@edr/api-common"; import { @@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto"; import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-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 { ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, @@ -516,9 +520,14 @@ export class TrainSchedulingController { @Post("schedules/:id/dispatch") @BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch) - @ApiOperation({ summary: "Dispatch a scheduled train" }) - dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) { - return this.trainSchedulingService.dispatchSchedule(id); + @ApiOperation({ + summary: "Dispatch a scheduled train (optional actual departure time, past allowed)", + }) + dispatchSchedule( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: DispatchScheduleDto, + ) { + return this.trainSchedulingService.dispatchSchedule(id, dto); } @Get("intercity/bookings") @@ -956,6 +965,20 @@ export class TrainSchedulingController { 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") @TrainSchedulingUpdate() @ApiOperation({ diff --git a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts index da7ebc0e7..1495185f0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/dto/record-checkpoint.dto.ts @@ -10,8 +10,6 @@ import { Min, } from 'class-validator'; -import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator'; - export class RecordCheckpointDto { @ApiProperty({ description: 'Station position along the route (0 = origin).' }) @IsInt() @@ -24,17 +22,17 @@ export class RecordCheckpointDto { kind?: TrainCheckpointKind; /** - * A checkpoint records where the train is as staff observe it, and the final - * one arrives the schedule — so a backdated value rewrites the journey after - * the fact. Only "now" is accepted; omit the field and the service stamps it. + * When the train was actually at the station — staff often log after the + * fact, so a past value is allowed. The service rejects the future and any + * value out of order with the neighbouring legs. */ @ApiProperty({ 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() @IsISO8601() - @IsNotBackdated() occurredAt?: string; @ApiProperty({ required: false }) @@ -43,3 +41,30 @@ export class RecordCheckpointDto { @MaxLength(500) 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; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 43a169058..ed099ad72 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => { let wagonBookingAllocationsRepository: Record; let wagonAllocationContainerItemsRepository: Record; let wagonAllocationBulkLoadsRepository: Record; + let trainCheckpointEventsRepository: Record; beforeEach(() => { // findGroupSiblings runs a query builder off dataSource.manager; default it @@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => { findByIdWithFullGraph: jest.fn(), findAll: jest.fn(), updateStatus: jest.fn(), + update: jest.fn(), maxReferenceSequence: jest.fn().mockResolvedValue(0), }; trainScheduleBookingsRepository = { @@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => { findAll: jest.fn().mockResolvedValue([]), }; - const trainCheckpointEventsRepository = { + trainCheckpointEventsRepository = { findBySchedule: jest.fn().mockResolvedValue([]), findAll: jest.fn().mockResolvedValue([]), 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', () => { const effective = (booking: unknown): number => (service as never as { effectiveWagonsRequired(b: unknown): number }) diff --git a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index 73eb6fc31..2effe6a37 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -174,7 +174,11 @@ import { import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; import { BookingJourneyService } from '../booking-journey.service'; 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 { deriveTradeDirection } from '../../../common/derive-trade-direction.util'; import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service'; @@ -2645,7 +2649,7 @@ export class TrainSchedulingService { return this.getTrainScheduleById(scheduleId); } - async dispatchSchedule(scheduleId: string) { + async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) { const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -2653,6 +2657,9 @@ export class TrainSchedulingService { if (schedule.status !== TrainScheduleStatusEnum.Scheduled) { 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); // Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon) // never blocks departure — the dispatch confirm dialog warns and staff decide. @@ -2677,7 +2684,6 @@ export class TrainSchedulingService { } } - const now = new Date(); await this.dataSource.transaction(async (manager) => { const trainNumber = await this.assignTrainNumber(manager, schedule); if (setLocomotiveIds.length) { @@ -4160,6 +4166,7 @@ export class TrainSchedulingService { ? TrainCheckpointKind.Arrived : TrainCheckpointKind.Passed); 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. const [existing] = await this.trainCheckpointEventsRepository.findAll({ @@ -4183,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) { - await this.arriveSchedule(scheduleId); + await this.arriveSchedule(scheduleId, occurredAt); } else { // Mid-corridor auto-unload: bookings destined for this yard alight the // moment the train is recorded here — the yard operator no longer has to @@ -4220,11 +4233,133 @@ export class TrainSchedulingService { 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 = {}; + 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 * 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); if (!schedule) { throw new NotFoundException(`Train schedule ${scheduleId} not found`); @@ -4233,7 +4368,9 @@ export class TrainSchedulingService { 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.trainSchedulesRepository.updateStatus( @@ -9025,33 +9162,88 @@ export class TrainSchedulingService { 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 - // built train (physical wagon with no slot row yet). + // Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing — + // 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 wagonForTarget = slotById - ? null - : schedule.trainSet?.trainId - ? await this.dataSource.getRepository(Wagon).findOne({ - where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId }, - relations: { wagonType: true }, - }) - : null; + let wagonForTarget: Wagon | null = null; + if (!slotById) { + const wagon = await this.dataSource.getRepository(Wagon).findOne({ + where: { id: dto.targetWagonId }, + relations: { wagonType: true }, + }); + const onThisTrain = + !!wagon && + ((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) || + slots.some((w) => w.physicalWagonId === wagon.id)); + wagonForTarget = onThisTrain ? wagon : null; + } if (!slotById && !wagonForTarget) { 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 = slotById ?? (wagonForTarget - ? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null) + ? (slots.find( + (w) => + w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan), + ) ?? null) : null); 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) : []; if (targetSlot && targetSlot.id === source.id) { 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 load’s leg.', + ); + } const loadTypesOf = (allocs: WagonBookingAllocation[]) => [ ...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())), diff --git a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts index 2d8950fb4..b5a14f6f3 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -121,8 +121,46 @@ export class WagonsService { * (yard workspace, coupling pickers) walk the pages client-side — see * `wagonService.listAll` in the backoffice. */ - findAll(query: ListWagonsQueryDto = {}): Promise> { - return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 }); + async findAll(query: ListWagonsQueryDto = {}): Promise> { + 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 { + 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 { diff --git a/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx new file mode 100644 index 000000000..6d748b559 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/components/trainScheduling/CheckpointTimeModal.tsx @@ -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(null); + const [note, setNote] = useState(""); + useEffect(() => { + if (!opened) return; + setAt(initialOccurredAt ? new Date(initialOccurredAt) : new Date()); + setNote(initialNote ?? ""); + }, [opened, initialOccurredAt, initialNote]); + + return ( + + {icon} + {title} + + } + > + + {description ? ( + + {description} + + ) : null} + setAt(v ? new Date(v) : null)} + maxDate={new Date()} + valueFormat="DD MMM YYYY HH:mm" + clearable={false} + radius="md" + /> +