diff --git a/apps/edr-freight-api/src/common/export-received-gate.spec.ts b/apps/edr-freight-api/src/common/export-received-gate.spec.ts index 6aaa24a26..62e7c758c 100644 --- a/apps/edr-freight-api/src/common/export-received-gate.spec.ts +++ b/apps/edr-freight-api/src/common/export-received-gate.spec.ts @@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => { assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }), ).resolves.toBeUndefined(); }); + + it('never blocks direct truck-to-train export — that cargo has no GRN by design', async () => { + const source = db([]); + await expect( + assertExportReceivedWithGrn(source, { + id: 'b-1', + tradeDirection: 'EXPORT', + exportHandoverMode: 'DIRECT_TO_TRAIN', + }), + ).resolves.toBeUndefined(); + // Direct short-circuits before querying — there is no inventory to look for. + expect(source.query as jest.Mock).not.toHaveBeenCalled(); + }); + + it('still gates a warehouse export booking', async () => { + await expect( + assertExportReceivedWithGrn(db([]), { + id: 'b-1', + tradeDirection: 'EXPORT', + exportHandoverMode: 'WAREHOUSE', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); }); diff --git a/apps/edr-freight-api/src/common/export-received-gate.ts b/apps/edr-freight-api/src/common/export-received-gate.ts index 0e1728800..5fd97480a 100644 --- a/apps/edr-freight-api/src/common/export-received-gate.ts +++ b/apps/edr-freight-api/src/common/export-received-gate.ts @@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm'; export interface ExportLoadGateBooking { id: string; tradeDirection?: string | null; + /** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */ + exportHandoverMode?: string | null; } +/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */ +export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN'; +/** Warehouse-then-train: the existing flow. Also what a null mode means. */ +export const WAREHOUSE = 'WAREHOUSE'; + /** * Export cargo may not be loaded onto its train until it has physically reached * the warehouse and been issued a GRN — whether it got there by first-mile or by @@ -21,12 +28,18 @@ export interface ExportLoadGateBooking { * "Received with a GRN" = an inventory row that has reached the warehouse * (RECEIVED or any later stage) and carries a GRN, in the column or the notes * fallback older rows use. + * + * Export has a second, warehouse-free shape: the customer's truck loads straight + * onto the wagon. That cargo is never received and never GRN'd, so a booking + * marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is + * attested by the carriage acceptance sheet instead. */ export async function assertExportReceivedWithGrn( db: DataSource | EntityManager, booking: ExportLoadGateBooking, ): Promise { if (booking.tradeDirection !== 'EXPORT') return; + if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return; const [row] = await db.query( `SELECT 1 diff --git a/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts b/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts new file mode 100644 index 000000000..42acfc8f9 --- /dev/null +++ b/apps/edr-freight-api/src/migrations/3370000000000-BookingExportHandoverMode.ts @@ -0,0 +1,28 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +/** + * Export cargo reaches a train two ways, and until now only one was modelled. + * + * DIRECT_TO_TRAIN — the customer's truck pulls alongside and the cargo goes + * straight onto the wagon. It never enters a warehouse, so no GRN is ever + * raised; the Carriage Acceptance Sheet is the only document handed over. + * + * WAREHOUSE — cargo is received into the warehouse, GRN'd, then loaded. This is + * the existing flow and stays gated on the GRN. + * + * NULL means WAREHOUSE, so existing rows keep today's behaviour with no backfill. + */ +export class BookingExportHandoverMode3370000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings + ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode + `); + } +} diff --git a/apps/edr-freight-api/src/modules/billing/billing.controller.ts b/apps/edr-freight-api/src/modules/billing/billing.controller.ts index ea43c9c74..5bf1448fd 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.controller.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.controller.ts @@ -1,19 +1,31 @@ import { + Body, Controller, Get, Param, ParseUUIDPipe, + Post, Query, Res, + UploadedFile, + UseInterceptors, } from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; +import { FileInterceptor } from "@nestjs/platform-express"; +import { + ApiBearerAuth, + ApiConsumes, + ApiOperation, + ApiTags, +} from "@nestjs/swagger"; import type { Response } from "express"; 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 { resolveAuthUserId } from "../../common/resolve-auth-user-id"; import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; +import { actorLabel } from "../warehouses/current-actor.util"; import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; import { BillingService } from "./billing.service"; import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @@ -25,6 +37,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto"; @BookingStaff([ FREIGHT_PERMS.invoices.view, FREIGHT_PERMS.invoices.export, + FREIGHT_PERMS.invoices.confirmOffline, ]) @ApiBearerAuth() export class BillingController { @@ -56,6 +69,36 @@ export class BillingController { return this.billingService.findById(id); } + @Get("offline-usd") + @ApiOperation({ + summary: + "Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context", + }) + findOfflineUsd(@Query() query: FilterInvoiceDto) { + return this.billingService.findOfflineUsdPaginated(query); + } + + @Post("invoices/:id/confirm-offline") + @BookingStaff(FREIGHT_PERMS.invoices.confirmOffline) + @UseInterceptors(FileInterceptor("file")) + @ApiConsumes("multipart/form-data") + @ApiOperation({ + summary: + "Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance", + }) + confirmOffline( + @Param("id", ParseUUIDPipe) id: string, + @UploadedFile() file: Express.Multer.File | undefined, + @Body("reference") reference: string | undefined, + @CurrentUser() user: TCurrentUser, + ) { + return this.billingService.confirmOfflinePayment(id, file, { + reference: reference?.trim() || null, + userId: resolveAuthUserId(user), + userName: actorLabel(user) ?? null, + }); + } + @Get("invoices/:id/document") @BookingStaff(FREIGHT_PERMS.invoices.export) @ApiOperation({ summary: "Download the sealed invoice PDF" }) diff --git a/apps/edr-freight-api/src/modules/billing/billing.module.ts b/apps/edr-freight-api/src/modules/billing/billing.module.ts index a67e6f6ba..7ec5c333b 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.module.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.module.ts @@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository"; import { InvoiceLineRepository } from "./invoice-line.repository"; import { PaymentModule } from "../payment/payment.module"; import { CompaniesModule } from "../companies/companies.module"; +import { FilesModule } from "../files/files.module"; @Module({ imports: [ @@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module"; CompaniesModule, DocumentsModule, UserTradeAccessModule, + FilesModule, ], controllers: [BillingController, PortalBillingController, PaymentController], providers: [BillingService, InvoiceRepository, InvoiceLineRepository], 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 6e25c72aa..576c7b166 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 @@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); }); @@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never); @@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); return { service, mg, events }; } @@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => { {} as never, // payment {} as never, // companies {} as never, // invoiceDocuments + {} as never, // files ); return { service, mg, events }; } @@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction", {} as never, {} as never, {} as never, + {} as never, ); return { service, defaultManager, txManager, transaction }; }; @@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => { {} as never, {} as never, {} as never, + {} as never, ); return { service, manager }; }; @@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => { payment as never, {} as never, {} as never, + {} as never, ); return { service, repo }; }; @@ -669,11 +677,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => { }); }); -describe("BillingService — CBE bill amounts round UP to whole birr", () => { - // CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down - // settles 0.40 short while markInvoiceAsPaid still writes paidAmount = - // totalAmount — money missing from the bank with the books saying paid. - // payInvoice and billQuery must agree, or /cbe/payment sees a mismatch. +describe("BillingService — CBE bill amounts carry cents, never rounded", () => { + // CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the + // bill must quote the exact balance. Rounding UP overcharged the payer by up to + // a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount + // = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches. const invoice = { id: "inv-1", status: Freight.InvoiceStatus.Pending, @@ -682,9 +690,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => { type: "PREPAID", invoiceNumber: "INV-20260101-00001", currency: "ETB", - // .40 — the case Math.round gets wrong (rounds down, underpays). - balanceAmount: 12345.4, - totalAmount: 12345.4, + // .43 — cents that must survive all the way to the bill. + balanceAmount: 12345.43, + totalAmount: 12345.43, company: { name: "Acme PLC" }, paymentId: null, dueAt: null, @@ -703,11 +711,12 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => { payment as never, {} as never, {} as never, + {} as never, ); return { service, repo }; }; - it("opens the intent for the ceiled balance, never below it", async () => { + it("opens the intent for the exact balance, cents included", async () => { const initiate = jest.fn().mockResolvedValue({ intentId: "intent-1", immediateSuccess: false, @@ -718,16 +727,16 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => { await service.payInvoice("inv-1", { method: "CBE_BILL" }); expect(initiate).toHaveBeenCalledWith( - expect.objectContaining({ amountMinor: 12346 }), + expect.objectContaining({ amountMinor: 12345.43 }), ); }); - it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => { + it("quotes the same exact amount on bill-query as payInvoice opened", async () => { const { service } = build(); await expect(service.billQuery("booking-1")).resolves.toMatchObject({ stillPayable: true, - currentAmountMinor: 12346, + currentAmountMinor: 12345.43, }); }); }); 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 5051afd6e..9a31466f0 100644 --- a/apps/edr-freight-api/src/modules/billing/billing.service.ts +++ b/apps/edr-freight-api/src/modules/billing/billing.service.ts @@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm"; import { Booking } from "../bookings/entities/booking.entity"; import { CompaniesService } from "../companies/companies.service"; +import { FilesService } from "../files/files.service"; import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util"; import { PaymentService } from "../payment/payment.service"; import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto"; @@ -35,6 +36,14 @@ export interface PayInvoiceOptions { failureUrl?: string; } +/** Booking context attached to a finance offline-USD invoice row. */ +export interface OfflineUsdBookingInfo { + id: string; + reference: string; + paymentDeadline: Date | null; + paymentStatus: string; +} + /** A single manual/offline settlement to record against an invoice. */ export interface RecordPaymentInput { /** Amount settled by this payment; must be > 0. */ @@ -150,6 +159,7 @@ export class BillingService { private readonly payment: PaymentService, private readonly companies: CompaniesService, private readonly invoiceDocuments: InvoiceDocumentService, + private readonly files: FilesService, ) { } // ── Reads ────────────────────────────────────────────────────────────────── @@ -214,6 +224,146 @@ export class BillingService { return { items, total }; } + /** + * Finance's offline-settlement worklist: USD invoices (paid by bank transfer, + * never through the gateway), open ones by default or a single status when + * filtered. Booking-sourced rows carry the booking's reference and pay-window + * deadline so the UI can show the countdown and link to the booking. + */ + async findOfflineUsdPaginated( + filter: { + status?: Freight.InvoiceStatus; + search?: string; + page?: number; + pageSize?: number; + } = {}, + ): Promise<{ + items: (Invoice & { booking: OfflineUsdBookingInfo | null })[]; + total: number; + }> { + const page = filter.page && filter.page > 0 ? filter.page : 1; + const pageSize = + filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20; + + const qb = this.dataSource + .getRepository(Invoice) + .createQueryBuilder("invoice") + .leftJoinAndSelect("invoice.company", "company") + .where("UPPER(invoice.currency) = 'USD'") + .orderBy("invoice.issuedAt", "DESC") + .skip((page - 1) * pageSize) + .take(pageSize); + + if (filter.status) { + qb.andWhere("invoice.status = :status", { status: filter.status }); + } else { + qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES }); + } + if (filter.search) { + qb.andWhere( + "(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)", + { search: `%${filter.search}%` }, + ); + } + + const [items, total] = await qb.getManyAndCount(); + + const bookingIds = items + .filter((i) => i.source === "booking") + .map((i) => i.sourceId); + const bookings = bookingIds.length + ? await this.dataSource.getRepository(Booking).find({ + where: { id: In(bookingIds) }, + select: ["id", "reference", "paymentDeadline", "paymentStatus"], + }) + : []; + const byId = new Map(bookings.map((b) => [b.id, b])); + + return { + items: items.map((inv) => { + const b = byId.get(inv.sourceId); + return { + ...inv, + booking: b + ? { + id: b.id, + reference: b.reference, + paymentDeadline: b.paymentDeadline ?? null, + paymentStatus: b.paymentStatus, + } + : null, + } as Invoice & { booking: OfflineUsdBookingInfo | null }; + }), + total, + }; + } + + /** + * Finance confirms a USD invoice as paid by bank transfer: 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 + * the booking advances exactly as if it had been paid through the gateway. + * + * Guarded by the booking's pay window: past the deadline the booking expires + * like any unpaid one, so confirmation is refused. + */ + async confirmOfflinePayment( + invoiceId: string, + file: Express.Multer.File | undefined, + input: { + reference?: string | null; + userId?: string | null; + userName?: string | null; + }, + ): Promise { + const invoice = await this.invoices.findById(invoiceId); + if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`); + if (invoice.currency?.toUpperCase() !== "USD") { + throw new BadRequestException( + "Offline confirmation is only for USD invoices — this invoice is paid online.", + ); + } + if (!file) { + throw new BadRequestException("The bank payment slip file is required."); + } + + if (invoice.source === "booking") { + const booking = await this.dataSource.getRepository(Booking).findOne({ + where: { id: invoice.sourceId }, + select: ["id", "paymentDeadline"], + }); + const deadline = booking?.paymentDeadline; + if (deadline && new Date(deadline).getTime() < Date.now()) { + throw new BadRequestException( + "The payment window has closed — this booking can no longer be confirmed as paid.", + ); + } + } + + const slip = await this.files.upload({ + resource: "invoice", + resourceId: invoice.id, + code: "OFFLINE_PAYMENT_SLIP", + file, + title: "Bank payment slip", + uploadedByUserId: input.userId ?? null, + uploadedByName: input.userName ?? null, + }); + + return this.recordPayment(invoiceId, { + amount: Number(invoice.balanceAmount), + method: "BANK_TRANSFER", + reference: input.reference || slip.name, + metadata: { + offline: true, + slipFileId: slip.id, + confirmedByUserId: input.userId ?? null, + confirmedByName: input.userName ?? null, + }, + }); + } + /** Invoice header plus its line items. */ async findById(id: string): Promise { const invoice = await this.invoices.findById(id, { @@ -1191,11 +1341,11 @@ export class BillingService { // service branches on a domain-specific reference type. referenceType: PaymentReferenceType.SHIPMENT, orderRef: invoice.invoiceNumber.replace(/-/g, "_"), - // Whole birr, always UP. CBE bills this amount verbatim, so it must never - // land below the outstanding balance — Math.round would let a .40 balance - // settle 0.40 short. Ceil overcharges by <1 birr instead, and the same - // ceil in billQuery keeps the quoted and debited amounts identical. - amountMinor: Math.ceil(Number(invoice.balanceAmount)), + // Exact balance, cents included. CBE bills this verbatim and /cbe/payment + // matches the debited amount to the cent (amountsMatchToTheCent), so any + // rounding here would overcharge the payer and leave the invoice balance + // non-zero. billQuery quotes the same unrounded value. + amountMinor: round2(Number(invoice.balanceAmount)), currency: invoice.currency, reason: `Payment for invoice ${invoice.invoiceNumber}`, method: opts.method ?? "TELEBIRR", @@ -1340,9 +1490,10 @@ export class BillingService { }); if (open) { - // Ceil, matching payInvoice — the amount CBE quotes at the counter has to - // be the amount the intent was opened for, or /cbe/payment sees a mismatch. - const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount)); + // Unrounded, matching payInvoice — the amount CBE quotes at the counter has + // to be the amount the intent was opened for, to the cent, or /cbe/payment + // sees a mismatch. + const balance = round2(Number(open.balanceAmount ?? open.totalAmount)); const expired = open.dueAt && open.dueAt.getTime() < Date.now(); return { stillPayable: balance > 0 && !expired, @@ -1377,7 +1528,7 @@ export class BillingService { return { stillPayable: false, payerName: latest.company?.name ?? null, - currentAmountMinor: Math.ceil(Number(latest.totalAmount)), + currentAmountMinor: round2(Number(latest.totalAmount)), currency: latest.currency, paymentReason: `Freight invoice ${latest.invoiceNumber}`, reason: closedInvoiceReason(latest.status), diff --git a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts index ba6fece8c..4ebebe879 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-pricing.service.ts @@ -279,9 +279,10 @@ export class BookingPricingService { return { lineItems, - // Grand total is billed in whole currency units — fractional line sums - // (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD. - totalAmount: Math.round(total), + // Grand total keeps its cents, matching the line items it sums — rounding + // to whole birr made the total disagree with the breakdown (135,375.61 of + // lines shown as a 135,376.00 total) and CBE bills this figure to the cent. + totalAmount: round2(total), currency: booking.paymentCurrency, usedRates: [...usedRatesMap.values()], appliedModifiers: ruleResult.appliedModifiers, diff --git a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts index 198342cc8..b62fdfca0 100644 --- a/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/booking-wagon-cancellation.service.ts @@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; import { Rate } from '../rule-engine/entities/rate.entity'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; -import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity'; import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; diff --git a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts index b7eb526a3..f10b5f021 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.controller.ts @@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service'; import { GenerateGrnDto } from './dto/generate-grn.dto'; import { ContainerReceiptService } from './container-receipt.service'; import { SignContractDto } from './dto/sign-contract.dto'; +import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; import { BookingWagonCancellationService } from './booking-wagon-cancellation.service'; import { @@ -757,6 +758,18 @@ export class BookingsController { return this.customerTruckService.getLoadableContainers(id); } + @Patch(':id/export-handover-mode') + @BookingStaff(FREIGHT_PERMS.bookings.operations) + @ApiOperation({ + summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first', + }) + setExportHandoverMode( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: SetExportHandoverModeDto, + ) { + return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode); + } + @Post(':id/customer-trucks/:assignmentId/load') @BookingStaff(FREIGHT_PERMS.bookings.operations) @ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' }) 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 15799f000..c96c54f9b 100644 --- a/apps/edr-freight-api/src/modules/bookings/bookings.service.ts +++ b/apps/edr-freight-api/src/modules/bookings/bookings.service.ts @@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common'; // import { CustomersService } from '../customers/customers.service'; import { CompaniesService } from '../companies/companies.service'; import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity'; -import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { FilesService } from '../files/files.service'; import { MinioService } from '../minio/minio.service'; @@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter'; import { DataSource, In } from 'typeorm'; import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; -import { assertExportReceivedWithGrn } from '../../common/export-received-gate'; +import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate'; import { Yard } from '../rule-engine/entities/yard.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; @@ -285,10 +285,24 @@ export class BookingsService { // Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork // and never appears on this sheet — it is only the signal that EDR has taken // the cargo, which is what the customer's sheet attests to. + const isDirectExport = + booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN; const pendingWagons = wagons.length === 0; if (pendingWagons) { - const receivedLines: CarriageAcceptanceReceivedRow[] = - booking.tradeDirection === 'EXPORT' + // Direct truck-to-train cargo never enters the warehouse, so there is no + // GRN'd inventory to build the sheet from. Choosing direct handover is + // itself the acceptance, so the sheet issues off the booking's own + // containers (or its VGM weight when the cargo is bulk). + const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport + ? await this.dataSource.query( + `SELECT NULL::numeric AS "allocatedWeightTons", + c.container_number AS "containerNumbers" + FROM freight.containers c + WHERE c.booking_id = $1 AND c.deleted_at IS NULL + ORDER BY c.container_number`, + [bookingId], + ) + : booking.tradeDirection === 'EXPORT' ? await this.dataSource.query( `SELECT inv.weight AS "allocatedWeightTons", c.container_number AS "containerNumbers" @@ -304,6 +318,15 @@ export class BookingsService { [bookingId], ) : []; + // Bulk direct cargo has no containers — one line carrying the booking's + // declared weight still makes a valid sheet. + if (isDirectExport && receivedLines.length === 0) { + receivedLines.push({ + allocatedWeightTons: + booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons), + containerNumbers: null, + }); + } if (receivedLines.length === 0) { throw new BadRequestException( booking.tradeDirection === 'EXPORT' @@ -1997,6 +2020,47 @@ export class BookingsService { } /** Get a single booking by ID with files. */ + /** + * EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes + * the booking out of the warehouse flow entirely — no receipt, no GRN, and the + * carriage acceptance sheet becomes issuable straight away. + * + * Switching to direct is refused once the goods are already in the shed: + * inventory exists, so the cargo demonstrably went the warehouse route and its + * GRN paperwork must stand. + */ + async setExportHandoverMode( + bookingId: string, + mode: string, + ): Promise<{ bookingId: string; exportHandoverMode: string }> { + const booking = await this.bookingsRepository.findById(bookingId); + if (!booking) { + throw new NotFoundException(`Booking ${bookingId} not found`); + } + if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') { + throw new BadRequestException('Handover mode applies to export bookings only'); + } + if (mode === DIRECT_TO_TRAIN) { + const [stored]: Array<{ one: number }> = await this.dataSource.query( + `SELECT 1 AS one + FROM freight.warehouse_inventory + WHERE booking_id = $1 AND deleted_at IS NULL + LIMIT 1`, + [bookingId], + ); + if (stored) { + throw new BadRequestException( + 'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train', + ); + } + } + await this.dataSource.query( + `UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`, + [bookingId, mode], + ); + return { bookingId, exportHandoverMode: mode }; + } + async findById(id: string): Promise { const booking = await this.bookingsRepository.findByIdWithFiles(id); if (!booking) { diff --git a/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts b/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts new file mode 100644 index 000000000..c2685cba0 --- /dev/null +++ b/apps/edr-freight-api/src/modules/bookings/dto/set-export-handover-mode.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn } from 'class-validator'; + +import { DIRECT_TO_TRAIN, WAREHOUSE } from '../../../common/export-received-gate'; + +export class SetExportHandoverModeDto { + @ApiProperty({ + enum: [DIRECT_TO_TRAIN, WAREHOUSE], + description: + 'DIRECT_TO_TRAIN — the customer truck loads straight onto the wagon (no warehouse, no GRN). ' + + 'WAREHOUSE — received at the warehouse and issued a GRN first.', + }) + @IsIn([DIRECT_TO_TRAIN, WAREHOUSE]) + exportHandoverMode!: string; +} diff --git a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts index 3d339fa19..dbc306e3b 100644 --- a/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts +++ b/apps/edr-freight-api/src/modules/bookings/entities/booking.entity.ts @@ -302,6 +302,18 @@ export class Booking extends BaseEntity { @Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true }) customerTruckArrivedAt?: Date | null; + /** + * EXPORT only. How the cargo reaches the train: + * - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No + * warehouse, so no GRN is ever raised and the carriage acceptance sheet is + * the only document handed over. + * - WAREHOUSE (also null) — received into the warehouse and GRN'd first. + * + * Null is treated as WAREHOUSE so existing bookings keep the GRN gate. + */ + @Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true }) + exportHandoverMode?: string | null; + /** * Did the goods need re-handling in the warehouse? Recorded by warehouse * staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule; diff --git a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts index e6fdd47ee..ec6df69c5 100644 --- a/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts +++ b/apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts @@ -32,7 +32,7 @@ export class Container extends BaseEntity { type: 'varchar', nullable: true, }) -sealNumber!: string | null; + sealNumber!: string | null; @Column({ type: 'varchar', default: 'AVAILABLE' }) status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED diff --git a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts index 59afdb585..441544b39 100644 --- a/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts +++ b/apps/edr-freight-api/src/modules/contracts/contract-booking.service.ts @@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto'; import { BookingInvoiceService } from '../bookings/booking-invoice.service'; import { validate20ftWeightPairing } from '../bookings/container-pairing.util'; import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity'; -import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { BookingBatchService } from '../train-scheduling/booking-batch.service'; import { eatDay } from '../train-scheduling/batch-window.util'; import { wagonsPerUnitForSize } from '../rule-engine/container-type.util'; diff --git a/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts b/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts new file mode 100644 index 000000000..0b2a16026 --- /dev/null +++ b/apps/edr-freight-api/src/modules/contracts/contract-onetime-paid-gate.spec.ts @@ -0,0 +1,38 @@ +import { ContractsRepository } from './contracts.repository'; + +/** + * A ONE_TIME contract stops blocking a duplicate request only once its booking + * is PAID. The existing duplicate-guard spec stubs the repository out, so the + * candidate SQL itself is unchecked there — this pins the predicate. + */ +describe('findDuplicateCandidates ONE_TIME paid gate', () => { + const candidateSql = (): string => { + const conditions: string[] = []; + const qb = { + leftJoinAndSelect: () => qb, + where: () => qb, + andWhere: (condition: string) => { + if (typeof condition === 'string') conditions.push(condition); + return qb; + }, + getMany: async () => [], + }; + + const repository = new ContractsRepository( + { createQueryBuilder: () => qb } as never, + {} as never, + ); + + void repository.findDuplicateCandidates('company-1', 'svc-1'); + return conditions.join(' AND '); + }; + + it('spends the contract on payment, not on the booking row existing', () => { + const sql = candidateSql(); + + expect(sql).toContain("contract.contract_kind <> 'ONE_TIME'"); + // The gate: an unpaid booking must NOT free the lane. + expect(sql).toContain("b.payment_status = 'PAID'"); + expect(sql).toContain('b.deleted_at IS NULL'); + }); +}); diff --git a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts index a7fddd816..2ab7e3d7e 100644 --- a/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts +++ b/apps/edr-freight-api/src/modules/contracts/contracts.repository.ts @@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository { .andWhere('contract.status NOT IN (:...terminal)', { terminal: TERMINAL_CONTRACT_STATUSES, }) - // A ONE_TIME contract allows a single booking, so once that booking - // exists the contract is spent and can never carry another shipment. + // A ONE_TIME contract allows a single booking, so once that booking is + // PAID the contract is spent and can never carry another shipment. // Without this it kept blocking new requests on the same service type + // route until its validity lapsed — locking a customer out of a lane for // the rest of the term after one completed shipment. + // Payment is the gate, not the booking row: a DRAFT or abandoned unpaid + // booking must keep the contract blocking, otherwise a customer holds an + // unpaid booking and requests an identical contract alongside it. .andWhere( `(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS ( SELECT 1 FROM freight.bookings b WHERE b.contract_id = contract.id AND b.deleted_at IS NULL + AND b.payment_status = 'PAID' ))`, ) .getMany(); diff --git a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts index fefbbc868..7283c874a 100644 --- a/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts +++ b/apps/edr-freight-api/src/modules/scheduling-reschedule/scheduling-reschedule.service.ts @@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity'; import { BookingsRepository } from '../bookings/bookings.repository'; import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; -import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service'; +import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service'; import { BookingNotifierService } from '../train-scheduling/booking-notifier.service'; import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto'; import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; 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 e89c56b2a..23024bfdf 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 @@ -37,7 +37,7 @@ import { BookingNotifierService } from './booking-notifier.service'; import { TrainSchedulingService, effectiveWindowConfig, -} from './train-scheduling.service'; +} from './services/train-scheduling.service'; import { eatDay, listConfigBookingWindows } from './batch-window.util'; import { BATCH_BOARD_STATUSES, @@ -90,7 +90,7 @@ import { BookingWindowGateway } from './booking-window.gateway'; import { MAX_TEU_SLOTS_PER_WAGON, containerWagonsForLines, -} from './wagon-plan.util'; +} from './utils/wagon-plan.util'; import { Capacity, CorridorBudget, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts index 9eb6c0bf0..5036b01c1 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/booking-window.service.ts @@ -19,7 +19,7 @@ import { } from '../notifications/resolve-company-phone.util'; import { BookingBatchService } from './booking-batch.service'; import { BookingWindowGateway } from './booking-window.gateway'; -import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; +import { TrainSchedulingService, effectiveWindowConfig } from './services/train-scheduling.service'; import { BATCH_TIMEZONE } from './booking-batch.constants'; import { bookingCloseCutoff, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts index 72ee1407e..bbfaedaf0 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/container-placement.util.ts @@ -1,4 +1,4 @@ -import type { ContainerPlacementInput } from './wagon-plan.util'; +import type { ContainerPlacementInput } from './utils/wagon-plan.util'; export type ContainerUnitForPlacement = { bookingId: string; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts similarity index 91% rename from apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts rename to apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts index 2c92ba00d..c87d118c8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts @@ -1,8 +1,8 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger"; import type { Response } from "express"; -import type { AuthUserPayload } from "../../common/resolve-auth-user-id"; -import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service"; -import { resolveAuthUserId } from "../../common/resolve-auth-user-id"; +import type { AuthUserPayload } from "../../../common/resolve-auth-user-id"; +import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service"; +import { resolveAuthUserId } from "../../../common/resolve-auth-user-id"; import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res, @@ -19,46 +19,46 @@ import { TrainSchedulingRulesManage, TrainSchedulingUpdate, TrainSchedulingView, -} from "../../common/booking-guards"; -import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry"; -import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto"; -import { AssignBookingsDto } from "./dto/assign-bookings.dto"; -import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; -import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto"; -import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; -import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto"; -import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto"; -import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto"; -import { PinWagonsDto } from "./dto/pin-wagons.dto"; -import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto"; -import { UpdateContainerItemDto } from "./dto/update-container-item.dto"; -import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto"; -import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto"; -import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto"; -import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto"; -import { RecordCheckpointDto } from "./dto/record-checkpoint.dto"; +} from "../../../common/booking-guards"; +import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry"; +import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto"; +import { AssignBookingsDto } from "../dto/assign-bookings.dto"; +import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto"; +import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto"; +import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto"; +import { GetEligibleBookingsDto } from "../dto/get-eligible-bookings.dto"; +import { GetEligibleBulkBookingsDto } from "../dto/get-eligible-bulk-bookings.dto"; +import { GetEligibleContainerBookingsDto } from "../dto/get-eligible-container-bookings.dto"; +import { PinWagonsDto } from "../dto/pin-wagons.dto"; +import { MoveWagonLoadDto } from "../dto/move-wagon-load.dto"; +import { UpdateContainerItemDto } from "../dto/update-container-item.dto"; +import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-status.dto"; +import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto"; +import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto"; +import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto"; +import { RecordCheckpointDto } from "../dto/record-checkpoint.dto"; import { ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, -} from "./dto/import-djibouti-operation.dto"; -import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto"; -import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto"; -import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto"; -import { BatchBoardQueryDto } from "./dto/batch-board-query.dto"; -import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto"; -import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto"; -import { AvailableDaysQueryDto } from "./dto/available-days-query.dto"; -import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto"; -import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto"; -import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto"; -import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto"; -import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto"; -import { TrainSchedulingService } from "./train-scheduling.service"; -import { BookingBatchService } from "./booking-batch.service"; -import { BookingJourneyService } from "./booking-journey.service"; -import { BookingWindowService } from "./booking-window.service"; -import { IntercityService } from "./intercity.service"; -import { BillingService } from "../billing/billing.service"; +} from "../dto/import-djibouti-operation.dto"; +import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto"; +import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto"; +import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto"; +import { BatchBoardQueryDto } from "../dto/batch-board-query.dto"; +import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto"; +import { ListTrainSchedulesQueryDto } from "../dto/list-train-schedules-query.dto"; +import { AvailableDaysQueryDto } from "../dto/available-days-query.dto"; +import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto"; +import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto"; +import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto"; +import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto"; +import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto"; +import { TrainSchedulingService } from "../services/train-scheduling.service"; +import { BookingBatchService } from "../booking-batch.service"; +import { BookingJourneyService } from "../booking-journey.service"; +import { BookingWindowService } from "../booking-window.service"; +import { IntercityService } from "../intercity.service"; +import { BillingService } from "../../billing/billing.service"; @ApiTags("train-scheduling") @ApiBearerAuth() diff --git a/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts index 912f437f4..8521f4001 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/gatepass-payment-gate.spec.ts @@ -1,4 +1,4 @@ -import { TrainSchedulingService } from './train-scheduling.service'; +import { TrainSchedulingService } from './services/train-scheduling.service'; import { Booking } from '../bookings/entities/booking.entity'; import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts b/apps/edr-freight-api/src/modules/train-scheduling/repositories/train-checkpoint-events.repository.ts similarity index 89% rename from apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts rename to apps/edr-freight-api/src/modules/train-scheduling/repositories/train-checkpoint-events.repository.ts index 210de382e..195b2e596 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-checkpoint-events.repository.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/repositories/train-checkpoint-events.repository.ts @@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; +import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; @Injectable() export class TrainCheckpointEventsRepository extends BaseRepository { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts similarity index 99% rename from apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts index 455dc3d9c..0a7384cfa 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts @@ -1,11 +1,11 @@ import { BadRequestException, ConflictException } from '@nestjs/common'; import { WagonStatus } from '@edr/types'; -import { Wagon } from '../wagons/entities/wagon.entity'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; -import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; -import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity'; +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; import { TrainSchedulingService } from './train-scheduling.service'; const nw5 = { diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts similarity index 98% rename from apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts rename to apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts index b5658e556..657703ada 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts @@ -36,71 +36,71 @@ import { import { buildPaginationMeta, normalizePagination, -} from '../../common/utils/pagination.util'; -import { BookingsRepository } from '../bookings/bookings.repository'; -import { Booking } from '../bookings/entities/booking.entity'; -import { BookingContainer } from '../bookings/entities/booking-container.entity'; -import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity'; -import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service'; -import { Contract } from '../contracts/entities/contract.entity'; -import { Container } from '../container-management/entities/container.entity'; -import { Locomotive } from '../locomotives/entities/locomotive.entity'; -import { LocomotivesRepository } from '../locomotives/locomotives.repository'; -import { formatRouteLabel, Route } from '../routes/entities/route.entity'; -import { WagonMovement } from '../wagons/entities/wagon-movement.entity'; -import { Train } from '../trains/entities/train.entity'; -import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity'; -import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity'; -import { TrainSet } from '../train-sets/entities/train-set.entity'; -import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity'; -import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity'; -import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity'; -import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity'; -import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity'; -import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository'; -import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; -import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository'; -import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository'; -import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository'; -import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository'; -import { Yard } from '../rule-engine/entities/yard.entity'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; -import { WagonTypesRepository } from '../wagon-types/wagon-types.repository'; -import { Wagon } from '../wagons/entities/wagon.entity'; -import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto'; -import { AssignBookingsDto } from './dto/assign-bookings.dto'; -import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto'; -import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto'; -import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto'; -import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto'; +} from '../../../common/utils/pagination.util'; +import { BookingsRepository } from '../../bookings/bookings.repository'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { BookingContainer } from '../../bookings/entities/booking-container.entity'; +import { ClearanceMilestone } from '../../contracts/entities/clearance-milestone.entity'; +import { ClearanceMilestoneService } from '../../contracts/clearance-milestone.service'; +import { Contract } from '../../contracts/entities/contract.entity'; +import { Container } from '../../container-management/entities/container.entity'; +import { Locomotive } from '../../locomotives/entities/locomotive.entity'; +import { LocomotivesRepository } from '../../locomotives/locomotives.repository'; +import { formatRouteLabel, Route } from '../../routes/entities/route.entity'; +import { WagonMovement } from '../../wagons/entities/wagon-movement.entity'; +import { Train } from '../../trains/entities/train.entity'; +import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity'; +import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity'; +import { TrainSet } from '../../train-sets/entities/train-set.entity'; +import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity'; +import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity'; +import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; +import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity'; +import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity'; +import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository'; +import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository'; +import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository'; +import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository'; +import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository'; +import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository'; +import { Yard } from '../../rule-engine/entities/yard.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; +import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository'; +import { Wagon } from '../../wagons/entities/wagon.entity'; +import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto'; +import { AssignBookingsDto } from '../dto/assign-bookings.dto'; +import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto'; +import { GetEligibleBookingsDto } from '../dto/get-eligible-bookings.dto'; +import { GetEligibleBulkBookingsDto } from '../dto/get-eligible-bulk-bookings.dto'; +import { GetEligibleContainerBookingsDto } from '../dto/get-eligible-container-bookings.dto'; import { ListTrainSchedulesQueryDto, TrainScheduleFreightType, -} from './dto/list-train-schedules-query.dto'; -import { PinWagonsDto } from './dto/pin-wagons.dto'; -import { MoveWagonLoadDto } from './dto/move-wagon-load.dto'; -import { UpdateContainerItemDto } from './dto/update-container-item.dto'; -import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto'; -import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto'; -import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto'; -import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto'; -import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; +} from '../dto/list-train-schedules-query.dto'; +import { PinWagonsDto } from '../dto/pin-wagons.dto'; +import { MoveWagonLoadDto } from '../dto/move-wagon-load.dto'; +import { UpdateContainerItemDto } from '../dto/update-container-item.dto'; +import { UpdateImportLoadingStatusDto } from '../dto/update-import-loading-status.dto'; +import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.dto'; +import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto'; +import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto'; +import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity'; +import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto'; import { ImportDjiboutiOperation, type ImportDjiboutiDocumentType, -} from './entities/import-djibouti-operation.entity'; +} from '../entities/import-djibouti-operation.entity'; import { ImportDjiboutiActionDto, UploadImportDjiboutiDocumentDto, -} from './dto/import-djibouti-operation.dto'; -import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto'; -import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto'; -import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto'; -import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto'; -import { type BookingWindowConfig } from './booking-window.config'; -import { BookingWindowGateway } from './booking-window.gateway'; -import { BookingNotifierService } from './booking-notifier.service'; -import { BookingBatchService } from './booking-batch.service'; +} from '../dto/import-djibouti-operation.dto'; +import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto'; +import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto'; +import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto'; +import { type BookingWindowConfig } from '../booking-window.config'; +import { BookingWindowGateway } from '../booking-window.gateway'; +import { BookingNotifierService } from '../booking-notifier.service'; +import { BookingBatchService } from '../booking-batch.service'; import { computeFleetAvailability, summarizeFleetWarnings, @@ -109,13 +109,13 @@ import { type BookingWagonShortage, type DeferredBookingRow, type FleetAvailabilityRow, -} from './fleet-plan.util'; +} from '../utils/fleet-plan.util'; import { applyWagonOrderReversal, planWagonsWithStock, type AllowedWagonTypeMap, type WagonStock, -} from './wagon-plan-flex.util'; +} from '../wagon-plan-flex.util'; import { containerWagonsForLines, expandBookingContainerUnits, @@ -129,10 +129,10 @@ import { validateMixedTrainLimitsPerEdge, type ContainerPlacementInput, type WagonPlanSlot, -} from './wagon-plan.util'; -import { CorridorBudget } from './corridor-capacity.util'; -import { deriveScheduleDirection } from './derive-schedule-direction.util'; -import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util'; +} from '../utils/wagon-plan.util'; +import { CorridorBudget } from '../corridor-capacity.util'; +import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util'; +import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util'; import { bookingCargoTons, bulkItemsFitFor, @@ -144,7 +144,7 @@ import { wagonTypeDimensionsFromEntity, LocomotiveLimits, WagonTypeDimensions, -} from './train-capacity.util'; +} from '../train-capacity.util'; import { DEFAULT_BULK_WAGON_CAPACITY_TONS, DEFAULT_BULK_WAGON_LENGTH_METERS, @@ -153,8 +153,8 @@ import { DEFAULT_CONTAINER_WAGON_LENGTH_METERS, DEFAULT_CONTAINER_WAGON_TARE_TONS, paymentDrainEndsAtIso, -} from './booking-batch.constants'; -import { orderConsistWagons } from './consist-order.util'; +} from '../booking-batch.constants'; +import { orderConsistWagons } from '../consist-order.util'; import { computeExportWindowTimes, computeImportWindowTimes, @@ -162,22 +162,22 @@ import { eatDay, eatDayToUtc, shiftEatDay, -} from './batch-window.util'; -import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; -import { BookingJourneyService } from './booking-journey.service'; -import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; -import { RecordCheckpointDto } from './dto/record-checkpoint.dto'; -import { RouteMilestone } from '../routes/entities/route-milestone.entity'; -import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; -import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service'; -import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service'; +} from '../batch-window.util'; +import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity'; +import { BookingJourneyService } from '../booking-journey.service'; +import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository'; +import { RecordCheckpointDto } from '../dto/record-checkpoint.dto'; +import { RouteMilestone } from '../../routes/entities/route-milestone.entity'; +import { deriveTradeDirection } from '../../../common/derive-trade-direction.util'; +import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service'; +import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service'; import { autoFillPlacements, findMissingContainerNumberIssues, isPlaceholderContainerNumber, placementsForBookings, type ContainerUnitForPlacement, -} from './container-placement.util'; +} from '../container-placement.util'; const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const; @@ -5709,7 +5709,7 @@ export class TrainSchedulingService { } private resolveScheduleFreightType( - schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, + schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule, ): 'CONTAINER' | 'BULK' | 'MIXED' | null { const types = new Set( (schedule.scheduleBookings ?? []) @@ -5751,7 +5751,7 @@ export class TrainSchedulingService { throw new ConflictException('Could not allocate a unique schedule reference'); } - private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) { + private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) { return { id: schedule.id, reference: schedule.reference ?? null, @@ -6907,7 +6907,7 @@ export class TrainSchedulingService { originYardId?: string, destinationYardId?: string, ): Promise< - import('../train-schedules/entities/train-schedule.entity').TrainSchedule[] + import('../../train-schedules/entities/train-schedule.entity').TrainSchedule[] > { const schedules = await this.trainSchedulesRepository.findAll({ where: { @@ -7497,7 +7497,7 @@ export class TrainSchedulingService { } private async mapScheduleDetail( - schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule, + schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule, ) { const allocations = (schedule.trainSet?.wagons ?? []).flatMap( (w) => w.allocations ?? [], diff --git a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts index 56f2cff00..dd5cc74bc 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.module.ts @@ -24,9 +24,9 @@ import { WarehousesModule } from '../warehouses/warehouses.module'; import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity'; import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity'; import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity'; -import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository'; -import { TrainSchedulingController } from './train-scheduling.controller'; -import { TrainSchedulingService } from './train-scheduling.service'; +import { TrainCheckpointEventsRepository } from './repositories/train-checkpoint-events.repository'; +import { TrainSchedulingController } from './controllers/train-scheduling.controller'; +import { TrainSchedulingService } from './services/train-scheduling.service'; import { BookingBatchService } from './booking-batch.service'; import { BookingNotifierService } from './booking-notifier.service'; import { BookingWindowGateway } from './booking-window.gateway'; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.spec.ts similarity index 100% rename from apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.ts similarity index 65% rename from apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.ts index 7e7358358..1916f5bb8 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/derive-schedule-direction.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/derive-schedule-direction.util.ts @@ -1,4 +1,4 @@ -import { deriveTradeDirection } from '../../common/derive-trade-direction.util'; +import { deriveTradeDirection } from '../../../common/derive-trade-direction.util'; /** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */ export const deriveScheduleDirection = deriveTradeDirection; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.spec.ts similarity index 96% rename from apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.spec.ts index ff685d30c..23a432a1c 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.spec.ts @@ -1,5 +1,5 @@ -import { Booking } from '../bookings/entities/booking.entity'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { computeFleetAvailability, selectBookingsWithinFleetCap, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts similarity index 96% rename from apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts index 2769c212b..1dbd6e05a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts @@ -1,6 +1,6 @@ -import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util'; -import type { Booking } from '../bookings/entities/booking.entity'; -import type { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { bookingCargoTons, bulkWagonsForAllowedTypes } from '../train-capacity.util'; +import type { Booking } from '../../bookings/entities/booking.entity'; +import type { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { buildBulkWagonPlan, buildContainerWagonPlan, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts similarity index 98% rename from apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts index 43c9d0f31..52176662a 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.spec.ts @@ -1,7 +1,7 @@ import { AllocationLoadType } from '@edr/types'; -import { Booking } from '../bookings/entities/booking.entity'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { buildBulkWagonPlan, buildContainerWagonPlan, diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts similarity index 99% rename from apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts index 9720e46c4..01c497262 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts @@ -1,8 +1,8 @@ import { AllocationLoadType } from '@edr/types'; -import { Booking } from '../bookings/entities/booking.entity'; -import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util'; -import { WagonType } from '../wagon-types/entities/wagon-type.entity'; +import { Booking } from '../../bookings/entities/booking.entity'; +import { containersPerWagonForSize, wagonsPerUnitForSize } from '../../rule-engine/container-type.util'; +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; import { bookingCargoTons, bulkItemsFitFor, @@ -10,7 +10,7 @@ import { bulkTonsPerWagon, bulkTonWagonsRequired, consistViolations, -} from './train-capacity.util'; +} from '../train-capacity.util'; export const MAX_TRAIN_WEIGHT_TONS = 3500; export const MAX_TRAIN_LENGTH_METERS = 760; diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-readiness.util.spec.ts similarity index 100% rename from apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.spec.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-readiness.util.spec.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-readiness.util.ts similarity index 100% rename from apps/edr-freight-api/src/modules/train-scheduling/wagon-readiness.util.ts rename to apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-readiness.util.ts diff --git a/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-type-resolver.util.ts b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-type-resolver.util.ts new file mode 100644 index 000000000..6ce404ed1 --- /dev/null +++ b/apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-type-resolver.util.ts @@ -0,0 +1,49 @@ +import { WagonType } from '../../wagon-types/entities/wagon-type.entity'; + +const CARGO_CODE_TO_WAGON_TYPE: Record = { + COFFEE: 'KW2', + GRAIN: 'KW2', + WHEAT: 'KW2', + SORGHUM: 'KW2', + CORN: 'KW2', + FERTILIZER: 'PW2', + SUGAR: 'PW2', + COAL: 'KW3', + STEEL: 'CW3', + ORE: 'CW3', +}; + +const DEFAULT_BULK_WAGON_TYPE = 'CW3'; +const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5'; + +/** + * Resolve wagon type code from cargo type code for bulk freight. + */ +export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string { + if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE; + const normalized = cargoTypeCode.trim().toUpperCase(); + return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE; +} + +/** + * Pick the best matching wagon type entity for bulk cargo. + */ +export function pickBulkWagonType( + wagonTypes: WagonType[], + cargoTypeCode?: string | null, +): WagonType | undefined { + const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode); + const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive); + if (direct) return direct; + + return wagonTypes.find( + (wt) => + wt.isActive && + !wt.supportsContainer && + wt.code !== DEFAULT_CONTAINER_WAGON_TYPE, + ); +} + +export function getDefaultContainerWagonTypeCode(): string { + return DEFAULT_CONTAINER_WAGON_TYPE; +} diff --git a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts index 8d9339186..af0cd11f4 100644 --- a/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts +++ b/apps/edr-freight-api/src/modules/train-scheduling/wagon-plan-flex.util.spec.ts @@ -4,7 +4,7 @@ import { applyWagonOrderReversal, planWagonsWithStock, } from './wagon-plan-flex.util'; -import type { WagonPlanSlot } from './wagon-plan.util'; +import type { WagonPlanSlot } from './utils/wagon-plan.util'; const nw6: WagonType = { id: 'wt-nw6', 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 ef1e741be..f7db22506 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 @@ -11,7 +11,7 @@ import { sortBookingsForScheduling, type BookingWagonShortage, type DeferredBookingRow, -} from './fleet-plan.util'; +} from './utils/fleet-plan.util'; import { MAX_TEU_SLOTS_PER_WAGON, containerWagonsForLines, @@ -21,7 +21,7 @@ import { teuSlotsForSizeFt, type SlotLoadType, type WagonPlanSlot, -} from './wagon-plan.util'; +} from './utils/wagon-plan.util'; /** * Wagon types allowed to carry each container type / bulk cargo type — the 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 59a04f08c..2f204bd62 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 @@ -1425,6 +1425,9 @@ export class WarehouseInventoryService { WHERE b.deleted_at IS NULL AND b.payment_status = 'PAID' AND inv.id IS NULL + -- Direct truck-to-train cargo never comes to the warehouse, so never + -- offer it for receipt. + AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN' ORDER BY b.scheduled_date DESC NULLS LAST`, ); 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 95ea1db59..06511cb2b 100644 --- a/apps/edr-freight-api/src/seed/freight-permissions.registry.ts +++ b/apps/edr-freight-api/src/seed/freight-permissions.registry.ts @@ -482,6 +482,13 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [ "edr_freight_app:invoices:eims_resolve", "Resolve a blocked MoR EIMS submission", ), + // USD bookings are paid by bank transfer; Finance uploads the slip and settles + // the invoice. Moves money state, so it is its own grant, not part of view. + perm( + "d2b00001-0001-4000-8000-000000000007", + "edr_freight_app:invoices:confirm_offline", + "Confirm offline (bank transfer) invoice payment", + ), ]; // E. First / last mile operations @@ -1640,6 +1647,7 @@ export const FREIGHT_PERMS = { export: "edr_freight_app:invoices:export", eimsRegister: "edr_freight_app:invoices:eims_register", eimsResolve: "edr_freight_app:invoices:eims_resolve", + confirmOffline: "edr_freight_app:invoices:confirm_offline", }, firstMile: { view: "edr_freight_app:first_mile:view", diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index 255413271..4f13ec6e0 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -36,6 +36,7 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage"; import CustomersPage from "./pages/customers/CustomersPage"; import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage"; import InvoicesPage from "./pages/invoices/InvoicesPage"; +import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage"; import MyProfilePage from "./pages/dashboard/MyProfilePage"; import OverviewPage from "./pages/dashboard/OverviewPage"; import ReportsHubPage from "./pages/reports/ReportsHubPage"; @@ -266,6 +267,14 @@ const App = () => { } /> + + + + } + /> - `${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`; + `${booking.paymentCurrency} ${n.toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; return ( @@ -74,7 +77,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) { {li.description} - {Number(li.amount).toLocaleString()} {li.currency} + {Number(li.amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + {li.currency} ))} diff --git a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx index 4427367cb..65c9719ed 100644 --- a/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx +++ b/apps/edr-freight-web/backoffice/src/components/bookings/detail/BookingTrucksPanel.tsx @@ -11,7 +11,10 @@ import { SectionCard } from "./SectionCard"; import { MetricTile } from "./MetricTile"; const money = (amount: number, currency: string) => - `${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`; + `${Number(amount).toLocaleString(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} ${currency === "ETB" ? "Birr (ETB)" : currency}`; /** * Cargo costs (booking-level totals) plus the same truck-import block the diff --git a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx index 5502886d3..67e84a09f 100644 --- a/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx +++ b/apps/edr-freight-web/backoffice/src/components/contracts/GlCreateBookingForm.tsx @@ -348,6 +348,9 @@ export default function GlCreateBookingForm() { // Intercity shipments ride a passing import/export train staff pick at // finalize time — no shipment day is chosen and no window gate applies. const isIntercity = contract?.tradeDirection === "DOMESTIC"; + // USD billing is offered on import traffic only — export and domestic + // shipments are always invoiced in ETB. + const isImport = contract?.tradeDirection === "IMPORT"; // ONE_TIME split-remainder mode: a previous booking on this contract was // split on train capacity, so the capacity endpoint reports the outstanding @@ -1757,12 +1760,15 @@ export default function GlCreateBookingForm() { Billing currency - Shipments are invoiced in ETB. + {isImport + ? "Import shipments may be invoiced in ETB or USD. USD is paid by bank transfer, not online." + : "Shipments are invoiced in ETB."} diff --git a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx index 4ebca89b4..e0bf818ff 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx +++ b/apps/edr-freight-web/backoffice/src/components/layout/sidebar-sections.tsx @@ -9,6 +9,7 @@ import { FileText, Hammer, History, + Landmark, LayoutDashboard, LayoutGrid, MapPin, @@ -111,6 +112,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] icon: , permission: FREIGHT_PERMS.invoices.view, }, + { + label: "USD Payments", + href: "/dashboard/usd-payments", + icon: , + permission: FREIGHT_PERMS.invoices.view, + }, { label: "Support", href: "/dashboard/support", diff --git a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx index 4684a3ad1..397496fd2 100644 --- a/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx +++ b/apps/edr-freight-web/backoffice/src/components/ruleEngine/RuleEngineFormDialog.tsx @@ -561,7 +561,7 @@ const RuleEngineFormDialog = ({ // numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km), // so let the field carry decimals and let a 400 catch the rest. step={isNumber ? "any" : undefined} - disabled={field.disabled || computed !== undefined} + disabled={field.disabled || (field.disabledOnEdit && !!initialRecord) || computed !== undefined} value={String((computed !== undefined ? computed : values[field.name]) ?? "")} onChange={(e) => { const next = e.currentTarget.value; diff --git a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts index 6a1135e0d..b681a2909 100644 --- a/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/QUERY_KEYS.ts @@ -51,6 +51,8 @@ export const QUERY_KEYS = { list: (filter?: InvoiceListFilter) => ["invoices", "list", filter ?? {}] as const, byId: (id: string) => ["invoices", "detail", id] as const, + offlineUsd: (filter?: InvoiceListFilter) => + ["invoices", "offline-usd", filter ?? {}] as const, eimsStatus: (id: string) => ["invoices", "eims", id] as const, }, diff --git a/apps/edr-freight-web/backoffice/src/constants/URLS.ts b/apps/edr-freight-web/backoffice/src/constants/URLS.ts index 3f6d05257..3b482571c 100644 --- a/apps/edr-freight-web/backoffice/src/constants/URLS.ts +++ b/apps/edr-freight-web/backoffice/src/constants/URLS.ts @@ -105,6 +105,8 @@ export const URL_CONSTANTS = { INVOICES: "/billing/invoices", INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`, INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`, + OFFLINE_USD: "/billing/offline-usd", + CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`, }, // MoR EIMS filing. Mounted on /invoices, not /billing/invoices — see EimsInvoiceController. @@ -153,6 +155,8 @@ export const URL_CONSTANTS = { CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`, CARRIAGE_ACCEPTANCE_SHEET: (id: string) => `/bookings/${id}/carriage-acceptance-sheet`, + EXPORT_HANDOVER_MODE: (id: string) => + `/bookings/${id}/export-handover-mode`, SUMMARY: (id: string) => `/bookings/${id}/summary`, CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`, MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`, diff --git a/apps/edr-freight-web/backoffice/src/lib/permissions.ts b/apps/edr-freight-web/backoffice/src/lib/permissions.ts index 6e3de9528..09790091d 100644 --- a/apps/edr-freight-web/backoffice/src/lib/permissions.ts +++ b/apps/edr-freight-web/backoffice/src/lib/permissions.ts @@ -128,6 +128,7 @@ export const FREIGHT_PERMS = { invoices: { view: "edr_freight_app:invoices:view", export: "edr_freight_app:invoices:export", + confirmOffline: "edr_freight_app:invoices:confirm_offline", // Filing with MoR EIMS. Held by named admins rather than a role preset: registration is // irreversible at the tax authority, and resolving clears a system-wide filing block. eimsRegister: "edr_freight_app:invoices:eims_register", diff --git a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx index e77b4c44b..feee70b9d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/bookings/BookingRequestDetailPage.tsx @@ -22,6 +22,7 @@ import { Paper, Button, Box, + SegmentedControl, } from "@mantine/core"; import { PageContainer } from "@/components/page"; @@ -258,6 +259,44 @@ export default function BookingRequestDetailPage() { booking={booking} mutations={mutations} /> + {booking.tradeDirection === "EXPORT" && ( + + + + How the cargo reaches the train + + { + try { + await bookingsService.setExportHandoverMode( + booking.id, + value as "DIRECT_TO_TRAIN" | "WAREHOUSE", + ); + await refetch(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Could not change the handover mode", + ); + } + }} + /> + + {booking.exportHandoverMode === "DIRECT_TO_TRAIN" + ? "No warehouse receipt and no GRN — the carriage acceptance sheet is the handover document." + : "Cargo is received at the warehouse and issued a GRN before loading."} + + + + )} {booking.isGovernment && booking.contractSummary && ( + ); + }, + }, + { + id: "status", + header: "Status", + cell: ({ row }) => , + }, + { + id: "amount", + header: "Amount", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.totalAmount, row.original.currency)} + + ), + }, + { + id: "balance", + header: "Balance", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + {formatMoney(row.original.balanceAmount, row.original.currency)} + + ), + }, + { + id: "payWindow", + header: "Pay window", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => ( + + ), + }, + { + id: "action", + header: "", + meta: { headerClassName: "text-right", cellClassName: "text-right" }, + cell: ({ row }) => { + const paid = row.original.status === "PAID"; + if (paid || !canConfirm) return null; + return ( + + ); + }, + }, + ], + [canConfirm, navigate], + ); + + return ( + + void refetch()} + > + + + } + /> + + + + + + } + value={query} + onChange={(e) => setQuery(e.target.value)} + rightSection={ + query ? ( + setQuery("")} + > + + + ) : null + } + style={{ flex: 1, minWidth: "240px" }} + radius="lg" + /> + { + setStatusFilter( + v === "open" ? "" : (v as Freight.InvoiceStatus), + ); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }} + data={[ + { label: "Awaiting payment", value: "open" }, + { label: "Paid", value: "PAID" }, + { label: "Overdue", value: "OVERDUE" }, + ]} + /> + + {total} record{total !== 1 ? "s" : ""} + + + + + + + navigate(`/dashboard/invoices/${row.id}`)} + emptyMessage={ + debouncedQuery + ? "No USD invoices match your search." + : "No USD invoices awaiting confirmation." + } + error={ + isError + ? { + message: "Failed to load USD invoices.", + onRetry: () => void refetch(), + } + : undefined + } + pagination={{ + pageIndex: pagination.pageIndex, + pageSize: pagination.pageSize, + pageCount, + totalCount: total, + }} + tableOptions={{ + state: { pagination }, + onPaginationChange: setPagination, + manualPagination: true, + pageCount, + }} + containerClassName="border-0 shadow-none bg-transparent" + footer={DataTableFooter} + /> + + + + + + Confirm bank transfer payment + } + radius="md" + size="md" + > + {confirming && ( + + + Confirming settles {confirming.invoiceNumber} in full ( + {formatMoney(confirming.balanceAmount, confirming.currency)}) and + marks the booking as paid. Upload the customer's bank slip + first — this cannot be undone. + + + + + setReference(e.target.value)} + /> + + + + + + + )} + + + ); +} diff --git a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts index d5998515e..22e1046e8 100644 --- a/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts +++ b/apps/edr-freight-web/backoffice/src/pages/ruleEngine/config/resources.ts @@ -39,6 +39,8 @@ export interface FormFieldDef { placeholder?: string; description?: string; disabled?: boolean; + /** Editable on create, locked when editing an existing record. */ + disabledOnEdit?: boolean; /** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */ suffix?: string; /** Hide this field when another field currently equals one of these values. */ @@ -389,7 +391,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [ ], formFields: [ { name: "label", label: "Label", type: "text", required: true }, - { name: "sizeFt", label: "Size (ft)", type: "number", required: true }, + { name: "sizeFt", label: "Size (ft)", type: "number", required: true, disabledOnEdit: true }, // Options injected at render from useWagonTypeOptions (RuleEngineResourcePage). { name: "wagonTypeIds", diff --git a/apps/edr-freight-web/backoffice/src/services/api.ts b/apps/edr-freight-web/backoffice/src/services/api.ts index a6613d368..895a54070 100644 --- a/apps/edr-freight-web/backoffice/src/services/api.ts +++ b/apps/edr-freight-web/backoffice/src/services/api.ts @@ -41,6 +41,7 @@ import type { Invoice, InvoiceListFilter, PaginatedInvoices, + PaginatedOfflineUsdInvoices, } from "@/types/invoice"; import type { IOverviewDashboard, OverviewRange } from "@/types/overview"; import { @@ -2917,6 +2918,29 @@ export const api = { ({ id }) => QUERY_KEYS.INVOICES.byId(id), ), + listOfflineUsd: endpoint< + { filter: InvoiceListFilter }, + PaginatedOfflineUsdInvoices + >( + "invoices", + "listOfflineUsd", + ({ filter }) => invoicesService.listOfflineUsd(filter), + ({ filter }) => QUERY_KEYS.INVOICES.offlineUsd(filter), + ), + + confirmOffline: endpoint< + { id: string; file: File; reference?: string }, + Invoice + >( + "invoices", + "confirmOffline", + ({ id, file, reference }) => + invoicesService.confirmOffline(id, file, reference), + undefined, + // Settling the invoice also advances the booking, so refresh both trees. + () => [QUERY_KEYS.INVOICES.ROOT, QUERY_KEYS.BOOKINGS.ROOT], + ), + eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>( "invoices", "eimsStatus", diff --git a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts index 946a921b7..c1ce4e67d 100644 --- a/apps/edr-freight-web/backoffice/src/services/bookings.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/bookings.service.ts @@ -458,6 +458,13 @@ export const bookingsService = { return (unwrap(response.data) ?? []) as BookingDetail[]; }, + setExportHandoverMode: async ( + id: string, + exportHandoverMode: "DIRECT_TO_TRAIN" | "WAREHOUSE", + ): Promise => { + await client.patch(B.EXPORT_HANDOVER_MODE(id), { exportHandoverMode }); + }, + downloadCarriageAcceptanceSheet: async (id: string): Promise => { const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), { responseType: "blob", diff --git a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts index a4abc6ea2..18febdf7b 100644 --- a/apps/edr-freight-web/backoffice/src/services/invoices.service.ts +++ b/apps/edr-freight-web/backoffice/src/services/invoices.service.ts @@ -4,6 +4,7 @@ import type { Invoice, InvoiceListFilter, PaginatedInvoices, + PaginatedOfflineUsdInvoices, } from "@/types/invoice"; const cleanParams = (params: object) => @@ -33,4 +34,25 @@ export const invoicesService = { responseType: "blob", }); }, + + /** Finance worklist: USD invoices awaiting bank-transfer confirmation. */ + listOfflineUsd( + filter: InvoiceListFilter, + ): Promise { + return apiClient + .get(URL_CONSTANTS.BILLING.OFFLINE_USD, { + params: cleanParams(filter), + }) + .then((r) => r.data); + }, + + /** Confirm a USD invoice paid by bank transfer — the slip file is required. */ + confirmOffline(id: string, file: File, reference?: string): Promise { + const body = new FormData(); + body.append("file", file); + if (reference) body.append("reference", reference); + return apiClient + .post(URL_CONSTANTS.BILLING.CONFIRM_OFFLINE(id), body) + .then((r) => r.data); + }, }; diff --git a/apps/edr-freight-web/backoffice/src/types/booking.ts b/apps/edr-freight-web/backoffice/src/types/booking.ts index cf18abb27..69312e428 100644 --- a/apps/edr-freight-web/backoffice/src/types/booking.ts +++ b/apps/edr-freight-web/backoffice/src/types/booking.ts @@ -168,6 +168,11 @@ export interface BookingDetail { contractType: string; freightType: "CONTAINER" | "BULK"; tradeDirection: string; + /** + * EXPORT only. DIRECT_TO_TRAIN = customer truck loads straight onto the wagon + * (no warehouse, no GRN). null/WAREHOUSE = received and GRN'd first. + */ + exportHandoverMode?: "DIRECT_TO_TRAIN" | "WAREHOUSE" | null; /** What the containers carry / bulk commodity label — entered at booking time. */ cargoFreeText?: string | null; cargoTotalWeightVgm: number; diff --git a/apps/edr-freight-web/backoffice/src/types/invoice.ts b/apps/edr-freight-web/backoffice/src/types/invoice.ts index 26ecfcafa..14c99eb50 100644 --- a/apps/edr-freight-web/backoffice/src/types/invoice.ts +++ b/apps/edr-freight-web/backoffice/src/types/invoice.ts @@ -20,3 +20,22 @@ export interface PaginatedInvoices { items: Invoice[]; total: number; } + +/** + * A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows + * carry the shipment's pay-window deadline so the list can show the same + * countdown the customer sees — Finance must confirm before it closes. + */ +export interface OfflineUsdInvoice extends Invoice { + booking: { + id: string; + reference: string; + paymentDeadline: string | null; + paymentStatus: string; + } | null; +} + +export interface PaginatedOfflineUsdInvoices { + items: OfflineUsdInvoice[]; + total: number; +} diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts index 962b59bcc..b33017976 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/actions.ts @@ -1,5 +1,7 @@ import type { Freight } from "@edr/types"; +import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment"; + /** A pending customer action surfaced on the home "needs attention" card. */ export interface ActionItem { id: string; @@ -13,6 +15,8 @@ export interface ActionItem { targetId: string; /** Highlighted as action-required in the home card. */ urgent?: boolean; + /** USD booking: paid by bank transfer, no online payment modal. */ + offlinePay?: boolean; } /** @@ -60,13 +64,17 @@ export function deriveActionItems( ? b.status === "FULLY_EXECUTED" : b.status === "SELECTED_FOR_BATCH"); if (canPay) { + const offlinePay = isUsdOfflineBooking(b); items.push({ id: `pay-${b.id}`, kind: "pay", reference: b.reference, - description: "Payment due for this shipment", + description: offlinePay + ? "Payment due — pay by bank transfer and send the slip to Finance" + : "Payment due for this shipment", targetId: b.id, urgent: true, + offlinePay, }); } } diff --git a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx index 4b16e132d..7e3be49c3 100644 --- a/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx +++ b/apps/edr-freight-web/portal/src/pages/MyPortalPage/components/ActionNeededSection.tsx @@ -65,7 +65,10 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { const handleClick = (item: ActionItem) => { switch (item.kind) { case "pay": - setPayItem(item); + // USD is paid by bank transfer — the booking page shows the countdown + // and the pay-by-bank instructions instead of the payment modal. + if (item.offlinePay) navigate(`/bookings/${item.targetId}`); + else setPayItem(item); break; case "sign": navigate(`/contracts/${item.targetId}/view`); @@ -151,7 +154,9 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) { leftSection={} > {item.kind === "pay" - ? "Pay now" + ? item.offlinePay + ? "Pay by bank" + : "Pay now" : item.kind === "sign" ? "Sign" : "Book"} diff --git a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx index 775d9fe41..78116013e 100644 --- a/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx +++ b/apps/edr-freight-web/portal/src/pages/billing/InvoiceDetailPage.tsx @@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; import { Alert, + Badge, Box, Button, Center, @@ -21,6 +22,7 @@ import { CreditCard, Download, ExternalLink, + Landmark, Receipt, } from "lucide-react"; import toast from "react-hot-toast"; @@ -30,6 +32,7 @@ import { invoicesService } from "@/services/invoices.service"; import { useInvoicePayment } from "@/hooks/useInvoicePayment"; import { warehouseInvoicesService } from "@/services/warehouse-invoices.service"; import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal"; +import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment"; import { saveBlob } from "@/utils/download"; import { formatCurrency } from "@/lib/currency"; import { BORDER, INK, MUTED } from "../contracts/contract-ui"; @@ -224,7 +227,7 @@ export default function InvoiceDetailPage() { Receipt )} - {payable && ( + {payable && !isUsdCurrency(invoice.currency) && (