diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts index 71107650f..5cdb0d6a4 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -114,6 +114,8 @@ interface CarriageAcceptanceWagonRow { arrivalAt: string | null; containerNumbers: string | null; sealNumbers: string | null; + /** Allocation status — LOADED/DEPARTED means EDR has the cargo. */ + status: string | null; } /** A received-but-not-yet-marshalled export line, standing in for a wagon row. */ @@ -263,9 +265,13 @@ export class BookingsService { /** * Carriage acceptance sheet — one per booking, listing every wagon the booking - * occupies. Handed to the customer when EDR accepts the cargo (export) and when - * the wagons are allocated before marshalling (import), so it is only available - * once the booking has wagon allocations. + * occupies. A booking is routinely loaded in parts (some containers go, the + * rest wait for the next train), so each row carries a Status of Loaded or + * Not loaded and the totals count only the loaded ones: the customer sees the + * whole plan on one page without the sheet overstating what EDR has taken. + * + * Handed to the customer when EDR accepts the cargo (export) and when the + * wagons are allocated before marshalling (import). */ async carriageAcceptanceSheet(bookingId: string): Promise<{ filename: string; buffer: Buffer }> { const booking = await this.findById(bookingId); @@ -285,6 +291,7 @@ export class BookingsService { s.scheduled_departure_date AS "departureAt", so.label AS "marshalledAt", sd.label AS "arrivalAt", + a.status AS "status", string_agg(DISTINCT ci.container_number, ', ') AS "containerNumbers", string_agg(DISTINCT ci.seal_number, ', ') AS "sealNumbers" FROM freight.wagon_booking_allocations a @@ -299,7 +306,7 @@ export class BookingsService { LEFT JOIN freight.wagon_allocation_container_items ci ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL WHERE a.booking_id = $1 AND a.deleted_at IS NULL - GROUP BY tsw.id, a.id, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, + GROUP BY tsw.id, a.id, a.status, wt.code, wt.name, w.wagon_number, wt.tare_weight_tons, s.train_number, s.scheduled_departure_date, so.label, sd.label ORDER BY tsw.sequence_no`, [bookingId], @@ -383,6 +390,8 @@ export class BookingsService { arrivalAt: null, containerNumbers: row.containerNumbers, sealNumbers: row.sealNumbers ?? null, + // A received line has no allocation; it is cargo EDR already holds. + status: null, })); } @@ -497,7 +506,17 @@ export class BookingsService { const header = wagons[0]; const sheetDate = header.departureAt ? new Date(header.departureAt) : new Date(); - const totals = wagons.reduce( + // Loaded = EDR has the cargo. A booking is routinely loaded in parts, so the + // totals count only those: the sheet shows the whole plan, but must never + // total up cargo still sitting in the yard. A received-line sheet + // (pendingWagons) has no allocation status, and every line on it is cargo + // already accepted, so it counts in full. + const isLoaded = (w: CarriageAcceptanceWagonRow) => + pendingWagons || w.status === 'LOADED' || w.status === 'DEPARTED'; + const loadedWagons = wagons.filter(isLoaded); + const notLoadedCount = wagons.length - loadedWagons.length; + + const totals = loadedWagons.reduce( (acc, w) => ({ tare: acc.tare + (Number(w.tareWeightTons) || 0), capacity: acc.capacity + (Number(w.loadCapacityTons) || 0), @@ -507,7 +526,7 @@ export class BookingsService { { tare: 0, capacity: 0, load: 0, length: 0 }, ); // A wagon carrying no weight and no container is running empty under this booking. - const fullWagons = wagons.filter( + const fullWagons = loadedWagons.filter( (w) => (Number(w.allocatedWeightTons) || 0) > 0 || Boolean(w.containerNumbers), ).length; @@ -525,6 +544,9 @@ export class BookingsService { ${esc(departureStation)} ${esc(w.containerNumbers)} ${esc(w.sealNumbers)} + ${ + pendingWagons ? 'Accepted' : isLoaded(w) ? 'Loaded' : 'Not loaded' + } ${money(prices[i])} `, ) @@ -535,11 +557,11 @@ export class BookingsService { // figure from the printed sheet. const totalsRow = ` TOT - ${wagons.length} ${pendingWagons ? 'received lines' : 'wagons'} + ${loadedWagons.length} ${pendingWagons ? 'received lines' : 'wagons loaded'} ${ pendingWagons ? 'pending marshalling' - : `full ${fullWagons} / empty ${wagons.length - fullWagons}` + : `full ${fullWagons} / empty ${loadedWagons.length - fullWagons}` } ${num(totals.tare, 2)} ${num(totals.length)} @@ -549,6 +571,7 @@ export class BookingsService { + ${notLoadedCount > 0 ? `loaded only (${notLoadedCount} not loaded)` : ''} ${money(totalAmount)} `; @@ -575,6 +598,8 @@ export class BookingsService { th { background: #f8fafc; color: #475569; text-align: left; } th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; } .num { text-align: right; } + .loaded { color: #0f766e; font-weight: 700; } + .pending { color: #b45309; font-weight: 700; } tr.totals td { background: #f8fafc; font-weight: 700; } .notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; } .signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; } @@ -618,6 +643,7 @@ export class BookingsService { Departure Station Container No. Seal No. + Status Price (${esc(currency)}) diff --git a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts index 477724839..10b6afd37 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-notifier.service.ts @@ -176,6 +176,18 @@ export class ContractNotifierService { this.inApp(c, 'Contract suspension lifted', msg); } + /** + * Backoffice cancelled the contract. Terminal — the customer is told they may + * submit a new contract with the same details if they still need the service. + */ + cancelledByStaff(c: Contract, reason: string): void { + const msg = + `Your contract ${c.reference} has been cancelled. Reason: ${reason}. ` + + `If you still need this service you can submit a new contract request with the same details.`; + void this.notifyContact(c, msg, 'CANCELLED'); + this.inApp(c, 'Contract cancelled', msg); + } + /** Customer cancelled their own contract — staff-side record. */ cancelledByCustomer(c: Contract, reason: string): void { this.inAppStaff( diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts index 96a8a26ac..c535c909e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.lane-scope.spec.ts @@ -120,3 +120,34 @@ describe('contract base freight is priced on the contract lane only', () => { ]); }); }); + +describe('contract base freight ignores shipping-line rates', () => { + it("never prices a customer contract off a line's negotiated rate (CTR-2026-00049)", async () => { + // Both LIVE on the contract's own lane: the line rate sorted first and won, + // so the contract quoted 32 USD/wagon instead of the standard 1690. + const breakdown = await service([ + rate({ + containerTypeId: CT20, + rateValue: 32, + rateUnit: 'PER_WAGON', + shippingLineCompanyId: 'line-1', + }), + rate({ containerTypeId: CT20, rateValue: 1690, rateUnit: 'PER_WAGON' }), + ]).buildBreakdown(contract({})); + expect(breakdown.lineItems).toEqual([ + expect.objectContaining({ code: 'CONTAINER_20FT', unitPrice: 1690 }), + ]); + }); + + it('blocks when the only rate on the lane belongs to a shipping line', async () => { + await expect( + service([ + rate({ + containerTypeId: CT20, + rateValue: 32, + shippingLineCompanyId: 'line-1', + }), + ]).buildBreakdown(contract({})), + ).rejects.toThrow(UnprocessableEntityException); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts index 0af09a5f8..04be6460f 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-pricing.service.ts @@ -85,7 +85,15 @@ export class ContractPricingService { * commodity rate) — NO totals or quantities (doc §9.1). */ async buildBreakdown(contract: Contract): Promise { - const liveRates = await this.ratesService.findLiveRates(); + // Contracts belong to a customer company — there is no shipping-line + // contract (no shipping_line_company_id on the entity), so a contract may + // only ever price off the standard rates. Without this filter a line's + // negotiated rate on the same lane matched first and the contract froze it + // for a customer: CTR-2026-00049 quoted a line's 32 USD/wagon 20ft and + // 23 USD/container 40ft instead of the standard 1690 / 1676. + const liveRates = (await this.ratesService.findLiveRates()).filter( + (r) => !r.shippingLineCompanyId, + ); const currency = contract.paymentCurrency; const isEtb = currency === 'ETB'; const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts new file mode 100644 index 000000000..06a406bf1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-staff-cancel.spec.ts @@ -0,0 +1,113 @@ +import { ContractTransitionService } from './contract-transition.service'; +import type { Contract } from './entities/contract.entity'; + +/** + * Staff cancel is terminal, so the rules that matter are: it needs its own + * permission (suspend must NOT imply it), it refuses to strand live shipments, + * it works on a suspended contract, and it cannot be applied twice. + */ +describe('ContractTransitionService — staff cancel', () => { + const contract = (over: Partial = {}): Contract => + ({ + id: 'c-1', + reference: 'CTR-2026-00042', + companyId: 'co-1', + status: 'CONTRACT_ACTIVE', + freightType: 'CONTAINER', + ...over, + }) as Contract; + + let current: Contract; + let repo: { + update: jest.Mock; + createReviewNote: jest.Mock; + countActiveBookings: jest.Mock; + }; + let notifier: { cancelledByStaff: jest.Mock }; + let service: ContractTransitionService; + + const staff = { + permissions: [{ key: 'edr_freight_app:contracts:cancel' }], + }; + + beforeEach(() => { + current = contract(); + repo = { + update: jest.fn().mockImplementation((_id: string, patch: object) => { + current = { ...current, ...patch } as Contract; + return Promise.resolve(current); + }), + createReviewNote: jest.fn().mockResolvedValue(undefined), + countActiveBookings: jest.fn().mockResolvedValue(0), + }; + notifier = { cancelledByStaff: jest.fn() }; + service = Object.create( + ContractTransitionService.prototype, + ) as ContractTransitionService; + Object.assign(service, { + contractsRepository: repo, + contractsService: { findById: () => Promise.resolve(current) }, + notifier, + }); + }); + + it('cancels, records the reason as a staff note, and notifies the customer', async () => { + await service.cancelByStaff('c-1', 'Duplicate request', 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith('c-1', { + status: 'CANCELLED', + statusBeforeSuspension: null, + }); + expect(repo.createReviewNote).toHaveBeenCalledWith( + 'c-1', + 'Duplicate request', + 'CANCELLATION', + 'staff-1', + 'STAFF', + ); + expect(notifier.cancelledByStaff).toHaveBeenCalled(); + }); + + it('cancels a suspended contract — freezing it is exactly when staff kill it', async () => { + current = contract({ + status: 'SUSPENDED', + statusBeforeSuspension: 'CONTRACT_ACTIVE', + } as Partial); + + await service.cancelByStaff('c-1', 'Customer withdrew', 'staff-1', staff as never); + + expect(repo.update).toHaveBeenCalledWith('c-1', { + status: 'CANCELLED', + statusBeforeSuspension: null, + }); + }); + + it('refuses while a shipment is still running', async () => { + repo.countActiveBookings.mockResolvedValue(2); + + await expect( + service.cancelByStaff('c-1', 'Change of plan', 'staff-1', staff as never), + ).rejects.toThrow('2 active shipments'); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('refuses to cancel an already-terminal contract', async () => { + current = contract({ status: 'CANCELLED' }); + + await expect( + service.cancelByStaff('c-1', 'Again', 'staff-1', staff as never), + ).rejects.toThrow(/already cancelled/i); + expect(repo.update).not.toHaveBeenCalled(); + }); + + it('rejects a user holding only the suspend key — cancel is a separate permission', async () => { + const suspender = { + permissions: [{ key: 'edr_freight_app:contracts:suspend' }], + }; + + await expect( + service.cancelByStaff('c-1', 'Not allowed', 'staff-1', suspender as never), + ).rejects.toThrow(); + expect(repo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts index 936176f86..ac400d9d3 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-transition.service.ts @@ -1452,6 +1452,54 @@ export class ContractTransitionService { return updated; } + /** + * Staff cancel — terminal, unlike suspend. The contract is dead; a fresh one + * with the same parameters can be submitted afterwards (references are minted + * per contract, so nothing about the old row blocks the new one). + * + * Cancellable from ANY non-terminal status, including SUSPENDED: a frozen + * contract is exactly the one staff most often need to kill outright. + */ + async cancelByStaff( + contractId: string, + reason: string, + actorId: string, + user?: TCurrentUser | null, + ): Promise { + const contract = await this.contractsService.findById(contractId); + assertFreightPermission(user, FREIGHT_PERMS.contracts.cancel); + if ((TERMINAL_CONTRACT_STATUSES as readonly string[]).includes(contract.status)) { + throw new ConflictException( + `Contract is already ${contract.status.toLowerCase().replace(/_/g, ' ')}.`, + ); + } + + // Same guard as the customer path: live shipments must be settled first, + // otherwise cancelling the contract orphans cargo already in motion. + const active = await this.contractsRepository.countActiveBookings(contractId); + if (active > 0) { + throw new BadRequestException( + `This contract has ${active} active shipment${active === 1 ? '' : 's'}. ` + + 'Cancel or complete them before cancelling the contract.', + ); + } + + await this.contractsRepository.createReviewNote( + contractId, + reason, + 'CANCELLATION', + actorId, + 'STAFF', + ); + await this.contractsRepository.update(contractId, { + status: 'CANCELLED', + statusBeforeSuspension: null, + } as never); + const updated = await this.contractsService.findById(contractId); + this.notifier.cancelledByStaff(updated, reason); + return updated; + } + async renew(contractId: string, userId?: string): Promise { const source = await this.contractsService.findById(contractId); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts index 1dc083934..c38576fff 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.controller.ts @@ -72,6 +72,7 @@ import { RequestChangesDto, ResumeContractDto, SuspendContractDto, + CancelContractByStaffDto, } from './dto/approve-step.dto'; import { SignContractDto } from './dto/sign-contract.dto'; import { ReviewClearanceDocumentDto } from './dto/review-clearance-document.dto'; @@ -552,6 +553,25 @@ export class ContractsController { ); } + @Post(':id/staff/cancel') + @BookingStaff(FREIGHT_PERMS.contracts.cancel) + @ApiOperation({ + summary: + 'Staff cancel a contract (terminal — a new contract with the same details may be submitted after)', + }) + cancelByStaff( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: CancelContractByStaffDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.transitionService.cancelByStaff( + id, + dto.reason, + resolveAuthUserId(user), + user, + ); + } + @Post(':id/approval-steps/:stepId/approve') @BookingStaff(FREIGHT_PERMS.contracts.view) @ApiOperation({ summary: 'Approve one approval step in sequence' }) diff --git a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts index 6aa36b13d..63057978c 100644 --- a/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts +++ b/apps/edr-freight-api/src/modules/contracts/dto/approve-step.dto.ts @@ -51,6 +51,14 @@ export class CancelContractDto { reason?: string; } +/** Staff cancel is terminal, so the reason is mandatory — it is the audit record. */ +export class CancelContractByStaffDto { + @ApiProperty({ description: 'Why the contract is being cancelled — shown to the customer' }) + @IsString() + @MinLength(1) + reason!: string; +} + export class SuspendContractDto { @ApiProperty({ description: 'Why the contract is being frozen — shown to the customer' }) @IsString() diff --git a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts index 467b172ba..1dd8ad9ca 100644 --- a/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts +++ b/apps/edr-freight-api/src/modules/notifications/notify-company.util.ts @@ -83,3 +83,184 @@ export async function notifyCarriageAcceptanceReady( logger.warn(`Carriage acceptance ready notify failed for ${bookingId}: ${(err as Error).message}`); } } + +/** One container line on the load manifest notice. */ +interface LoadManifestLists { + reference: string; + companyId: string | null; + trainNumber: string | null; + originStation: string | null; + destinationStation: string | null; + departureAt: Date | null; + loaded: string[]; + leftBehind: string[]; +} + +/** At most `max` numbers, then "+N more" — an SMS must not carry 44 of them. */ +function summarizeNumbers(numbers: string[], max = 5): string { + if (numbers.length === 0) return 'none'; + const shown = numbers.slice(0, max).join(', '); + const rest = numbers.length - max; + return rest > 0 ? `${shown} +${rest} more` : shown; +} + +/** + * Read what actually went on the train and what did not. Left behind = every + * container the customer declared minus the ones sitting on a LOADED/DEPARTED + * wagon, so a booking loaded in parts reports honestly on both halves. + */ +export async function loadManifestLists( + dataSource: DataSource, + bookingId: string, + trainScheduleId: string, +): Promise { + const [booking]: Array<{ reference: string; companyId: string | null }> = + await dataSource.query( + `SELECT reference, company_id AS "companyId" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [bookingId], + ); + if (!booking) return null; + + const [train]: Array<{ + trainNumber: string | null; + originStation: string | null; + destinationStation: string | null; + departureAt: Date | null; + }> = await dataSource.query( + `SELECT s.train_number AS "trainNumber", + so.label AS "originStation", + sd.label AS "destinationStation", + s.scheduled_departure_date AS "departureAt" + FROM freight.train_schedules s + LEFT JOIN freight.yards so ON so.id = s.origin_station_id + LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id + WHERE s.id = $1 AND s.deleted_at IS NULL`, + [trainScheduleId], + ); + + const loadedRows: Array<{ containerNumber: string | null }> = await dataSource.query( + `SELECT DISTINCT ci.container_number AS "containerNumber" + FROM freight.wagon_allocation_container_items ci + JOIN freight.wagon_booking_allocations a + ON a.id = ci.wagon_booking_allocation_id AND a.deleted_at IS NULL + WHERE a.booking_id = $1 + AND ci.deleted_at IS NULL + AND a.status IN ('LOADED', 'DEPARTED') + ORDER BY 1`, + [bookingId], + ); + const declaredRows: Array<{ containerNumber: string | null }> = await dataSource.query( + `SELECT DISTINCT u.container_number AS "containerNumber" + FROM freight.booking_container_units u + JOIN freight.booking_container l + ON l.id = u.booking_container_id AND l.deleted_at IS NULL + WHERE l.booking_id = $1 AND u.deleted_at IS NULL + ORDER BY 1`, + [bookingId], + ); + + const loaded = loadedRows.map((r) => r.containerNumber).filter(Boolean) as string[]; + const loadedSet = new Set(loaded); + const leftBehind = (declaredRows.map((r) => r.containerNumber).filter(Boolean) as string[]).filter( + (n) => !loadedSet.has(n), + ); + + return { + reference: booking.reference, + companyId: booking.companyId, + trainNumber: train?.trainNumber ?? null, + originStation: train?.originStation ?? null, + destinationStation: train?.destinationStation ?? null, + departureAt: train?.departureAt ?? null, + loaded, + leftBehind, + }; +} + +/** + * Tell the customer what boarded the train and what did not, over in-app + SMS + * + email, and raise a warehouse-desk notice for anything left behind so + * somebody owns finding it space. A booking is routinely loaded in parts, and + * before this the customer learnt about it only by reading the sheet. + * + * Best-effort throughout: loading must never roll back because a provider is + * down. + */ +export async function notifyLoadManifest( + dataSource: DataSource, + notifications: NotificationsService, + inbox: NotificationInboxService, + bookingId: string, + trainScheduleId: string, + warehouseNotificationPermission: string, + logger: Logger, +): Promise { + try { + const m = await loadManifestLists(dataSource, bookingId, trainScheduleId); + if (!m) return; + + const route = + m.originStation && m.destinationStation + ? ` ${m.originStation} → ${m.destinationStation}` + : ''; + const departs = m.departureAt + ? `, departs ${new Date(m.departureAt).toLocaleString('en-GB')}` + : ''; + const train = m.trainNumber ? `train ${m.trainNumber}` : 'the train'; + + const headline = + `Booking ${m.reference}: ${m.loaded.length} container(s) loaded on ${train}` + + `${route}${departs}.`; + const loadedLine = m.loaded.length > 0 ? ` Loaded: ${summarizeNumbers(m.loaded)}.` : ''; + const leftLine = + m.leftBehind.length > 0 + ? ` Not loaded (${m.leftBehind.length}): ${summarizeNumbers(m.leftBehind)}.` + + ' These stay with EDR — once a warehouse is assigned you will receive the GRN.' + : ''; + const body = headline + loadedLine + leftLine; + + if (m.companyId) { + await inbox.notify({ + recipients: { companyId: m.companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.BOOKING_STATUS, + title: m.leftBehind.length > 0 ? 'Cargo partly loaded' : 'Cargo loaded', + // The in-app copy carries every number; SMS and email get the summary. + body: + headline + + (m.loaded.length > 0 ? `\nLoaded: ${m.loaded.join(', ')}` : '') + + (m.leftBehind.length > 0 + ? `\nNot loaded: ${m.leftBehind.join(', ')}\nThese stay with EDR — once a warehouse is assigned you will receive the GRN.` + : ''), + link: `/bookings/${bookingId}`, + data: { + bookingId, + reference: m.reference, + trainNumber: m.trainNumber, + loaded: m.loaded, + leftBehind: m.leftBehind, + }, + }); + await sendCompanyChannels(dataSource, notifications, m.companyId, body); + } + + // Nothing left behind is nothing for the warehouse desk to place. + if (m.leftBehind.length > 0) { + await inbox.notify({ + recipients: { permissionKeys: [warehouseNotificationPermission] }, + audience: NotificationAudience.BACKOFFICE, + type: NotificationType.REQUEST_SUBMITTED, + title: `${m.leftBehind.length} container(s) left behind — ${m.reference}`, + body: + `${train} departed without ${m.leftBehind.length} container(s) of booking ${m.reference}: ` + + `${m.leftBehind.join(', ')}. Assign warehouse space and raise the GRN.`, + link: `/dashboard/booking-requests/${bookingId}`, + data: { bookingId, reference: m.reference, leftBehind: m.leftBehind }, + }); + } + } catch (err) { + logger.warn(`Load manifest notify failed for ${bookingId}: ${(err as Error).message}`); + } +} diff --git a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts index 3fbecc036..f035f8a64 100644 --- a/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts +++ b/apps/edr-freight-api/src/modules/rule-engine/rule-engine.service.ts @@ -508,12 +508,12 @@ export class RuleEngineService { if (input.tradeDirection === 'IMPORT') { appliedModifiers.push( - ...this.derivedImportOverweight( + ...(await this.derivedImportOverweight( input, containerWeightResults, lineMaxVgmTons, liveRates, - ), + )), ); } @@ -540,23 +540,37 @@ export class RuleEngineService { } /** - * Import overweight — derived, never configured. Each overweight container - * line bills its excess tons at (its own base import freight on the booking's - * route) ÷ (2 × its weight limit): 20ft at 1000 USD with a 20 t limit → - * 25 USD per excess ton. Export keeps the configured OVERWEIGHT rate. - * Note: derives from the LIVE route rate even for frozen-rate contract - * bookings — the frozen snapshot has no route-scoped container price to - * divide. + * Import overweight — derived, never configured. Excess tons are billed on a + * PER-WAGON basis: (the wagon's base import freight on the booking's route) + * ÷ (2 × the container's weight limit). + * + * The rate is normalised to a wagon before dividing, because a 20ft rate + * quoted PER_CONTAINER prices only HALF a wagon — two 20ft ride one wagon — + * while a 40ft container IS the whole wagon. So a PER_CONTAINER 20ft rate is + * doubled first; 40ft (and any rate already quoted PER_WAGON) is taken as is: + * - 20ft PER_CONTAINER 845 USD, 20 t limit → (845 × 2) / (2 × 20) = 42.25 + * - 40ft PER_CONTAINER 1676 USD, 40 t limit → 1676 / (2 × 40) = 20.95 + * Halving over 2 × the limit keeps the original meaning: filling one wagon's + * worth of excess costs one extra wagon of freight. + * + * Export keeps the configured OVERWEIGHT rate. Note: derives from the LIVE + * route rate even for frozen-rate contract bookings — the frozen snapshot has + * no route-scoped container price to divide. */ - private derivedImportOverweight( + private async derivedImportOverweight( input: BookingEvaluationInput, weightResults: ContainerWeightResult[], lineMaxVgmTons: Array, liveRates: Rate[], - ): AppliedCargoModifier[] { + ): Promise { const modifiers: AppliedCargoModifier[] = []; if (!input.originYardId || !input.destinationYardId) return modifiers; + // How many of each container type ride one wagon: a 40ft fills a wagon, + // two 20ft share one. Keyed by container type so a PER_CONTAINER rate can + // be scaled up to the wagon the overweight formula prices against. + const sizeByTypeId = await this.containersPerWagonByTypeId(weightResults); + for (let i = 0; i < weightResults.length; i++) { const wr = weightResults[i]; const excess = Number(wr?.overweightExcessTons ?? 0); @@ -578,7 +592,16 @@ export class RuleEngineService { // No base rate → the base-freight line hard-blocks this booking anyway. if (!base) continue; - const perTon = Number(base.rateValue) / (2 * maxVgm); + // Normalise the rate to ONE WAGON before dividing. A PER_CONTAINER 20ft + // rate covers half a wagon, so it is scaled by the 2 containers that ride + // one; 40ft scales by 1. A rate already quoted PER_WAGON is the wagon + // price already — never scale it again. + const perWagonRate = + base.rateUnit === 'PER_CONTAINER' + ? Number(base.rateValue) * (sizeByTypeId.get(wr.containerTypeId) ?? 1) + : Number(base.rateValue); + + const perTon = perWagonRate / (2 * maxVgm); const amount = excess * perTon; if (!(amount > 0)) continue; @@ -595,6 +618,42 @@ export class RuleEngineService { return modifiers; } + /** + * Containers of each type that ride ONE wagon, derived from the type's + * size_ft against a 40ft wagon slot: 20ft → 2, 40ft → 1. Only the types the + * caller actually needs are looked up. Unknown or non-positive sizes fall + * back to 1, which leaves a PER_CONTAINER rate unscaled — the pre-existing + * behaviour, so a missing size can never inflate a bill. + */ + private async containersPerWagonByTypeId( + weightResults: ContainerWeightResult[], + ): Promise> { + const perWagon = new Map(); + const ids = [...new Set(weightResults.map((w) => w.containerTypeId).filter(Boolean))]; + if (ids.length === 0) return perWagon; + + let rows: Array<{ id: string; size_ft: string | number | null }> = []; + try { + rows = await this.dataSource.query( + 'SELECT id, size_ft FROM freight.container_types WHERE id = ANY($1)', + [ids], + ); + } catch { + // Size lookup unavailable — fall back to an unscaled (×1) rate, the + // behaviour before per-wagon normalisation. Never fail pricing over it. + return perWagon; + } + const WAGON_SLOT_FT = 40; + for (const row of rows) { + const sizeFt = Number(row.size_ft ?? 0); + perWagon.set( + row.id, + sizeFt > 0 ? Math.max(1, Math.floor(WAGON_SLOT_FT / sizeFt)) : 1, + ); + } + return perWagon; + } + /** * Empty-container return — sold per direction + route + container type, like * base freight. Each container line that opted in (returnQuantity, or every diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts index 07de05853..46cbdb2f6 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-journey.service.ts @@ -31,7 +31,11 @@ import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationInboxService } from '../notification-inbox/notification-inbox.service'; -import { notifyCarriageAcceptanceReady } from '../notifications/notify-company.util'; +import { + notifyCarriageAcceptanceReady, + notifyLoadManifest, +} from '../notifications/notify-company.util'; +import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry'; /** * Per-booking journey along a train's corridor — for EVERY trade direction. @@ -267,6 +271,20 @@ export class BookingJourneyService { }); }); + // What actually boarded, and what did not. A booking is routinely loaded in + // parts; the customer is told both halves, and the warehouse desk is told + // about the leftovers so somebody owns placing them. After the transaction: + // the lists are read back from the allocation statuses it just wrote. + void notifyLoadManifest( + this.dataSource, + this.notifications, + this.inbox, + bookingId, + scheduleId, + FREIGHT_PERMS.warehouseInventory.getNotification, + this.logger, + ); + // Customer tracking: cargo is on the train — loading milestones plus the // direction's "departed" handoff. Doc-trigger path no-ops non-customs // bookings (intercity) and already-completed codes. 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 d6e778216..64af1a56f 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 @@ -1134,7 +1134,7 @@ describe('TrainSchedulingService', () => { expect(html).toContain('2 (1 empty)'); }); - it('marks a leg slot on the import document as TO BE LOADED and keeps it out of the loaded tallies', () => { + it('drops a leg slot entirely from the import document — not part of the departing consist', () => { const loadList = { generatedAt: '2026-07-17T08:00:00.000Z', trainScheduleId: 'schedule-1', @@ -1176,15 +1176,16 @@ describe('TrainSchedulingService', () => { buildImportLoadListHtml: (l: unknown) => string; }).buildImportLoadListHtml(loadList); - expect(html).toContain('TO BE LOADED AT DIRE DAWA PORT'); - // Departure station of the leg slot is its board yard, not the origin. - expect(html).toContain('Dire Dawa Port'); - // Only the origin-loaded container counts; the leg slot's tallies separately. + // The leg slot (W-ICY, boards later at Dire Dawa) gets no row at all — + // it isn't on the departing consist. Only W-IMP appears. + expect(html).not.toContain('W-ICY'); + expect(html).not.toContain('ICY-001'); + expect(html).toContain('W-IMP'); + expect(html).toContain('Wagons1'); expect(html).toContain('Total containers1'); - expect(html).toContain('To load en route1 containers'); }); - it('marks a leg slot on the export document as TO LOAD AT its board yard and keeps it out of the tallies', () => { + it('drops a leg slot entirely from the export document — not part of the departing consist', () => { const sizedAllocation = { ...loadedAllocation, containerItems: [{ containerNumber: 'CONT-001', containerType: { sizeFt: 20 } }], @@ -1204,9 +1205,10 @@ describe('TrainSchedulingService', () => { pendingBoardYardLabelBySlot: new Map([['slot-leg', 'Dire Dawa Port']]), }); - expect(html).toContain('TO LOAD AT DIRE DAWA PORT'); + // The leg slot (W-LEG, boards later at Dire Dawa) gets no row at all. + expect(html).not.toContain('W-LEG'); + expect(html).toContain('Wagons1'); expect(html).toContain('Total containers1'); - expect(html).toContain('To load en route1 containers'); }); it('prints the consist-changes table for this stop, and omits it when there are none', () => { @@ -1240,6 +1242,44 @@ describe('TrainSchedulingService', () => { expect(withoutChanges).not.toContain('Consist Changed At This Stop'); }); + it('shows per-row Departure/Arrival Station — schedule endpoints for a whole-route wagon, its own board/alight yard for a leg slot', () => { + const wholeRoute = { ...makeWagon(1, 'W-001', [loadedAllocation]), id: 'slot-1' }; + const legSlot = { + ...makeWagon(2, 'W-LEG', [loadedAllocation]), + id: 'slot-leg', + boardYardId: 'yard-dire', + alightYardId: 'yard-adama', + }; + const schedule = { + id: 'schedule-1', + trainNumber: '8302', + direction: 'EXPORT', + originStation: { label: 'DCT/SGTD' }, + destinationStation: { label: 'GMP (Gelan Multipurpose Port)' }, + trainSet: { wagons: [wholeRoute, legSlot] }, + scheduleBookings: [], + }; + const build = (service as never as { + buildExportLoadListHtml: (s: unknown, o?: unknown) => string; + }).buildExportLoadListHtml.bind(service); + + const html = build(schedule, { + yardLabelById: new Map([ + ['yard-dire', 'Dire Dawa Port'], + ['yard-adama', 'Adama'], + ]), + }); + + expect(html).toContain('Departure Station'); + expect(html).toContain('Arrival Station'); + // Whole-route wagon: schedule's own endpoints. + expect(html).toContain('DCT/SGTD'); + expect(html).toContain('GMP (Gelan Multipurpose Port)'); + // Leg slot: its own board/alight yard, not the schedule's endpoints. + expect(html).toContain('Dire Dawa Port'); + expect(html).toContain('Adama'); + }); + it('lists loaded empty containers by number and states they are empty', () => { const schedule = { id: 'schedule-1', @@ -1404,6 +1444,30 @@ describe('TrainSchedulingService', () => { expect(numbers).toEqual(['W-LEG2']); }); + it('drops a leg slot LOADED by generation time but not yet coupled as of this stop', () => { + // Both W-DIRE (coupled+loaded at Dire Dawa) and W-ADAMA (coupled+loaded + // at Adama, a LATER stop) read identically to intercityOnBoardView by + // the time this runs — both LOADED right now. Only the adjustment log + // knows W-ADAMA hadn't coupled yet as of Dire Dawa's own timestamp. + const wholeRoute = makeWagon(1, 'W-001', [allocWith({ status: 'LOADED' })]); + const legDireDawa = { ...makeWagon(2, 'W-DIRE', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-dire' }; + const legAdama = { ...makeWagon(3, 'W-ADAMA', [allocWith({ status: 'LOADED' })]), boardYardId: 'yard-adama' }; + const schedule = { trainSet: { wagons: [wholeRoute, legDireDawa, legAdama] }, scheduleBookings: [] }; + + const { wagons } = onBoardView(schedule); + const boardedByDireDawa = new Set(['W-DIRE']); // logged ADD only up to Dire Dawa's stop + const wagonsAsOfStop = (service as never as { + wagonsAsOfStop: (w: unknown, s: Set) => Array<{ physicalWagon: { wagonNumber: string } }>; + }).wagonsAsOfStop.bind(service); + + const asOfDireDawa = wagonsAsOfStop(wagons, boardedByDireDawa); + expect(asOfDireDawa.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE']); + + const boardedByAdama = new Set(['W-DIRE', 'W-ADAMA']); // both stops have now happened + const asOfAdama = wagonsAsOfStop(wagons, boardedByAdama); + expect(asOfAdama.map((w) => w.physicalWagon.wagonNumber)).toEqual(['W-001', 'W-DIRE', 'W-ADAMA']); + }); + it('lists an IN_TRANSIT booking with no wagon allocation in the unassigned section', () => { const rider = { id: 'booking-9', 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 19140d822..07d386df3 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 @@ -28,6 +28,7 @@ import { ILike, In, IsNull, + LessThanOrEqual, Not, QueryFailedError, Raw, @@ -3556,17 +3557,19 @@ export class TrainSchedulingService { // Leg slots couple mid-corridor — this origin document must say where their // cargo boards instead of listing it as loaded here (see the import list). - const slotYardLabels = await this.yardLabelsById( - (schedule.trainSet?.wagons ?? []).map((wagon) => wagon.boardYardId), + // Also doubles as the per-row Departure/Arrival Station lookup below. + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), ); const pendingBoardYardLabelBySlot = new Map( (schedule.trainSet?.wagons ?? []) .filter((wagon) => wagon.boardYardId) - .map((wagon) => [wagon.id, slotYardLabels.get(wagon.boardYardId!) ?? 'en route']), + .map((wagon) => [wagon.id, yardLabelById.get(wagon.boardYardId!) ?? 'en route']), ); const html = this.buildExportLoadListHtml(schedule, { pendingBoardYardLabelBySlot, + yardLabelById, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), }); @@ -3623,6 +3626,24 @@ export class TrainSchedulingService { return { wagons, unassignedBookings }; } + /** + * Corrects intercityOnBoardView's CURRENT-state wagon list against a + * specific stop's document. intercityOnBoardView's "boardYardId == null || + * hasLoaded" test reads whatever is true RIGHT NOW — it can't distinguish + * "this leg slot coupled at THIS stop" from "it coupled at a LATER stop + * that has, by generation time, also already happened" (both look LOADED). + * Reprinting an earlier stop's document after a later one has run would + * otherwise leak the later stop's wagons in. `boardedWagonNumbers` is the + * set of physical wagon numbers with a logged ADD at or before this stop + * (see marshallingDocumentAt) — the ground truth a real-time heuristic + * can't provide once multiple stops have already happened. + */ + private wagonsAsOfStop(wagons: TrainSetWagon[], boardedWagonNumbers: Set): TrainSetWagon[] { + return wagons.filter( + (wagon) => wagon.boardYardId == null || boardedWagonNumbers.has(wagon.physicalWagon?.wagonNumber ?? ''), + ); + } + /** * Every corridor stop where the consist actually changed for this schedule * (coupled, uncoupled, or switched — any flavor), in the order the train @@ -3717,11 +3738,37 @@ export class TrainSchedulingService { ); } - const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const { wagons: currentWagons, unassignedBookings } = this.intercityOnBoardView(schedule); + // intercityOnBoardView's "boardYardId == null || hasLoaded" test reads + // CURRENT state — it can't tell "coupled here" from "coupled at a LATER + // stop that has since also happened" (both look LOADED by generation + // time once the trip has moved past this stop). Reprinting Marshalling 2 + // after Marshalling 3's stop already ran would otherwise show Marshalling + // 3's coupled wagons too. Correct it against the log: a leg-slot wagon + // belongs on THIS stop's document only if it actually has a logged ADD + // at or before THIS stop's own timestamp. + const boardedByThisStop = new Set( + ( + await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ + where: { + trainScheduleId: scheduleId, + action: 'ADD', + occurredAt: LessThanOrEqual(new Date(stop.firstOccurredAt)), + }, + }) + ).map((row) => row.wagonNumber), + ); + const wagons = this.wagonsAsOfStop(currentWagons, boardedByThisStop); const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({ where: { trainScheduleId: scheduleId, yardId: stop.yardId }, order: { occurredAt: 'ASC' }, }); + // Per-row Departure/Arrival Station: a whole-route wagon reads the + // schedule's own origin/destination, a leg-slot wagon reads where IT + // boards/alights instead. + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); const html = this.buildExportLoadListHtml(schedule, { title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`, positionLabel: `At ${stop.yardLabel}`, @@ -3730,6 +3777,7 @@ export class TrainSchedulingService { emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), consistChangesAtStop: this.consistChangesAt(schedule, logRows), + yardLabelById, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`); @@ -3769,6 +3817,9 @@ export class TrainSchedulingService { ? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}` : `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`; const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule); + const yardLabelById = await this.yardLabelsById( + (schedule.trainSet?.wagons ?? []).flatMap((wagon) => [wagon.boardYardId, wagon.alightYardId]), + ); const html = this.buildExportLoadListHtml(schedule, { title: 'Intercity Marshalling Document / Load List (Marshalling 2)', positionLabel, @@ -3776,6 +3827,7 @@ export class TrainSchedulingService { unassignedBookings, emptyContainers: await this.loadedEmptyContainers(scheduleId), logoImageUrl: await this.logoSettings.getLogoImageUrl(), + yardLabelById, }); // Styled table-aware fallback (marshalling grid) — see importLoadListDocument. const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list'); @@ -3845,6 +3897,10 @@ export class TrainSchedulingService { // Slots that couple to the train downstream (slot id → board yard label). // Their cargo renders as TO LOAD AT and stays out of the loaded tallies. pendingBoardYardLabelBySlot?: Map; + // yardId → label, for the per-row Departure/Arrival Station columns + // (falls back to the schedule's own origin/destination when a wagon's + // boardYardId/alightYardId is null — i.e. it rides the whole corridor). + yardLabelById?: Map; // Numbered marshalling docs only (see marshallingDocumentAt / // consistChangesAt) — couples/uncouples/switches logged at THIS stop. // Origin import/export docs never pass this, so they render no such box. @@ -3866,10 +3922,15 @@ export class TrainSchedulingService { const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-'); const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking])); // The document is checked against the physical train, so it has to run in - // consist order — the relation comes back unordered. - const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])].sort( - (a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0), - ); + // consist order — the relation comes back unordered. Slots planned to + // couple at a LATER stop (pendingBoardYardLabelBySlot, origin docs only — + // intercity calls never pass it, their wagons list is already on-board + // only) are dropped here, not just tallied around: they are not part of + // the departing consist, so they get no row and no count on this document. + // Their own coupling shows up on THAT stop's own marshalling document. + const wagons = [...(opts?.wagons ?? schedule.trainSet?.wagons ?? [])] + .filter((wagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id)) + .sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0)); // Empties sit on wagons that carry no booking allocation, keyed by the wagon // slot recorded when they were loaded. const emptiesByWagon = new Map(); @@ -3880,15 +3941,28 @@ export class TrainSchedulingService { empty, ]); } + const originLabel = schedule.originStation?.label ?? schedule.originStation?.code; + const destinationLabel = schedule.destinationStation?.label ?? schedule.destinationStation?.code; const rows = wagons .flatMap((wagon) => { + // Departure/Arrival Station per row: a leg-slot wagon boards/alights + // somewhere other than the schedule's own endpoints; a whole-route + // wagon just reads origin/destination. + const departureLabel = wagon.boardYardId + ? (opts?.yardLabelById?.get(wagon.boardYardId) ?? 'en route') + : originLabel; + const arrivalLabel = wagon.alightYardId + ? (opts?.yardLabelById?.get(wagon.alightYardId) ?? 'en route') + : destinationLabel; // Wagon identity is the same on every row the wagon produces, loaded or not. const wagonCells = `${esc(wagon.sequenceNo)} ${esc(wagon.physicalWagon?.wagonNumber)} ${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)} ${esc(Number(wagon.lengthMeters || 0).toFixed(3))} ${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))} - ${esc(Number(wagon.capacityTons || 0).toFixed(3))}`; + ${esc(Number(wagon.capacityTons || 0).toFixed(3))} + ${esc(departureLabel)} + ${esc(arrivalLabel)}`; const allocations = wagon.allocations ?? []; // An empty wagon still runs in the consist, so it still gets a line. Staff // check this document against the physical train — a wagon with no row @@ -3912,11 +3986,10 @@ export class TrainSchedulingService { return [ ` ${wagonCells} - EMPTY — no cargo allocated + EMPTY — no cargo allocated `, ]; } - const pendingAt = opts?.pendingBoardYardLabelBySlot?.get(wagon.id); return allocations.map((allocation) => { const booking = allocation.booking ?? bookingById.get(allocation.bookingId); const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType; @@ -3928,7 +4001,7 @@ export class TrainSchedulingService { const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', '); return ` ${wagonCells} - ${pendingAt ? `TO LOAD AT ${esc(pendingAt).toUpperCase()} — ` : ''}${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} + ${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)} ${esc(companyName)} ${esc(containerNumbers || firstContainer?.containerNumber)} ${esc(chassisNumbers)} @@ -3941,7 +4014,7 @@ export class TrainSchedulingService { // they are still physically on the train, so they get rows of their own. const unassigned = opts?.unassignedBookings ?? []; const unassignedRows = unassigned.length - ? `ON BOARD — WAGON NOT RECORDED` + + ? `ON BOARD — WAGON NOT RECORDED` + unassigned .map((booking) => { const containerNumbers = (booking.bookingContainers ?? []) @@ -3950,7 +4023,7 @@ export class TrainSchedulingService { .join(', '); const leg = `${booking.originYard?.label ?? booking.originYard?.code ?? '-'} → ${booking.destinationYard?.label ?? booking.destinationYard?.code ?? '-'}`; return ` - ${esc(booking.reference)} — ${esc(leg)} + ${esc(booking.reference)} — ${esc(leg)} ${esc(booking.cargoType?.cargoTypeName ?? booking.cargoType?.code)} ${esc(booking.company?.name)} ${esc(containerNumbers)} @@ -3965,27 +4038,20 @@ export class TrainSchedulingService { (wagon.allocations ?? []).length === 0 && !emptiesByWagon.get(Number(wagon.sequenceNo))?.length, ).length; - const loadsHere = (wagon: TrainSetWagon) => !opts?.pendingBoardYardLabelBySlot?.get(wagon.id); const totalWeight = wagons.reduce( (sum, wagon) => - sum + - (loadsHere(wagon) - ? (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) - : 0), + sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); // Container count summary (40ft, 20ft) — empties returning to Djibouti are - // physically on the train, so they count, and are called out on their own tile. - // Cargo boarding downstream is not on this train yet — it tallies separately. - let count40ft = 0, count20ft = 0, pendingContainers = 0; + // physically on the train, so they count, and are called out on their own + // tile. Cargo boarding downstream never enters this loop — `wagons` above + // already excludes those slots. + let count40ft = 0, count20ft = 0; wagons.forEach((wagon) => { (wagon.allocations ?? []).forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { - if (!loadsHere(wagon)) { - pendingContainers++; - return; - } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -4053,7 +4119,6 @@ export class TrainSchedulingService {
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
- ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''} ${emptyContainers.length ? `
Empty containers${esc(emptyContainers.length)}
` : ''}
Prepared person${esc(schedule.preparedByUserId)}
Check person${esc(schedule.checkedByUserId)}
@@ -4099,6 +4164,8 @@ export class TrainSchedulingService { Equated Length Tare Weight Load Capacity + Departure Station + Arrival Station Cargo Type Company Container No @@ -4107,7 +4174,7 @@ export class TrainSchedulingService { - ${rows || 'No wagons on this train set.'} + ${rows || 'No wagons on this train set.'} ${unassignedRows} @@ -4253,30 +4320,23 @@ export class TrainSchedulingService { .replace(/'/g, '''); const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-'); const status = loadList.operation.status; - // A leg slot (boardYard set) couples mid-corridor — its cargo is NOT on the - // physical train this Djibouti-side document is checked against, so it must - // stay out of the loaded tallies or the gate count stops matching. - const loadsHere = (wagon: (typeof loadList.wagons)[number]) => !wagon.boardYard; - const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); - const totalWeight = loadList.wagons.reduce( - (sum, wagon) => - sum + - (loadsHere(wagon) - ? wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0) - : 0), + // A leg slot (boardYard set) couples mid-corridor — it is not part of the + // consist this Djibouti-side document is checked against yet, so it gets + // no row and no count here at all. Its own coupling shows up on THAT + // stop's own marshalling document once it actually happens. + const wagons = loadList.wagons.filter((wagon) => !wagon.boardYard); + const totalAllocations = wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0); + const totalWeight = wagons.reduce( + (sum, wagon) => sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0), 0, ); - const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length; + const emptyWagons = wagons.filter((wagon) => wagon.allocations.length === 0).length; - // Container count summary (40ft, 20ft) — loaded at origin vs. en route - let count40ft = 0, count20ft = 0, pendingContainers = 0; - loadList.wagons.forEach((wagon) => { + // Container count summary (40ft, 20ft) + let count40ft = 0, count20ft = 0; + wagons.forEach((wagon) => { wagon.allocations.forEach((allocation) => { (allocation.containerItems ?? []).forEach((item) => { - if (!loadsHere(wagon)) { - pendingContainers++; - return; - } const size = this.resolveContainerItemSize(item); if (size === 40) count40ft++; else if (size === 20) count20ft++; @@ -4284,7 +4344,7 @@ export class TrainSchedulingService { }); }); - const allocationRows = loadList.wagons + const allocationRows = wagons .flatMap((wagon) => { const wagonCells = `${esc(wagon.sequenceNo)} ${esc(wagon.wagonNumber)} @@ -4317,7 +4377,7 @@ export class TrainSchedulingService { ${esc(allocation.loadType)} ${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')} ${esc(sealNumbers || '-')} - ${wagon.boardYard ? `TO BE LOADED AT ${esc(wagon.boardYard).toUpperCase()}` : ''} + ${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))} `; }, @@ -4384,13 +4444,12 @@ export class TrainSchedulingService {
Origin${esc(loadList.origin)}
Destination${esc(loadList.destination)}
Total bookings${esc(loadList.totalBookings)}
-
Wagons${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
+
Wagons${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}
Allocations${esc(totalAllocations)}
Total weight${esc(totalWeight.toFixed(3))} T
Containers 40ft${esc(count40ft)}
Containers 20ft${esc(count20ft)}
Total containers${esc(count40ft + count20ft)}
- ${pendingContainers ? `
To load en route${esc(pendingContainers)} containers
` : ''}
Gatepass granted${esc(date(loadList.operation.gatepassGrantedAt))}
diff --git a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts index 7b28a8582..26c29499d 100644 --- a/apps/edr-freight-api/src/modules/trains/train-builder.service.ts +++ b/apps/edr-freight-api/src/modules/trains/train-builder.service.ts @@ -19,6 +19,7 @@ import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.uti import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; import { TrainSet } from '../train-sets/entities/train-set.entity'; +import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; import { WagonType } from '../wagon-types/entities/wagon-type.entity'; import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; @@ -504,7 +505,7 @@ export class TrainBuilderService { if (locomotiveIds.length < 1) { throw new BadRequestException('A train must be pulled by at least one locomotive'); } - await this.dataSource.transaction(async (manager) => { + const pending = await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); const yard = await manager .getRepository(Yard) @@ -523,10 +524,76 @@ export class TrainBuilderService { await manager .getRepository(Train) .update(train.id, { capacityTons: round(limits?.maxPullWeightTons ?? 0) }); + // Capacity math reads the SET's locomotives, not the train's — push the + // new pull weight onto the live runs too, or they keep the old ceiling. + return this.syncLiveSchedulesAfterLocomotiveChange(manager, train.id, locomotiveIds); }); + // Re-derive FULL/reopen once committed — a bigger pull weight can free room + // on a schedule that had closed as FULL. + await this.reconcileWindowsAfterConsistChange(pending); return this.getComposition(id); } + /** + * Mirror a built train's locomotive change onto every LIVE (DRAFT/SCHEDULED) + * schedule formed from it. The three capacity axes are derived from + * `train_set_locomotives` (see trainSetLocomotiveLimits), which is snapshotted + * when the set is built and never re-synced — so adding a second locomotive + * raised `trains.capacity_tons` but left every existing schedule pulling on + * the old single-loco ceiling, still refusing bookings for want of weight. + * + * Only DRAFT/SCHEDULED runs follow the live train; DISPATCHED/ARRIVED render + * from their frozen snapshot and must not be disturbed (same rule as + * syncLiveScheduleAfterConsistChange). + */ + private async syncLiveSchedulesAfterLocomotiveChange( + manager: EntityManager, + trainId: string, + locomotiveIds: string[], + ): Promise { + const trainSets = await manager.getRepository(TrainSet).find({ where: { trainId } }); + if (!trainSets.length) return []; + + const schedules = await manager.getRepository(TrainSchedule).find({ + where: { + trainSetId: In(trainSets.map((s) => s.id)), + status: In(['DRAFT', 'SCHEDULED']), + }, + }); + if (!schedules.length) return []; + + // Only the sets still backing a live run — a set behind an ARRIVED schedule + // keeps the locomotives it actually ran with. + const liveSetIds = [...new Set(schedules.map((s) => s.trainSetId))]; + const [primaryId] = locomotiveIds; + for (const trainSetId of liveSetIds) { + await manager.getRepository(TrainSetLocomotive).delete({ trainSetId }); + await manager.getRepository(TrainSetLocomotive).save( + locomotiveIds.map((locomotiveId, index) => + manager + .getRepository(TrainSetLocomotive) + .create({ trainSetId, locomotiveId, sequenceNo: index }), + ), + ); + // `locomotiveId` is the primary-locomotive fallback for single-loco reads. + await manager.getRepository(TrainSet).update(trainSetId, { locomotiveId: primaryId }); + } + + return schedules.map((s) => ({ + scheduleId: s.id, + wasFull: s.bookingWindowStatus === 'FULL', + })); + } + + /** {@link reconcileWindowAfterConsistChange} over several schedules. */ + private async reconcileWindowsAfterConsistChange( + pending: PendingWindowCheck[], + ): Promise { + for (const check of pending) { + await this.reconcileWindowAfterConsistChange(check); + } + } + /** * Edit a built train's display identity: name and fixed import/export run * numbers. Mirrors the build-time number rules — the pair may not collide diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index aa032c17d..43e7d7ae8 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -1509,6 +1509,7 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; warehouseId?: string | null; + bookingId?: string | null; }; booking: { companyId?: string | null; @@ -1730,6 +1731,7 @@ export class WarehouseInventoryService { grnNumber, direction: dto.direction, warehouseId: dto.warehouseId, + bookingId, }, booking, bookingId, @@ -3107,6 +3109,7 @@ export class WarehouseInventoryService { grnNumber, direction: bookingDirection, warehouseId: dto.warehouseId, + bookingId: dto.bookingId ?? null, }); return saved.id; @@ -6614,10 +6617,9 @@ export class WarehouseInventoryService { grnNumber: string; direction?: string | null; warehouseId?: string | null; + /** Resolves the company, which unlocks in-app + email alongside the SMS. */ + bookingId?: string | null; }): Promise { - const phone = params.phone?.trim(); - if (!phone) return; - const ownerName = params.ownerName?.trim() || 'Customer'; const bookingReference = params.bookingReference?.trim(); const message = @@ -6627,6 +6629,47 @@ export class WarehouseInventoryService { (params.direction ? `Direction: ${params.direction}. ` : '') + `Thank you.`; + // A booking gives us the company, and with it the customer's inbox and + // email — not just whatever phone number the gate clerk typed. Without one + // (manual or backlog receive) the typed phone is all there is, so the + // original SMS-only path stands. + let companyId: string | null = null; + if (params.bookingId) { + try { + const [row]: Array<{ companyId: string | null }> = await this.dataSource.query( + `SELECT company_id AS "companyId" + FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL`, + [params.bookingId], + ); + companyId = row?.companyId ?? null; + } catch (error) { + this.logger.warn(`GRN ${params.grnNumber}: company lookup failed: ${String(error)}`); + } + } + + if (companyId) { + try { + await this.inbox.notify({ + recipients: { companyId }, + audience: NotificationAudience.PORTAL, + type: NotificationType.DOCUMENT_ACTION, + title: 'Cargo received — GRN issued', + body: message, + link: params.bookingId ? `/bookings/${params.bookingId}` : undefined, + data: { grnNumber: params.grnNumber, bookingId: params.bookingId ?? null }, + }); + // Sends SMS *and* email to the company's own contacts, so the typed + // phone below is skipped to avoid texting the customer twice. + await sendCompanyChannels(this.dataSource, this.notifications, companyId, message); + return; + } catch (error) { + this.logger.error(`Failed to notify company for GRN ${params.grnNumber}: ${String(error)}`); + } + } + + const phone = params.phone?.trim(); + if (!phone) return; try { await this.notifications.directSend('sms', phone, message); } catch (error) { diff --git a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts index 90a7310c8..2a8ac578e 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -465,6 +465,13 @@ export const CONTRACT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:contracts:suspend", "Suspend / resume a signed contract", ), + // Terminal kill switch. Unlike suspend this cannot be undone — the customer + // re-submits a fresh contract with the same parameters instead. + perm( + "a3000001-0001-4000-8000-00000000001c", + "edr_freight_app:contracts:cancel", + "Cancel a contract (terminal)", + ), ]; // Historical ids. EdrOrgSeeder no longer sends them — it upserts on `key` and @@ -1909,6 +1916,11 @@ export const NOTIFICATION_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:additional_charges:get_notification", "Receive additional charge notifications", ), + perm( + "f3a00001-0001-4000-8000-00000000000a", + "edr_freight_app:warehouse_inventory:get_notification", + "Receive warehouse desk notifications (containers left behind at loading)", + ), ]; export const ADVANCED_BACKOFFICE_PERMISSIONS: FreightPermissionSeed[] = [ @@ -2061,6 +2073,7 @@ export const FREIGHT_PERMS = { clearanceDjActions: "edr_freight_app:contracts:clearance_dj_actions", clearanceDutyAdvise: "edr_freight_app:contracts:clearance_duty_advise", suspend: "edr_freight_app:contracts:suspend", + cancel: "edr_freight_app:contracts:cancel", editDocument: "edr_freight_app:contracts:edit_document", finalInvoiceRaise: "edr_freight_app:contracts:final_invoice_raise", finalInvoiceConfirm: "edr_freight_app:contracts:final_invoice_confirm", @@ -2375,6 +2388,12 @@ export const FREIGHT_PERMS = { release: "edr_freight_app:warehouse_inventory:release", deliver: "edr_freight_app:warehouse_inventory:deliver", inspect: "edr_freight_app:warehouse_inventory:inspect", + /** + * Notification selector, not a route guard — who gets pinged when cargo is + * left behind at loading and needs warehouse space. Assign it to whichever + * desk owns that; it grants access to nothing. + */ + getNotification: "edr_freight_app:warehouse_inventory:get_notification", }, interchangeDocuments: { view: "edr_freight_app:interchange_documents:view", @@ -2865,6 +2884,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.contracts.generateContract, ...bothFreightTypes(FREIGHT_PERMS.contracts.signStaff), FREIGHT_PERMS.contracts.suspend, + // Terminal kill switch, granted alongside suspend on the same desk that + // already rejects contracts and cancels bookings. + FREIGHT_PERMS.contracts.cancel, FREIGHT_PERMS.contracts.editDocument, ...BOOKING_DESK_NOTIFICATION_KEYS, // Marketing follows up with the customer when a reviewer sends profile diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx index d2a245f24..be70ffe4d 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/ContractActionsToolbar.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Button, Group, Modal, Stack, Text, Textarea } from "@mantine/core"; import { + Ban, Check, Eye, // FilePen, // ponytail: back with the "Edit contract articles" button @@ -81,6 +82,8 @@ export function ContractActionsToolbar({ const mayReject = hasPermission(user, FREIGHT_PERMS.contracts.reject[arm]); // One key both ways — whoever can freeze a contract can unfreeze it. const maySuspend = hasPermission(user, FREIGHT_PERMS.contracts.suspend); + // Cancel is its own key — it is terminal, so it is NOT implied by suspend. + const mayCancel = hasPermission(user, FREIGHT_PERMS.contracts.cancel); const [editorOpen, setEditorOpen] = useState(false); const [editorMode, setEditorMode] = useState<"accept" | "edit">("accept"); @@ -93,6 +96,67 @@ export function ContractActionsToolbar({ const [suspendReason, setSuspendReason] = useState(""); const [resumeOpen, setResumeOpen] = useState(false); const [resumeNote, setResumeNote] = useState(""); + const [cancelOpen, setCancelOpen] = useState(false); + const [cancelReason, setCancelReason] = useState(""); + + // Shared by the suspended branch and the normal toolbar — both can cancel. + const cancelModal = ( + setCancelOpen(false)} + title="Cancel this contract?" + centered + > + + + Contract {contract.reference} will be cancelled permanently. + This cannot be undone — there is no way to reactivate it. A new + contract with the same details can be submitted afterwards. The + customer is notified. + +