From c723b660e254f5a13eca109872a46111b83e2d38 Mon Sep 17 00:00:00 2001 From: Marshal Date: Tue, 18 Aug 2026 08:27:18 +0000 Subject: [PATCH] feat(freight): offer built-train wagons per boarding yard on multi-yard consists --- apps/edr-freight-api/src/app.module.ts | 2 + .../3560000000000-ManualPaymentSettings.ts | 36 +++ .../modules/billing/billing.service.spec.ts | 11 + .../src/modules/billing/billing.service.ts | 30 +- .../dto/update-manual-payment-setting.dto.ts | 18 ++ .../entities/manual-payment-setting.entity.ts | 26 ++ .../manual-payment-settings.controller.ts | 47 +++ .../manual-payment-settings.service.ts | 71 +++++ .../payment-settings.module.ts | 20 ++ .../train-scheduling/booking-batch.service.ts | 16 +- .../services/train-scheduling.service.ts | 109 ++++++- .../train-scheduling/wagon-plan-flex.util.ts | 10 + .../wagon-stock-ledger.util.spec.ts | 91 ++++++ .../wagon-stock-ledger.util.ts | 45 ++- .../src/modules/trains/dto/build-train.dto.ts | 2 +- .../modules/trains/train-builder.service.ts | 58 +++- .../src/modules/wagons/wagons.service.ts | 9 +- .../src/seed/freight-permissions.registry.ts | 21 ++ apps/edr-freight-web/backoffice/src/App.tsx | 13 + .../components/layout/sidebar-sections.tsx | 5 + .../trainBuilder/AvailableWagonsPanel.tsx | 162 +++++++---- .../trainBuilder/ConsistWagonList.tsx | 7 +- .../backoffice/src/constants/URLS.ts | 4 + .../src/hooks/useManualPaymentSettings.ts | 39 +++ .../backoffice/src/lib/permissions.ts | 6 + .../src/pages/invoices/FinanceHubPage.tsx | 40 ++- .../src/pages/invoices/UsdPaymentsPage.tsx | 66 ++--- .../settings/ManualPaymentSettingsCard.tsx | 132 +++++++++ .../trainBuilder/TrainBuilderDetailPage.tsx | 31 +- .../services/manualPaymentSettings.service.ts | 36 +++ .../src/services/trainBuilder.service.ts | 11 + .../portal/src/hooks/useAuth.ts | 19 +- .../src/pages/contracts/ContractViewPage.tsx | 272 +++++++++++++----- 33 files changed, 1234 insertions(+), 231 deletions(-) create mode 100644 apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts create mode 100644 apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts create mode 100644 apps/edr-freight-web/backoffice/src/hooks/useManualPaymentSettings.ts create mode 100644 apps/edr-freight-web/backoffice/src/pages/settings/ManualPaymentSettingsCard.tsx create mode 100644 apps/edr-freight-web/backoffice/src/services/manualPaymentSettings.service.ts diff --git a/apps/edr-freight-api/src/app.module.ts b/apps/edr-freight-api/src/app.module.ts index d38c6ea92..d0ab805a8 100644 --- a/apps/edr-freight-api/src/app.module.ts +++ b/apps/edr-freight-api/src/app.module.ts @@ -50,6 +50,7 @@ import { SupportChatModule } from "./modules/support-chat/support-chat.module"; import { FileUploadSettingsModule } from "./modules/file-upload-settings/file-upload-settings.module"; import { DropdownSettingsModule } from "./modules/dropdown-settings/dropdown-settings.module"; import { ExchangeSettingsModule } from "./modules/exchange-settings/exchange-settings.module"; +import { PaymentSettingsModule } from "./modules/payment-settings/payment-settings.module"; import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.module"; import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module"; import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module"; @@ -213,6 +214,7 @@ if (!process.env.APPLICATION_NAME) { FileUploadSettingsModule, DropdownSettingsModule, ExchangeSettingsModule, + PaymentSettingsModule, StampSettingsModule, LogoSettingsModule, ContractTemplatesModule, diff --git a/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts new file mode 100644 index 000000000..0c4f5698f --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3560000000000-ManualPaymentSettings.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Single-row table controlling whether Finance may settle invoices by hand, + * per currency (see ManualPaymentSettingsService). Defaults preserve the + * pre-toggle behaviour: USD was always bank-transfer-only (ON), ETB manual + * settlement is the new capability and must be switched on deliberately (OFF). + */ +export class ManualPaymentSettings3560000000000 implements MigrationInterface { + name = "ManualPaymentSettings3560000000000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS freight.manual_payment_settings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + etb_enabled boolean NOT NULL DEFAULT false, + usd_enabled boolean NOT NULL DEFAULT true, + updated_by_id uuid, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz + ); + `); + await queryRunner.query(` + INSERT INTO freight.manual_payment_settings (etb_enabled, usd_enabled) + SELECT false, true + WHERE NOT EXISTS (SELECT 1 FROM freight.manual_payment_settings); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP TABLE IF EXISTS freight.manual_payment_settings;`, + ); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts index 243b1ee4d..c5cc9a81e 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.spec.ts @@ -81,6 +81,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); }); @@ -163,6 +164,7 @@ describe("BillingService.issueMemo", () => { {} as never, {} as never, { get: () => undefined } as never, + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, manager, savedLines }; } @@ -297,6 +299,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -352,6 +355,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -397,6 +401,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, mg, events }; } @@ -510,6 +515,7 @@ describe("BillingService.recordPayment", () => { {} as never, // invoiceDocuments {} as never, // files { get: () => undefined } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, mg, events }; } @@ -627,6 +633,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, defaultManager, txManager, transaction }; }; @@ -700,6 +707,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, manager }; }; @@ -791,6 +799,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; @@ -874,6 +883,7 @@ describe("BillingService — CBE bill amounts carry cents, never rounded", () => {} as never, {} as never, {} as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, repo }; }; @@ -943,6 +953,7 @@ describe("BillingService.document", () => { ? { tin: "0053481357", invoice: { sellerVatNumber: "43256663343256663322" } } : undefined, } as never, // config + { isEnabled: async () => true, enabledCurrencies: async () => ["ETB", "USD"] } as never, // manualPaymentSettings ); return { service, render, renderThermal }; }; 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 44e194a8d..d64361bf8 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -17,6 +17,7 @@ import { Booking } from "../bookings/entities/booking.entity"; // payers straight off the table. import { ShippingLineCompany } from "../shipping-lines/entities/shipping-line-company.entity"; import { ShippingLineCredit } from "../shipping-lines/entities/shipping-line-credit.entity"; +import { ManualPaymentSettingsService } from "../payment-settings/manual-payment-settings.service"; import { EimsConfig } from "../../config/eims.config"; import { CompaniesService } from "../companies/companies.service"; import { EimsInvoiceStatus } from "../eims/eims-registration.types"; @@ -201,6 +202,7 @@ export class BillingService { private readonly invoiceDocuments: InvoiceDocumentService, private readonly files: FilesService, private readonly config: ConfigService, + private readonly manualPaymentSettings: ManualPaymentSettingsService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -370,20 +372,24 @@ export class BillingService { const pageSize = filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + // Only currencies whose manual-payment channel is switched on are listed: + // a row Finance cannot act on is noise, and the confirm endpoint would + // refuse it anyway. All off → nothing to work. + const enabled = await this.manualPaymentSettings.enabledCurrencies(); + if (!enabled.length) return { items: [], total: 0 }; + const currencies = filter.currency + ? enabled.filter((c) => c === filter.currency) + : enabled; + if (!currencies.length) return { items: [], total: 0 }; + const qb = this.dataSource .getRepository(Invoice) .createQueryBuilder("invoice") .leftJoinAndSelect("invoice.company", "company") - .where("UPPER(invoice.currency) IN ('USD', 'ETB')") + .where("UPPER(invoice.currency) IN (:...currencies)", { currencies }) .orderBy("invoice.issuedAt", "DESC") .skip((page - 1) * pageSize) .take(pageSize); - - if (filter.currency) { - qb.andWhere("UPPER(invoice.currency) = :currency", { - currency: filter.currency, - }); - } if (filter.status) { qb.andWhere("invoice.status = :status", { status: filter.status }); } else { @@ -465,7 +471,8 @@ export class BillingService { /** * Finance confirms an invoice (USD or ETB) as paid manually — bank transfer - * or counter payment: stores the slip against the invoice and settles the + * or counter payment. Refused when that currency's manual-payment channel is + * switched off in settings. Stores the slip against the invoice and settles the * FULL outstanding balance through * {@link recordPayment}, which flips the invoice to PAID and (for bookings) * emits `booking.invoice.paid` — the same event an online payment fires, so @@ -485,6 +492,13 @@ export class BillingService { ): Promise { const invoice = await this.invoices.findById(invoiceId); if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + // The channel is a setting, not a role: even a permitted user cannot + // settle by hand in a currency whose channel is switched off. + if (!(await this.manualPaymentSettings.isEnabled(invoice.currency))) { + throw new BadRequestException( + `Manual payment is disabled for ${invoice.currency ?? "this"} invoices. Enable it in Configuration → Manual payments first.`, + ); + } if (!file) { throw new BadRequestException("The bank payment slip file is required."); } diff --git a/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts new file mode 100644 index 000000000..971de03cb --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/dto/update-manual-payment-setting.dto.ts @@ -0,0 +1,18 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional } from "class-validator"; + +/** + * Partial update: the UI flips one currency at a time, so an omitted field + * leaves that currency's channel exactly as it was. + */ +export class UpdateManualPaymentSettingDto { + @ApiPropertyOptional({ description: "Allow manual settlement of ETB invoices" }) + @IsOptional() + @IsBoolean() + etbEnabled?: boolean; + + @ApiPropertyOptional({ description: "Allow manual settlement of USD invoices" }) + @IsOptional() + @IsBoolean() + usdEnabled?: boolean; +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts new file mode 100644 index 000000000..a18f97279 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/entities/manual-payment-setting.entity.ts @@ -0,0 +1,26 @@ +import { BaseEntity } from "@edr/api-common"; +import { Column, Entity } from "typeorm"; + +/** + * Single-row table controlling whether Finance may settle invoices by hand + * (bank transfer / counter payment) instead of the customer paying online. + * + * Per currency on purpose: the two channels are operationally different — USD + * bookings have always been bank-transfer-only, while ETB normally goes + * through the gateway and manual settlement is the exception. Switching one + * off must not switch off the other. + */ +@Entity({ schema: "freight", name: "manual_payment_settings" }) +export class ManualPaymentSetting extends BaseEntity { + /** Manual settlement allowed for ETB invoices. */ + @Column({ name: "etb_enabled", type: "boolean", default: false }) + etbEnabled!: boolean; + + /** Manual settlement allowed for USD invoices. */ + @Column({ name: "usd_enabled", type: "boolean", default: true }) + usdEnabled!: boolean; + + /** IAM user id of the last operator to change either toggle. */ + @Column({ name: "updated_by_id", type: "uuid", nullable: true }) + updatedById?: string | null; +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts new file mode 100644 index 000000000..786d1f7ca --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.controller.ts @@ -0,0 +1,47 @@ +import { Body, Controller, Get, Patch } from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { CurrentUser } from "@edr/api-common"; +import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type"; + +import { BookingStaff } from "../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { UpdateManualPaymentSettingDto } from "./dto/update-manual-payment-setting.dto"; +import { ManualPaymentSettingsService } from "./manual-payment-settings.service"; + +@ApiTags("payment-settings") +@ApiBearerAuth() +@Controller("payment-settings/manual") +export class ManualPaymentSettingsController { + constructor(private readonly service: ManualPaymentSettingsService) {} + + /** + * Read is gated on `manual_payment:view`, which Finance also holds — the + * Manual Payments worklist reads this to know which currency tabs to offer. + */ + @Get() + @BookingStaff([ + FREIGHT_PERMS.settings.manualPayment.view, + FREIGHT_PERMS.admin, + ]) + @ApiOperation({ + summary: "Whether manual (offline) invoice settlement is enabled, per currency", + }) + get() { + return this.service.get(); + } + + @Patch() + @BookingStaff([ + FREIGHT_PERMS.settings.manualPayment.manage, + FREIGHT_PERMS.admin, + ]) + @ApiOperation({ + summary: "Enable or disable manual invoice settlement for ETB and/or USD", + }) + update( + @Body() dto: UpdateManualPaymentSettingDto, + @CurrentUser() user: TCurrentUser, + ) { + return this.service.update(dto, user?.id ?? null); + } +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts new file mode 100644 index 000000000..efb43fa91 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/manual-payment-settings.service.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; + +/** The two currencies an invoice can be settled by hand in. */ +export type ManualPaymentCurrency = "ETB" | "USD"; + +/** + * Owns the single `manual_payment_settings` row: whether Finance may settle + * invoices by hand, per currency. + * + * Defaults mirror how the platform behaved before the toggles existed — USD + * has always been bank-transfer-only so it starts ON; ETB manual settlement is + * the new capability and starts OFF, so enabling it is a deliberate act. + */ +@Injectable() +export class ManualPaymentSettingsService { + private readonly logger = new Logger(ManualPaymentSettingsService.name); + + constructor( + @InjectRepository(ManualPaymentSetting) + private readonly repository: Repository, + ) {} + + /** The settings row, created at the defaults on first access. */ + async get(): Promise { + const existing = await this.repository.findOne({ where: {} }); + if (existing) return existing; + + return this.repository.save( + this.repository.create({ etbEnabled: false, usdEnabled: true }), + ); + } + + /** Currencies manual settlement is currently allowed for. */ + async enabledCurrencies(): Promise { + const setting = await this.get(); + const enabled: ManualPaymentCurrency[] = []; + if (setting.etbEnabled) enabled.push("ETB"); + if (setting.usdEnabled) enabled.push("USD"); + return enabled; + } + + /** Whether one currency may be settled by hand right now. */ + async isEnabled(currency: string | null | undefined): Promise { + const upper = currency?.toUpperCase(); + if (upper !== "ETB" && upper !== "USD") return false; + const setting = await this.get(); + return upper === "ETB" ? setting.etbEnabled : setting.usdEnabled; + } + + /** Flip either toggle; an omitted field leaves that currency unchanged. */ + async update( + patch: { etbEnabled?: boolean; usdEnabled?: boolean }, + updatedById?: string | null, + ): Promise { + const current = await this.get(); + await this.repository.update(current.id, { + ...(patch.etbEnabled === undefined ? {} : { etbEnabled: patch.etbEnabled }), + ...(patch.usdEnabled === undefined ? {} : { usdEnabled: patch.usdEnabled }), + updatedById: updatedById ?? null, + }); + const updated = await this.get(); + this.logger.warn( + `Manual payment channels set to ETB=${updated.etbEnabled} USD=${updated.usdEnabled} by ${updatedById ?? "unknown user"}`, + ); + return updated; + } +} diff --git a/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts b/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts new file mode 100644 index 000000000..bcadfe340 --- /dev/null +++ b/apps/edr-freight-api/src/modules/payment-settings/payment-settings.module.ts @@ -0,0 +1,20 @@ +import { Global, Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ManualPaymentSetting } from "./entities/manual-payment-setting.entity"; +import { ManualPaymentSettingsController } from "./manual-payment-settings.controller"; +import { ManualPaymentSettingsService } from "./manual-payment-settings.service"; + +/** + * Global so billing can inject {@link ManualPaymentSettingsService} to gate + * the manual-settlement worklist and confirmation endpoint without importing + * this module (and without a cycle, since this module needs nothing back). + */ +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([ManualPaymentSetting])], + controllers: [ManualPaymentSettingsController], + providers: [ManualPaymentSettingsService], + exports: [ManualPaymentSettingsService], +}) +export class PaymentSettingsModule {} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts index 4418eed3d..cb1958582 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-batch.service.ts @@ -1221,13 +1221,21 @@ export class BookingBatchService implements OnModuleInit { const ledger = new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), + stock.byYardId, + budget.stops, ); + // On a multi-yard consist the pool that matters is the one standing at + // the booking's own boarding yard — a type carried only in Mojo must not + // be advertised to a customer boarding at Dire. + const carriedAtBoardYard = (wagonTypeId: string): number => { + const boardYardId = stock.byYardId ? budget.stops[leg.fromEdge] : null; + if (boardYardId) return stock.byYardId?.get(boardYardId)?.get(wagonTypeId) ?? 0; + return stock.remainingByTypeId.get(wagonTypeId) ?? 0; + }; const byWagonType = allowed .filter( ({ wagonTypeId }) => - stock.mode !== 'TRAIN' || - !wagonTypeId || - (stock.remainingByTypeId.get(wagonTypeId) ?? 0) > 0, + stock.mode !== 'TRAIN' || !wagonTypeId || carriedAtBoardYard(wagonTypeId) > 0, ) .map(({ wagonTypeId, dims }) => { const type = wagonTypeId ? typeById.get(wagonTypeId) : undefined; @@ -4783,6 +4791,8 @@ export class BookingBatchService implements OnModuleInit { return new WagonStockLedger( stock.remainingByTypeId, Math.max(1, budget.stops.length - 1), + stock.byYardId, + budget.stops, ); } 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 03535135d..9a20c0486 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 @@ -1589,6 +1589,7 @@ export class TrainSchedulingService { `Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`, ); } + await this.assertRouteCoversWagonYards(builtTrain, route); const conflict = await this.findTrainRouteDayConflict( builtTrain.id, route.id, @@ -4274,12 +4275,26 @@ export class TrainSchedulingService { .getRepository(Locomotive) .update({ id: In(locoIds) }, { currentYardId: station.yardId }); } + // Only wagons the train has actually COLLECTED move with it. On a + // consist spread across yards (20 in Dire, 33 in Mojo), reaching Mojo + // moves the Dire wagons — the ones already aboard — and picks up the + // Mojo ones standing here. Wagons waiting at yards further down the + // line stay where they are until the train physically gets to them. + const passedYardIds = stations + .filter((s) => s.sequenceNo <= dto.sequenceNo) + .map((s) => s.yardId); await manager .getRepository(Wagon) - .update( - { currentTrainScheduleId: scheduleId }, - { currentYardId: station.yardId }, - ); + .createQueryBuilder() + .update(Wagon) + .set({ currentYardId: station.yardId }) + .where('current_train_schedule_id = :scheduleId', { scheduleId }) + // A yard-less wagon has no "waiting further down the line" position + // to protect, so it rides along as it always did. + .andWhere('(current_yard_id IS NULL OR current_yard_id IN (:...passedYardIds))', { + passedYardIds, + }) + .execute(); if (schedule.trainSet?.trainId) { await manager .getRepository(Train) @@ -5286,12 +5301,26 @@ export class TrainSchedulingService { const typeCodeById = new Map(wagonTypes.map((type) => [type.id, type.code])); const counts = new Map(); + // A built consist spread across several yards can only offer, at each yard, + // the wagons standing there. A single-yard consist keeps the original + // behaviour: the whole train counts wherever it currently sits. + const consistYards = builtTrainId + ? new Set( + wagons + .filter((w) => w.trainId === builtTrainId && w.currentYardId) + .map((w) => w.currentYardId as string), + ) + : new Set(); + const consistIsSplit = consistYards.size > 1; + for (const wagon of wagons) { // Train-bound schedule: the built train's own consist IS the fleet — only - // its wagons count (wherever they currently sit; they travel with the - // train), and loose yard wagons never do. + // its wagons count, and loose yard wagons never do. A single-yard consist + // counts wherever it sits (it travels with the train); a split consist is + // counted at the yard each wagon actually stands in. if (builtTrainId) { if (wagon.trainId !== builtTrainId) continue; + if (consistIsSplit && wagon.currentYardId !== originYardId) continue; } else { // Schedule-scoped availability: pins held by OTHER schedules never // consume a wagon here — the same physical wagon may serve the July 17 @@ -5651,12 +5680,23 @@ export class TrainSchedulingService { // consist views draw the schedule exactly like the train builder; a schedule // created with reverseWagonOrder pins back-to-front (physically-last wagon // takes slot #1). Unsequenced wagons sort after every sequenced one. + const consistYards = new Set( + wagons + .filter((w) => w.trainId === builtTrainId && w.currentYardId) + .map((w) => w.currentYardId as string), + ); + // Split consist: a slot boarding at a given yard must take a wagon that + // physically stands there — the train cannot load a Mojo wagon at Dire. + // A single-yard consist ignores this (the whole train is at one place). + const requiredYardId = + consistYards.size > 1 ? (slot.boardYardId ?? originYardId) : null; const candidates = wagons .filter( (w) => w.trainId === builtTrainId && w.wagonTypeId === slot.wagonTypeId && - spanFree(w.id), + spanFree(w.id) && + (!requiredYardId || w.currentYardId === requiredYardId), ) .sort((a, b) => { if (a.sequenceNumber == null || b.sequenceNumber == null) { @@ -5825,14 +5865,28 @@ export class TrainSchedulingService { }); const remainingByTypeId = new Map(); const codesByTypeId = new Map(); + const byYardId = new Map>(); for (const wagon of wagons) { remainingByTypeId.set( wagon.wagonTypeId, (remainingByTypeId.get(wagon.wagonTypeId) ?? 0) + 1, ); if (wagon.wagonType) codesByTypeId.set(wagon.wagonTypeId, wagon.wagonType.code); + if (wagon.currentYardId) { + const perType = byYardId.get(wagon.currentYardId) ?? new Map(); + perType.set(wagon.wagonTypeId, (perType.get(wagon.wagonTypeId) ?? 0) + 1); + byYardId.set(wagon.currentYardId, perType); + } } - return { mode: 'TRAIN', remainingByTypeId, codesByTypeId }; + // Single-yard consist (the overwhelming majority): the whole train is + // offered at every boarding yard exactly as before — the per-yard split is + // only meaningful once the consist is genuinely spread across yards. + return { + mode: 'TRAIN', + remainingByTypeId, + codesByTypeId, + ...(byYardId.size > 1 ? { byYardId } : {}), + }; } /** @@ -6160,6 +6214,45 @@ export class TrainSchedulingService { return saved; } + /** + * A built train's wagons may stand in several yards. The route must pass + * through every one of them as origin or an intermediate stop — never only + * as the final destination (the train has to pick the wagons up en route). + */ + private async assertRouteCoversWagonYards(train: Train, route: Route) { + const wagons = await this.dataSource.getRepository(Wagon).find({ + where: { trainId: train.id }, + select: { id: true, currentYardId: true }, + }); + const wagonYards = [...new Set(wagons.map((w) => w.currentYardId).filter((y): y is string => !!y))]; + if (!wagonYards.length) return; + + const milestones = await this.dataSource + .getRepository(RouteMilestone) + .find({ where: { routeId: route.id }, order: { sequenceNo: 'ASC' } }); + const stops = milestones.length >= 2 + ? milestones.map((m) => m.yardId) + : [route.originYardId, route.destinationYardId]; + // Every stop except the last one is a pickup point. + const pickupYards = new Set(stops.slice(0, -1)); + + const uncovered = wagonYards.filter((y) => !pickupYards.has(y)); + if (!uncovered.length) return; + + const labels = await this.yardLabelMap(uncovered); + const destination = stops[stops.length - 1]; + const detail = uncovered + .map((y) => + y === destination + ? `${labels.get(y) ?? y} (only as the destination)` + : `${labels.get(y) ?? y} (not on route)`, + ) + .join(', '); + throw new BadRequestException( + `Route ${formatRouteLabel(route)} does not pass through every yard where train ${train.code}'s wagons stand: ${detail}`, + ); + } + private async getSchedulableRoute(routeId: string) { const route = await this.dataSource.getRepository(Route).findOne({ where: { id: routeId }, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts index f7db22506..29a67da45 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.ts @@ -43,6 +43,16 @@ export type WagonStock = { remainingByTypeId: Map; /** Wagon-type code per id, for human-readable shortfall messages. */ codesByTypeId: Map; + /** + * Multi-yard consist only: yardId → (wagonTypeId → count) for the wagons + * standing at that yard. A train whose wagons are split across yards can + * only offer, at each boarding yard, the wagons physically standing there — + * a wagon waiting in Mojo is not bookable from Dire, and one picked up at + * Dire is not re-offered at Mojo. Absent (undefined) when every wagon sits + * in one yard, which keeps single-yard trains on the original whole-train + * math. + */ + byYardId?: Map>; }; export type FlexPlanResult = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts index 47823cddd..5815ce435 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.spec.ts @@ -68,3 +68,94 @@ describe('WagonStockLedger', () => { expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 3 })).toBe(10); }); }); + +describe('WagonStockLedger — multi-yard consist', () => { + // The reported case: a built train of 53 wagons, 20 standing in Dire and 33 + // in Mojo. Each yard may only sell the wagons physically standing there. + const DIRE = 'yard-dire'; + const MOJO = 'yard-mojo'; + const ADDIS = 'yard-addis'; + const STOPS = [DIRE, MOJO, ADDIS]; + const EDGES = STOPS.length - 1; + const splitStock = () => + new Map([ + [DIRE, new Map([['nw5', 20]])], + [MOJO, new Map([['nw5', 33]])], + ]); + // Legs along Dire → Mojo → Addis. + const DIRE_TO_ADDIS = { fromEdge: 0, toEdge: 2 }; + const MOJO_TO_ADDIS = { fromEdge: 1, toEdge: 2 }; + + const splitLedger = () => + new WagonStockLedger(new Map([['nw5', 53]]), EDGES, splitStock(), STOPS); + + it('offers each yard only the wagons standing there', () => { + const ledger = splitLedger(); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(20); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + }); + + it('keeps the yards independent — Dire bookings never eat Mojo stock', () => { + const ledger = splitLedger(); + // A Dire booking rides the whole corridor, occupying the Mojo→Addis edge… + expect(ledger.consume(['nw5'], 20, DIRE_TO_ADDIS)).toBe(20); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(0); + // …but those are Dire's steel, so Mojo still has its own 33 to sell. + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + expect(ledger.consume(['nw5'], 33, MOJO_TO_ADDIS)).toBe(33); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(0); + }); + + it('never lends a free Dire wagon to a Mojo customer', () => { + const ledger = splitLedger(); + // Only 5 of Dire's 20 sell; the other 15 ride past Mojo empty. + expect(ledger.consume(['nw5'], 5, DIRE_TO_ADDIS)).toBe(5); + // Mojo is still capped at its own 33 — the 15 empty Dire wagons are not + // offered here, exactly as the operator requires. + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(33); + expect(ledger.consume(['nw5'], 40, MOJO_TO_ADDIS)).toBe(33); + }); + + it('offers nothing at the destination — there is nothing to pick up there', () => { + const ledger = splitLedger(); + // A leg boarding at the last stop has no pool of its own. + expect(ledger.availableFor(['nw5'], { fromEdge: 2, toEdge: 2 })).toBe(0); + }); + + it('second example: Addis → Dire → Indode → Mojo → Djibouti', () => { + const [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI] = [ + 'yard-add', + 'yard-dire', + 'yard-indode', + 'yard-mojo', + 'yard-djibouti', + ]; + const stops = [ADD, DIRE_2, INDODE, MOJO_2, DJIBOUTI]; + const ledger = new WagonStockLedger( + new Map([['nw5', 53]]), + stops.length - 1, + new Map([ + [DIRE_2, new Map([['nw5', 20]])], + [MOJO_2, new Map([['nw5', 33]])], + ]), + stops, + ); + const to = (fromEdge: number) => ({ fromEdge, toEdge: stops.length - 1 }); + // Addis: the train starts empty — nothing to sell. + expect(ledger.availableFor(['nw5'], to(0))).toBe(0); + // Dire: the 20 wagons waiting there. + expect(ledger.availableFor(['nw5'], to(1))).toBe(20); + // Indode: the same 20 wagons, which have moved with the train. + expect(ledger.availableFor(['nw5'], to(2))).toBe(0); + // Mojo: its own 33 only. + expect(ledger.availableFor(['nw5'], to(3))).toBe(33); + }); + + it('single-yard consist keeps the original whole-train behaviour', () => { + // No byYardId (the train is not split) — every leg sees the whole train, + // exactly as before this feature. + const ledger = new WagonStockLedger(new Map([['nw5', 53]]), EDGES); + expect(ledger.availableFor(['nw5'], DIRE_TO_ADDIS)).toBe(53); + expect(ledger.availableFor(['nw5'], MOJO_TO_ADDIS)).toBe(53); + }); +}); diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts index 0e4f6949d..bf5b935ad 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-stock-ledger.util.ts @@ -20,17 +20,53 @@ import type { CorridorLeg } from './corridor-capacity.util'; * Gelan→Adama never competes for stock with an export on Adama→Doraleh. */ export class WagonStockLedger { + /** + * Usage rows keyed by pool. A single-yard train has one pool (''), so this is + * exactly the original per-type accounting. A multi-yard consist keys by + * boarding yard as well, because the Dire wagons and the Mojo wagons are + * disjoint sets of steel: 5 Dire wagons riding the whole corridor occupy the + * Mojo→Addis edge, but they must not shrink what Mojo itself can offer. + */ private readonly usedPerEdge = new Map(); constructor( private readonly remainingByTypeId: Map, private readonly edgeCount: number, + /** + * Multi-yard consist only (see {@link WagonStock.byYardId}): the wagons + * standing at each yard. When present, a leg is served ONLY by the wagons + * standing at the yard it boards from — a Dire→Addis booking on a train + * whose wagons sit 20 in Dire and 33 in Mojo sees 20, and a Mojo→Addis + * booking sees 33, never the Dire wagons that ride past empty. + */ + private readonly byYardId?: Map>, + /** Ordered corridor stops, parallel to the edges — maps an edge to its yard. */ + private readonly stops: readonly string[] = [], ) {} + /** The yard a leg boards from, or '' when the train is not split across yards. */ + private poolYardOf(leg: CorridorLeg): string { + if (!this.byYardId) return ''; + return this.stops[leg.fromEdge] ?? ''; + } + + /** Usage-row key: one row per (pool, wagon type). */ + private rowKey(wagonTypeId: string, leg: CorridorLeg): string { + const pool = this.poolYardOf(leg); + return pool ? `${pool}\u0000${wagonTypeId}` : wagonTypeId; + } + + /** Wagons of one type offered at the yard a leg boards from. */ + private totalForType(wagonTypeId: string, leg: CorridorLeg): number { + const pool = this.poolYardOf(leg); + if (!pool) return this.remainingByTypeId.get(wagonTypeId) ?? 0; + return this.byYardId?.get(pool)?.get(wagonTypeId) ?? 0; + } + /** Free wagons of ONE type on a leg: total minus its busiest edge within that leg. */ private availableForType(wagonTypeId: string, leg: CorridorLeg): number { - const total = this.remainingByTypeId.get(wagonTypeId) ?? 0; - const row = this.usedPerEdge.get(wagonTypeId); + const total = this.totalForType(wagonTypeId, leg); + const row = this.usedPerEdge.get(this.rowKey(wagonTypeId, leg)); if (!row) return total; let busiest = 0; for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { @@ -69,10 +105,11 @@ export class WagonStockLedger { if (!deepest) break; const take = Math.min(outstanding, deepest.free); - let row = this.usedPerEdge.get(deepest.id); + const key = this.rowKey(deepest.id, leg); + let row = this.usedPerEdge.get(key); if (!row) { row = new Array(this.edgeCount).fill(0); - this.usedPerEdge.set(deepest.id, row); + this.usedPerEdge.set(key, row); } for (let edge = leg.fromEdge; edge < leg.toEdge; edge += 1) { row[edge] = (row[edge] ?? 0) + take; diff --git a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts index 7b0bdd3bd..08a424cdc 100644 --- a/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts +++ b/apps/edr-freight-api/src/modules/trains/dto/build-train.dto.ts @@ -44,7 +44,7 @@ export class BuildTrainDto { @ApiPropertyOptional({ type: [String], format: 'uuid', - description: 'Wagons to attach at build time, in consist order (must sit in the same yard)', + description: 'Wagons to attach at build time, in consist order (any yard)', }) @IsOptional() @IsArray() 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 0f377071c..4b94b2dcb 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 @@ -283,6 +283,10 @@ export class TrainBuilderService { wagonNumber: wagon.wagonNumber, sequenceNumber: wagon.sequenceNumber, status: wagon.status, + currentYardId: wagon.currentYardId ?? null, + currentYard: wagon.currentYard + ? { id: wagon.currentYard.id, code: wagon.currentYard.code, label: wagon.currentYard.label } + : null, wagonType: wagon.wagonType ? { id: wagon.wagonType.id, @@ -326,6 +330,27 @@ export class TrainBuilderService { : null, locomotives, wagons, + // Where the consist physically stands. A train built from several yards + // only picks a yard's wagons up when it reaches that yard, and a customer + // boarding there can only book the wagons standing there — the schedule + // route must therefore cover every one of these yards before its + // destination. + wagonYards: [ + ...wagons + .reduce((acc, wagon) => { + const id = wagon.currentYardId ?? 'UNASSIGNED'; + const entry = acc.get(id) ?? { + yardId: wagon.currentYardId ?? null, + code: wagon.currentYard?.code ?? null, + label: wagon.currentYard?.label ?? null, + wagonCount: 0, + }; + entry.wagonCount += 1; + acc.set(id, entry); + return acc; + }, new Map()) + .values(), + ].sort((a, b) => b.wagonCount - a.wagonCount), totals: { wagonCount: wagons.length, totalTareTons, @@ -428,10 +453,13 @@ export class TrainBuilderService { } /** - * Relocate the train to another yard. The consist moves as one unit: every - * coupled locomotive and wagon follows to the new yard (so their current - * yards always match the train's), and each wagon gets a movement-ledger row. - * Blocked while the train is out on a dispatched run. + * Relocate the train to another yard. The locomotives always follow. Of the + * wagons, only those standing WITH the train move: on a consist spread + * across yards (20 in Dire, 33 waiting in Mojo), moving the train Dire→Mojo + * relocates the 20 it is actually pulling and leaves the Mojo wagons where + * they stand — the train collects those by arriving, not by this call. + * Each moved wagon gets a movement-ledger row. Blocked while the train is + * out on a dispatched run. */ async setYard(id: string, currentYardId: string) { await this.dataSource.transaction(async (manager) => { @@ -439,6 +467,7 @@ export class TrainBuilderService { if (train.currentYardId === currentYardId) return; const yard = await manager.getRepository(Yard).findOne({ where: { id: currentYardId } }); if (!yard) throw new NotFoundException(`Yard ${currentYardId} not found`); + const previousYardId = train.currentYardId ?? null; await manager.getRepository(Train).update(train.id, { currentYardId: yard.id }); @@ -454,7 +483,17 @@ export class TrainBuilderService { ); } - const wagons = await manager.getRepository(Wagon).find({ where: { trainId: train.id } }); + const allWagons = await manager + .getRepository(Wagon) + .find({ where: { trainId: train.id } }); + // Wagons travelling with the train = those at the yard it is leaving. + // A yard-less wagon has no standing position of its own, so it follows. + const wagons = allWagons.filter( + (wagon) => + wagon.currentYardId == null || + previousYardId == null || + wagon.currentYardId === previousYardId, + ); const now = new Date(); for (const wagon of wagons) { if (wagon.currentYardId === yard.id) continue; @@ -475,7 +514,7 @@ export class TrainBuilderService { return this.getComposition(id); } - /** Append AVAILABLE wagons from the train's own yard to the consist. */ + /** Append AVAILABLE, unassigned wagons (any yard) to the consist. */ async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) { await this.dataSource.transaction(async (manager) => { const train = await this.getEditableTrain(manager, id); @@ -1037,11 +1076,8 @@ export class TrainBuilderService { if (wagon.status !== WagonStatus.Available) { throw new ConflictException(`Wagon ${wagon.wagonNumber} is not available (${wagon.status})`); } - if (wagon.currentYardId !== train.currentYardId) { - throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, - ); - } + // Wagons may sit in any yard — the schedule's route must pass through + // every wagon yard before its destination (checked at scheduling time). toAttach.push(wagon); } if (!toAttach.length) return []; 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 b5a14f6f3..f2c76e283 100644 --- a/apps/edr-freight-api/src/modules/wagons/wagons.service.ts +++ b/apps/edr-freight-api/src/modules/wagons/wagons.service.ts @@ -342,8 +342,8 @@ export class WagonsService { async assignToTrain(wagonId: string, dto: AssignWagonToTrainDto): Promise { const wagon = await this.findById(wagonId); - // Mirror train-builder attachWagons: only a truly free, available wagon in - // the train's own yard can be coupled, and never onto a dispatched train. + // Mirror train-builder attachWagons: only a truly free, available wagon + // (any yard) can be coupled, and never onto a dispatched train. if (wagon.trainId != null) { throw new ConflictException(`Wagon ${wagon.wagonNumber} is already on another train`); } @@ -358,11 +358,6 @@ export class WagonsService { `Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`, ); } - if (wagon.currentYardId !== train.currentYardId) { - throw new BadRequestException( - `Wagon ${wagon.wagonNumber} is not in the train's yard; only wagons in the same yard can be attached`, - ); - } const maxSeq = await this.wagonRepo .createQueryBuilder('w') 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 24be04f66..87ba8cf8a 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -1465,6 +1465,16 @@ export const GRANULAR_SPLIT_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:settings:exchange_rate:manage", "Set the USD-ETB fallback rate", ), + perm( + "b4d00001-0001-4000-8000-000000000003", + "edr_freight_app:settings:manual_payment:view", + "View the manual (offline) payment channel settings", + ), + perm( + "b4d00001-0001-4000-8000-000000000004", + "edr_freight_app:settings:manual_payment:manage", + "Enable or disable manual invoice settlement per currency", + ), perm( "b4e00001-0001-4000-8000-000000000001", "edr_freight_app:settings:contract_templates:view", @@ -2112,6 +2122,14 @@ export const FREIGHT_PERMS = { view: "edr_freight_app:settings:exchange_rate:view", manage: "edr_freight_app:settings:exchange_rate:manage", }, + // Whether Finance may settle invoices by hand, per currency. Split + // view/manage on purpose: Finance reads it (the worklist offers only the + // enabled currencies) but must not switch its own channel on — same + // maker-checker split as the other sensitive finance settings. + manualPayment: { + view: "edr_freight_app:settings:manual_payment:view", + manage: "edr_freight_app:settings:manual_payment:manage", + }, contractTemplates: { view: "edr_freight_app:settings:contract_templates:view", manage: "edr_freight_app:settings:contract_templates:manage", @@ -2416,6 +2434,9 @@ export const ROLE_PERMISSION_PRESETS = { FREIGHT_PERMS.invoices.export, // Manual settlement (bank transfer / counter) of USD and ETB invoices. FREIGHT_PERMS.invoices.confirmOffline, + // Read-only: the worklist offers whichever currencies are switched on. + // Flipping the switch is deliberately NOT here — see `manualPayment`. + FREIGHT_PERMS.settings.manualPayment.view, // Deliberately NOT granted here: invoices:eims_register, eims_resolve, eims_cancel, // eims_receipt_register, eims:memo_issue. Automatic filing needs no human permission at all // (the cron sweep runs as the system); these are the *manual* exceptional-operations diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index c131af4bd..f07ec32b5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -86,6 +86,7 @@ import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPa import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedulingGlobalRulesPage"; import TradeAccessPage from "./pages/configuration/TradeAccessPage"; import ExchangeRateSettingsCard from "./pages/settings/ExchangeRateSettingsCard"; +import ManualPaymentSettingsCard from "./pages/settings/ManualPaymentSettingsCard"; import FirstMilePage from "./pages/operations/FirstMilePage"; import LastMilePage from "./pages/operations/LastMilePage"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; @@ -1147,6 +1148,18 @@ const App = () => { } /> + +
+ +
+ + } + /> ("ALL"); + const [yardFilter, setYardFilter] = useState("ALL"); const [runOnly, setRunOnly] = useState(false); const [selected, setSelected] = useState([]); + const [page, setPage] = useState(1); // The train's own run, e.g. "8001-8002" — only offered when the train has one. const runLabel = exportTrainNumber @@ -39,59 +45,65 @@ export default function AvailableWagonsPanel({ : null; const wagonsQuery = useQuery( - api.wagons.list.queryOptions({ + api.wagons.listPaged.queryOptions({ input: { filters: { status: Freight.WagonStatus.Available, - currentYardId: yardId, // Loose wagons only — one already on another train cannot be coupled. unassigned: true, + search: debouncedSearch.trim() || undefined, + currentYardId: yardFilter === "ALL" ? undefined : yardFilter, + wagonTypeId: typeFilter === "ALL" ? undefined : typeFilter, + // Rostered to this train's run — the API matches either run column. + trainNumber: runOnly && exportTrainNumber ? exportTrainNumber : undefined, + page, + pageSize: PAGE_SIZE, }, }, - enabled: Boolean(yardId), }), ); - const wagons = useMemo(() => { - const q = search.trim().toLowerCase(); - return (wagonsQuery.data ?? []).filter((wagon) => { - if (typeFilter !== "ALL" && wagon.wagonTypeId !== typeFilter) return false; - // Rostered to this train's run — match on the export run, which fixes the - // import run anyway. - if (runOnly && wagon.exportTrainNumber !== exportTrainNumber) return false; - if (q && !wagon.wagonNumber.toLowerCase().includes(q)) return false; - return true; - }); - }, [wagonsQuery.data, search, typeFilter, runOnly, exportTrainNumber]); + const wagons = wagonsQuery.data?.items ?? []; + const total = wagonsQuery.data?.meta.total ?? 0; + const totalPages = Math.max(1, wagonsQuery.data?.meta.totalPages ?? 1); - const runMatchCount = useMemo( - () => - exportTrainNumber - ? (wagonsQuery.data ?? []).filter( - (w) => w.exportTrainNumber === exportTrainNumber, - ).length - : 0, - [wagonsQuery.data, exportTrainNumber], - ); + // Filters change → back to page 1 (and clamp when the list shrinks). + useEffect(() => { + setPage(1); + }, [debouncedSearch, typeFilter, yardFilter, runOnly]); + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages]); - const typeOptions = useMemo(() => { - const byId = new Map(); - for (const wagon of wagonsQuery.data ?? []) { - if (wagon.wagonType) { - // e.g. "Flat wagon (NW5)" — name with its type code. - byId.set( - wagon.wagonType.id, - wagon.wagonType.code - ? `${wagon.wagonType.name} (${wagon.wagonType.code})` - : wagon.wagonType.name, - ); - } - } + // Dropdowns come from the reference lists, not the current page — a yard or + // type must stay pickable even when this page holds none of it. + const yardsQuery = useQuery(api.routes.yards.queryOptions({ staleTime: 5 * 60_000 })); + const wagonTypesQuery = useQuery(api.wagonTypes.list.queryOptions({ staleTime: 5 * 60_000 })); + + const yardOptions = useMemo(() => { + const yards = [...(yardsQuery.data ?? [])].sort((a, b) => + a.id === homeYardId ? -1 : b.id === homeYardId ? 1 : a.label.localeCompare(b.label), + ); return [ - { value: "ALL", label: "All types" }, - ...[...byId.entries()].map(([value, label]) => ({ value, label })), + { value: "ALL", label: "All yards" }, + ...yards.map((yard) => ({ + value: yard.id, + label: `${yard.label}${yard.id === homeYardId ? " · train's yard" : ""}`, + })), ]; - }, [wagonsQuery.data]); + }, [yardsQuery.data, homeYardId]); + + const typeOptions = useMemo( + () => [ + { value: "ALL", label: "All types" }, + // e.g. "Flat wagon (NW5)" — name with its type code. + ...(wagonTypesQuery.data ?? []).map((type) => ({ + value: type.id, + label: type.code ? `${type.name} (${type.code})` : type.name, + })), + ], + [wagonTypesQuery.data], + ); const toggle = (wagonId: string, checked: boolean) => { setSelected((prev) => @@ -99,8 +111,8 @@ export default function AvailableWagonsPanel({ ); }; - const allSelected = - wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); + // Select-all covers this page only — the rest of the matches are not loaded. + const allSelected = wagons.length > 0 && wagons.every((w) => selected.includes(w.id)); const someSelected = wagons.some((w) => selected.includes(w.id)); const toggleAll = (checked: boolean) => { @@ -138,24 +150,40 @@ export default function AvailableWagonsPanel({ onChange={(v) => setTypeFilter(v ?? "ALL")} /> +