mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -36,4 +36,27 @@ describe('assertExportReceivedWithGrn', () => {
|
|||||||
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
|
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
|
||||||
).resolves.toBeUndefined();
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,15 @@ import type { DataSource, EntityManager } from 'typeorm';
|
|||||||
export interface ExportLoadGateBooking {
|
export interface ExportLoadGateBooking {
|
||||||
id: string;
|
id: string;
|
||||||
tradeDirection?: string | null;
|
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
|
* 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
|
* 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 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
|
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
|
||||||
* fallback older rows use.
|
* 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(
|
export async function assertExportReceivedWithGrn(
|
||||||
db: DataSource | EntityManager,
|
db: DataSource | EntityManager,
|
||||||
booking: ExportLoadGateBooking,
|
booking: ExportLoadGateBooking,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (booking.tradeDirection !== 'EXPORT') return;
|
if (booking.tradeDirection !== 'EXPORT') return;
|
||||||
|
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
|
||||||
|
|
||||||
const [row] = await db.query(
|
const [row] = await db.query(
|
||||||
`SELECT 1
|
`SELECT 1
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings
|
||||||
|
ADD COLUMN IF NOT EXISTS export_handover_mode varchar(20)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
ALTER TABLE freight.bookings DROP COLUMN IF EXISTS export_handover_mode
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} 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 type { Response } from "express";
|
||||||
|
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
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 { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||||
@@ -25,6 +37,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
|||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.invoices.view,
|
FREIGHT_PERMS.invoices.view,
|
||||||
FREIGHT_PERMS.invoices.export,
|
FREIGHT_PERMS.invoices.export,
|
||||||
|
FREIGHT_PERMS.invoices.confirmOffline,
|
||||||
])
|
])
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
@@ -56,6 +69,36 @@ export class BillingController {
|
|||||||
return this.billingService.findById(id);
|
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")
|
@Get("invoices/:id/document")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository";
|
|||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
import { PaymentModule } from "../payment/payment.module";
|
import { PaymentModule } from "../payment/payment.module";
|
||||||
import { CompaniesModule } from "../companies/companies.module";
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
import { FilesModule } from "../files/files.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
|||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
|
FilesModule,
|
||||||
],
|
],
|
||||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
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,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, defaultManager, txManager, transaction };
|
return { service, defaultManager, txManager, transaction };
|
||||||
};
|
};
|
||||||
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, manager };
|
return { service, manager };
|
||||||
};
|
};
|
||||||
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
payment as never,
|
payment as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -669,11 +677,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
|
||||||
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
|
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
|
||||||
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
|
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
|
||||||
// totalAmount — money missing from the bank with the books saying paid.
|
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
|
||||||
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
|
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
|
||||||
const invoice = {
|
const invoice = {
|
||||||
id: "inv-1",
|
id: "inv-1",
|
||||||
status: Freight.InvoiceStatus.Pending,
|
status: Freight.InvoiceStatus.Pending,
|
||||||
@@ -682,9 +690,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
|||||||
type: "PREPAID",
|
type: "PREPAID",
|
||||||
invoiceNumber: "INV-20260101-00001",
|
invoiceNumber: "INV-20260101-00001",
|
||||||
currency: "ETB",
|
currency: "ETB",
|
||||||
// .40 — the case Math.round gets wrong (rounds down, underpays).
|
// .43 — cents that must survive all the way to the bill.
|
||||||
balanceAmount: 12345.4,
|
balanceAmount: 12345.43,
|
||||||
totalAmount: 12345.4,
|
totalAmount: 12345.43,
|
||||||
company: { name: "Acme PLC" },
|
company: { name: "Acme PLC" },
|
||||||
paymentId: null,
|
paymentId: null,
|
||||||
dueAt: null,
|
dueAt: null,
|
||||||
@@ -703,11 +711,12 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
|||||||
payment as never,
|
payment as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, repo };
|
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({
|
const initiate = jest.fn().mockResolvedValue({
|
||||||
intentId: "intent-1",
|
intentId: "intent-1",
|
||||||
immediateSuccess: false,
|
immediateSuccess: false,
|
||||||
@@ -718,16 +727,16 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
|||||||
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
||||||
|
|
||||||
expect(initiate).toHaveBeenCalledWith(
|
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();
|
const { service } = build();
|
||||||
|
|
||||||
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
||||||
stillPayable: true,
|
stillPayable: true,
|
||||||
currentAmountMinor: 12346,
|
currentAmountMinor: 12345.43,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
|||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
|
|||||||
failureUrl?: string;
|
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. */
|
/** A single manual/offline settlement to record against an invoice. */
|
||||||
export interface RecordPaymentInput {
|
export interface RecordPaymentInput {
|
||||||
/** Amount settled by this payment; must be > 0. */
|
/** Amount settled by this payment; must be > 0. */
|
||||||
@@ -150,6 +159,7 @@ export class BillingService {
|
|||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
|
private readonly files: FilesService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -214,6 +224,146 @@ export class BillingService {
|
|||||||
return { items, total };
|
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<Invoice> {
|
||||||
|
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. */
|
/** Invoice header plus its line items. */
|
||||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
const invoice = await this.invoices.findById(id, {
|
const invoice = await this.invoices.findById(id, {
|
||||||
@@ -1191,11 +1341,11 @@ export class BillingService {
|
|||||||
// service branches on a domain-specific reference type.
|
// service branches on a domain-specific reference type.
|
||||||
referenceType: PaymentReferenceType.SHIPMENT,
|
referenceType: PaymentReferenceType.SHIPMENT,
|
||||||
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
||||||
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
|
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
|
||||||
// land below the outstanding balance — Math.round would let a .40 balance
|
// matches the debited amount to the cent (amountsMatchToTheCent), so any
|
||||||
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
|
// rounding here would overcharge the payer and leave the invoice balance
|
||||||
// ceil in billQuery keeps the quoted and debited amounts identical.
|
// non-zero. billQuery quotes the same unrounded value.
|
||||||
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
|
amountMinor: round2(Number(invoice.balanceAmount)),
|
||||||
currency: invoice.currency,
|
currency: invoice.currency,
|
||||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||||
method: opts.method ?? "TELEBIRR",
|
method: opts.method ?? "TELEBIRR",
|
||||||
@@ -1340,9 +1490,10 @@ export class BillingService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (open) {
|
if (open) {
|
||||||
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
|
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
|
||||||
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
|
// to be the amount the intent was opened for, to the cent, or /cbe/payment
|
||||||
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
|
// sees a mismatch.
|
||||||
|
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
|
||||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||||
return {
|
return {
|
||||||
stillPayable: balance > 0 && !expired,
|
stillPayable: balance > 0 && !expired,
|
||||||
@@ -1377,7 +1528,7 @@ export class BillingService {
|
|||||||
return {
|
return {
|
||||||
stillPayable: false,
|
stillPayable: false,
|
||||||
payerName: latest.company?.name ?? null,
|
payerName: latest.company?.name ?? null,
|
||||||
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
|
currentAmountMinor: round2(Number(latest.totalAmount)),
|
||||||
currency: latest.currency,
|
currency: latest.currency,
|
||||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||||
reason: closedInvoiceReason(latest.status),
|
reason: closedInvoiceReason(latest.status),
|
||||||
|
|||||||
@@ -279,9 +279,10 @@ export class BookingPricingService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
lineItems,
|
lineItems,
|
||||||
// Grand total is billed in whole currency units — fractional line sums
|
// Grand total keeps its cents, matching the line items it sums — rounding
|
||||||
// (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
|
// to whole birr made the total disagree with the breakdown (135,375.61 of
|
||||||
totalAmount: Math.round(total),
|
// lines shown as a 135,376.00 total) and CBE bills this figure to the cent.
|
||||||
|
totalAmount: round2(total),
|
||||||
currency: booking.paymentCurrency,
|
currency: booking.paymentCurrency,
|
||||||
usedRates: [...usedRatesMap.values()],
|
usedRates: [...usedRatesMap.values()],
|
||||||
appliedModifiers: ruleResult.appliedModifiers,
|
appliedModifiers: ruleResult.appliedModifiers,
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
|
|||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service';
|
|||||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||||
import { ContainerReceiptService } from './container-receipt.service';
|
import { ContainerReceiptService } from './container-receipt.service';
|
||||||
import { SignContractDto } from './dto/sign-contract.dto';
|
import { SignContractDto } from './dto/sign-contract.dto';
|
||||||
|
import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||||
import {
|
import {
|
||||||
@@ -757,6 +758,18 @@ export class BookingsController {
|
|||||||
return this.customerTruckService.getLoadableContainers(id);
|
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')
|
@Post(':id/customer-trucks/:assignmentId/load')
|
||||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||||
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common';
|
|||||||
// import { CustomersService } from '../customers/customers.service';
|
// import { CustomersService } from '../customers/customers.service';
|
||||||
import { CompaniesService } from '../companies/companies.service';
|
import { CompaniesService } from '../companies/companies.service';
|
||||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
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 { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { FilesService } from '../files/files.service';
|
import { FilesService } from '../files/files.service';
|
||||||
import { MinioService } from '../minio/minio.service';
|
import { MinioService } from '../minio/minio.service';
|
||||||
@@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
|||||||
import { DataSource, In } from 'typeorm';
|
import { DataSource, In } from 'typeorm';
|
||||||
|
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
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 { Yard } from '../rule-engine/entities/yard.entity';
|
||||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.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
|
// 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
|
// 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.
|
// 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;
|
const pendingWagons = wagons.length === 0;
|
||||||
if (pendingWagons) {
|
if (pendingWagons) {
|
||||||
const receivedLines: CarriageAcceptanceReceivedRow[] =
|
// Direct truck-to-train cargo never enters the warehouse, so there is no
|
||||||
booking.tradeDirection === 'EXPORT'
|
// 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(
|
? await this.dataSource.query(
|
||||||
`SELECT inv.weight AS "allocatedWeightTons",
|
`SELECT inv.weight AS "allocatedWeightTons",
|
||||||
c.container_number AS "containerNumbers"
|
c.container_number AS "containerNumbers"
|
||||||
@@ -304,6 +318,15 @@ export class BookingsService {
|
|||||||
[bookingId],
|
[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) {
|
if (receivedLines.length === 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
booking.tradeDirection === 'EXPORT'
|
booking.tradeDirection === 'EXPORT'
|
||||||
@@ -1997,6 +2020,47 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get a single booking by ID with files. */
|
/** 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<Booking> {
|
async findById(id: string): Promise<Booking> {
|
||||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||||
if (!booking) {
|
if (!booking) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -302,6 +302,18 @@ export class Booking extends BaseEntity {
|
|||||||
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
|
||||||
customerTruckArrivedAt?: Date | null;
|
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
|
* Did the goods need re-handling in the warehouse? Recorded by warehouse
|
||||||
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
|
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class Container extends BaseEntity {
|
|||||||
type: 'varchar',
|
type: 'varchar',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
})
|
})
|
||||||
sealNumber!: string | null;
|
sealNumber!: string | null;
|
||||||
|
|
||||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
||||||
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
|||||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
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 { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||||
})
|
})
|
||||||
// A ONE_TIME contract allows a single booking, so once that booking
|
// A ONE_TIME contract allows a single booking, so once that booking is
|
||||||
// exists the contract is spent and can never carry another shipment.
|
// PAID the contract is spent and can never carry another shipment.
|
||||||
// Without this it kept blocking new requests on the same service type +
|
// 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
|
// route until its validity lapsed — locking a customer out of a lane for
|
||||||
// the rest of the term after one completed shipment.
|
// 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(
|
.andWhere(
|
||||||
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||||
SELECT 1 FROM freight.bookings b
|
SELECT 1 FROM freight.bookings b
|
||||||
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||||
|
AND b.payment_status = 'PAID'
|
||||||
))`,
|
))`,
|
||||||
)
|
)
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
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 { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ import { BookingNotifierService } from './booking-notifier.service';
|
|||||||
import {
|
import {
|
||||||
TrainSchedulingService,
|
TrainSchedulingService,
|
||||||
effectiveWindowConfig,
|
effectiveWindowConfig,
|
||||||
} from './train-scheduling.service';
|
} from './services/train-scheduling.service';
|
||||||
import { eatDay, listConfigBookingWindows } from './batch-window.util';
|
import { eatDay, listConfigBookingWindows } from './batch-window.util';
|
||||||
import {
|
import {
|
||||||
BATCH_BOARD_STATUSES,
|
BATCH_BOARD_STATUSES,
|
||||||
@@ -90,7 +90,7 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
|||||||
import {
|
import {
|
||||||
MAX_TEU_SLOTS_PER_WAGON,
|
MAX_TEU_SLOTS_PER_WAGON,
|
||||||
containerWagonsForLines,
|
containerWagonsForLines,
|
||||||
} from './wagon-plan.util';
|
} from './utils/wagon-plan.util';
|
||||||
import {
|
import {
|
||||||
Capacity,
|
Capacity,
|
||||||
CorridorBudget,
|
CorridorBudget,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
} from '../notifications/resolve-company-phone.util';
|
} from '../notifications/resolve-company-phone.util';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
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 { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||||
import {
|
import {
|
||||||
bookingCloseCutoff,
|
bookingCloseCutoff,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ContainerPlacementInput } from './wagon-plan.util';
|
import type { ContainerPlacementInput } from './utils/wagon-plan.util';
|
||||||
|
|
||||||
export type ContainerUnitForPlacement = {
|
export type ContainerUnitForPlacement = {
|
||||||
bookingId: string;
|
bookingId: string;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
import type { AuthUserPayload } 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 { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
|
||||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
|
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
|
||||||
@@ -19,46 +19,46 @@ import {
|
|||||||
TrainSchedulingRulesManage,
|
TrainSchedulingRulesManage,
|
||||||
TrainSchedulingUpdate,
|
TrainSchedulingUpdate,
|
||||||
TrainSchedulingView,
|
TrainSchedulingView,
|
||||||
} from "../../common/booking-guards";
|
} from "../../../common/booking-guards";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
|
||||||
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto";
|
||||||
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
import { AssignBookingsDto } from "../dto/assign-bookings.dto";
|
||||||
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
|
||||||
import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto";
|
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
|
||||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto";
|
||||||
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
import { GetEligibleBookingsDto } from "../dto/get-eligible-bookings.dto";
|
||||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
import { GetEligibleBulkBookingsDto } from "../dto/get-eligible-bulk-bookings.dto";
|
||||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
import { GetEligibleContainerBookingsDto } from "../dto/get-eligible-container-bookings.dto";
|
||||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
import { PinWagonsDto } from "../dto/pin-wagons.dto";
|
||||||
import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto";
|
import { MoveWagonLoadDto } from "../dto/move-wagon-load.dto";
|
||||||
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
import { UpdateContainerItemDto } from "../dto/update-container-item.dto";
|
||||||
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-status.dto";
|
||||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
|
||||||
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
|
||||||
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
|
||||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto";
|
||||||
import {
|
import {
|
||||||
ImportDjiboutiActionDto,
|
ImportDjiboutiActionDto,
|
||||||
UploadImportDjiboutiDocumentDto,
|
UploadImportDjiboutiDocumentDto,
|
||||||
} from "./dto/import-djibouti-operation.dto";
|
} from "../dto/import-djibouti-operation.dto";
|
||||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
|
||||||
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
|
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto";
|
||||||
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
|
import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
|
||||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
|
||||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
|
||||||
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
|
import { ListTrainSchedulesQueryDto } from "../dto/list-train-schedules-query.dto";
|
||||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
|
||||||
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
|
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
||||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
||||||
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
|
||||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
|
||||||
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
|
import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto";
|
||||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
import { TrainSchedulingService } from "../services/train-scheduling.service";
|
||||||
import { BookingBatchService } from "./booking-batch.service";
|
import { BookingBatchService } from "../booking-batch.service";
|
||||||
import { BookingJourneyService } from "./booking-journey.service";
|
import { BookingJourneyService } from "../booking-journey.service";
|
||||||
import { BookingWindowService } from "./booking-window.service";
|
import { BookingWindowService } from "../booking-window.service";
|
||||||
import { IntercityService } from "./intercity.service";
|
import { IntercityService } from "../intercity.service";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../../billing/billing.service";
|
||||||
|
|
||||||
@ApiTags("train-scheduling")
|
@ApiTags("train-scheduling")
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@@ -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 { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
|
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
import { WagonStatus } from '@edr/types';
|
import { WagonStatus } from '@edr/types';
|
||||||
|
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
const nw5 = {
|
const nw5 = {
|
||||||
@@ -36,71 +36,71 @@ import {
|
|||||||
import {
|
import {
|
||||||
buildPaginationMeta,
|
buildPaginationMeta,
|
||||||
normalizePagination,
|
normalizePagination,
|
||||||
} from '../../common/utils/pagination.util';
|
} from '../../../common/utils/pagination.util';
|
||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../../bookings/bookings.repository';
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
|
||||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
import { ClearanceMilestone } from '../../contracts/entities/clearance-milestone.entity';
|
||||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
import { ClearanceMilestoneService } from '../../contracts/clearance-milestone.service';
|
||||||
import { Contract } from '../contracts/entities/contract.entity';
|
import { Contract } from '../../contracts/entities/contract.entity';
|
||||||
import { Container } from '../container-management/entities/container.entity';
|
import { Container } from '../../container-management/entities/container.entity';
|
||||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
|
||||||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
|
||||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
|
||||||
import { Train } from '../trains/entities/train.entity';
|
import { Train } from '../../trains/entities/train.entity';
|
||||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||||
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
|
||||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
|
||||||
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
|
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
|
||||||
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
|
||||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
|
||||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
|
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
import { GetEligibleBookingsDto } from '../dto/get-eligible-bookings.dto';
|
||||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
import { GetEligibleBulkBookingsDto } from '../dto/get-eligible-bulk-bookings.dto';
|
||||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
import { GetEligibleContainerBookingsDto } from '../dto/get-eligible-container-bookings.dto';
|
||||||
import {
|
import {
|
||||||
ListTrainSchedulesQueryDto,
|
ListTrainSchedulesQueryDto,
|
||||||
TrainScheduleFreightType,
|
TrainScheduleFreightType,
|
||||||
} from './dto/list-train-schedules-query.dto';
|
} from '../dto/list-train-schedules-query.dto';
|
||||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
import { PinWagonsDto } from '../dto/pin-wagons.dto';
|
||||||
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
|
import { MoveWagonLoadDto } from '../dto/move-wagon-load.dto';
|
||||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
import { UpdateContainerItemDto } from '../dto/update-container-item.dto';
|
||||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
import { UpdateImportLoadingStatusDto } from '../dto/update-import-loading-status.dto';
|
||||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.dto';
|
||||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||||||
|
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
||||||
import {
|
import {
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
type ImportDjiboutiDocumentType,
|
type ImportDjiboutiDocumentType,
|
||||||
} from './entities/import-djibouti-operation.entity';
|
} from '../entities/import-djibouti-operation.entity';
|
||||||
import {
|
import {
|
||||||
ImportDjiboutiActionDto,
|
ImportDjiboutiActionDto,
|
||||||
UploadImportDjiboutiDocumentDto,
|
UploadImportDjiboutiDocumentDto,
|
||||||
} from './dto/import-djibouti-operation.dto';
|
} 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 { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
||||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
|
||||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
import { type BookingWindowConfig } from '../booking-window.config';
|
||||||
import { type BookingWindowConfig } from './booking-window.config';
|
import { BookingWindowGateway } from '../booking-window.gateway';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingNotifierService } from '../booking-notifier.service';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingBatchService } from '../booking-batch.service';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
|
||||||
import {
|
import {
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
summarizeFleetWarnings,
|
summarizeFleetWarnings,
|
||||||
@@ -109,13 +109,13 @@ import {
|
|||||||
type BookingWagonShortage,
|
type BookingWagonShortage,
|
||||||
type DeferredBookingRow,
|
type DeferredBookingRow,
|
||||||
type FleetAvailabilityRow,
|
type FleetAvailabilityRow,
|
||||||
} from './fleet-plan.util';
|
} from '../utils/fleet-plan.util';
|
||||||
import {
|
import {
|
||||||
applyWagonOrderReversal,
|
applyWagonOrderReversal,
|
||||||
planWagonsWithStock,
|
planWagonsWithStock,
|
||||||
type AllowedWagonTypeMap,
|
type AllowedWagonTypeMap,
|
||||||
type WagonStock,
|
type WagonStock,
|
||||||
} from './wagon-plan-flex.util';
|
} from '../wagon-plan-flex.util';
|
||||||
import {
|
import {
|
||||||
containerWagonsForLines,
|
containerWagonsForLines,
|
||||||
expandBookingContainerUnits,
|
expandBookingContainerUnits,
|
||||||
@@ -129,10 +129,10 @@ import {
|
|||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from './wagon-plan.util';
|
} from '../utils/wagon-plan.util';
|
||||||
import { CorridorBudget } from './corridor-capacity.util';
|
import { CorridorBudget } from '../corridor-capacity.util';
|
||||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
|
||||||
import {
|
import {
|
||||||
bookingCargoTons,
|
bookingCargoTons,
|
||||||
bulkItemsFitFor,
|
bulkItemsFitFor,
|
||||||
@@ -144,7 +144,7 @@ import {
|
|||||||
wagonTypeDimensionsFromEntity,
|
wagonTypeDimensionsFromEntity,
|
||||||
LocomotiveLimits,
|
LocomotiveLimits,
|
||||||
WagonTypeDimensions,
|
WagonTypeDimensions,
|
||||||
} from './train-capacity.util';
|
} from '../train-capacity.util';
|
||||||
import {
|
import {
|
||||||
DEFAULT_BULK_WAGON_CAPACITY_TONS,
|
DEFAULT_BULK_WAGON_CAPACITY_TONS,
|
||||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||||
@@ -153,8 +153,8 @@ import {
|
|||||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||||
paymentDrainEndsAtIso,
|
paymentDrainEndsAtIso,
|
||||||
} from './booking-batch.constants';
|
} from '../booking-batch.constants';
|
||||||
import { orderConsistWagons } from './consist-order.util';
|
import { orderConsistWagons } from '../consist-order.util';
|
||||||
import {
|
import {
|
||||||
computeExportWindowTimes,
|
computeExportWindowTimes,
|
||||||
computeImportWindowTimes,
|
computeImportWindowTimes,
|
||||||
@@ -162,22 +162,22 @@ import {
|
|||||||
eatDay,
|
eatDay,
|
||||||
eatDayToUtc,
|
eatDayToUtc,
|
||||||
shiftEatDay,
|
shiftEatDay,
|
||||||
} from './batch-window.util';
|
} from '../batch-window.util';
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||||
import { BookingJourneyService } from './booking-journey.service';
|
import { BookingJourneyService } from '../booking-journey.service';
|
||||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
|
||||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
|
||||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
|
||||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
||||||
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
|
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
|
||||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
|
||||||
import {
|
import {
|
||||||
autoFillPlacements,
|
autoFillPlacements,
|
||||||
findMissingContainerNumberIssues,
|
findMissingContainerNumberIssues,
|
||||||
isPlaceholderContainerNumber,
|
isPlaceholderContainerNumber,
|
||||||
placementsForBookings,
|
placementsForBookings,
|
||||||
type ContainerUnitForPlacement,
|
type ContainerUnitForPlacement,
|
||||||
} from './container-placement.util';
|
} from '../container-placement.util';
|
||||||
|
|
||||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||||
|
|
||||||
@@ -5709,7 +5709,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveScheduleFreightType(
|
private resolveScheduleFreightType(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
||||||
const types = new Set(
|
const types = new Set(
|
||||||
(schedule.scheduleBookings ?? [])
|
(schedule.scheduleBookings ?? [])
|
||||||
@@ -5751,7 +5751,7 @@ export class TrainSchedulingService {
|
|||||||
throw new ConflictException('Could not allocate a unique schedule reference');
|
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 {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
@@ -6907,7 +6907,7 @@ export class TrainSchedulingService {
|
|||||||
originYardId?: string,
|
originYardId?: string,
|
||||||
destinationYardId?: string,
|
destinationYardId?: string,
|
||||||
): Promise<
|
): Promise<
|
||||||
import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
|
import('../../train-schedules/entities/train-schedule.entity').TrainSchedule[]
|
||||||
> {
|
> {
|
||||||
const schedules = await this.trainSchedulesRepository.findAll({
|
const schedules = await this.trainSchedulesRepository.findAll({
|
||||||
where: {
|
where: {
|
||||||
@@ -7497,7 +7497,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async mapScheduleDetail(
|
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(
|
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
|
||||||
(w) => w.allocations ?? [],
|
(w) => w.allocations ?? [],
|
||||||
@@ -24,9 +24,9 @@ import { WarehousesModule } from '../warehouses/warehouses.module';
|
|||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
|
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from './repositories/train-checkpoint-events.repository';
|
||||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
import { TrainSchedulingController } from './controllers/train-scheduling.controller';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './services/train-scheduling.service';
|
||||||
import { BookingBatchService } from './booking-batch.service';
|
import { BookingBatchService } from './booking-batch.service';
|
||||||
import { BookingNotifierService } from './booking-notifier.service';
|
import { BookingNotifierService } from './booking-notifier.service';
|
||||||
import { BookingWindowGateway } from './booking-window.gateway';
|
import { BookingWindowGateway } from './booking-window.gateway';
|
||||||
|
|||||||
@@ -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. */
|
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
|
||||||
export const deriveScheduleDirection = deriveTradeDirection;
|
export const deriveScheduleDirection = deriveTradeDirection;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import {
|
import {
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
selectBookingsWithinFleetCap,
|
selectBookingsWithinFleetCap,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
|
import { bookingCargoTons, bulkWagonsForAllowedTypes } from '../train-capacity.util';
|
||||||
import type { Booking } from '../bookings/entities/booking.entity';
|
import type { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import type { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import {
|
import {
|
||||||
buildBulkWagonPlan,
|
buildBulkWagonPlan,
|
||||||
buildContainerWagonPlan,
|
buildContainerWagonPlan,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AllocationLoadType } from '@edr/types';
|
import { AllocationLoadType } from '@edr/types';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import {
|
import {
|
||||||
buildBulkWagonPlan,
|
buildBulkWagonPlan,
|
||||||
buildContainerWagonPlan,
|
buildContainerWagonPlan,
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { AllocationLoadType } from '@edr/types';
|
import { AllocationLoadType } from '@edr/types';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../../rule-engine/container-type.util';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import {
|
import {
|
||||||
bookingCargoTons,
|
bookingCargoTons,
|
||||||
bulkItemsFitFor,
|
bulkItemsFitFor,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
bulkTonsPerWagon,
|
bulkTonsPerWagon,
|
||||||
bulkTonWagonsRequired,
|
bulkTonWagonsRequired,
|
||||||
consistViolations,
|
consistViolations,
|
||||||
} from './train-capacity.util';
|
} from '../train-capacity.util';
|
||||||
|
|
||||||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
|
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
applyWagonOrderReversal,
|
applyWagonOrderReversal,
|
||||||
planWagonsWithStock,
|
planWagonsWithStock,
|
||||||
} from './wagon-plan-flex.util';
|
} from './wagon-plan-flex.util';
|
||||||
import type { WagonPlanSlot } from './wagon-plan.util';
|
import type { WagonPlanSlot } from './utils/wagon-plan.util';
|
||||||
|
|
||||||
const nw6: WagonType = {
|
const nw6: WagonType = {
|
||||||
id: 'wt-nw6',
|
id: 'wt-nw6',
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
sortBookingsForScheduling,
|
sortBookingsForScheduling,
|
||||||
type BookingWagonShortage,
|
type BookingWagonShortage,
|
||||||
type DeferredBookingRow,
|
type DeferredBookingRow,
|
||||||
} from './fleet-plan.util';
|
} from './utils/fleet-plan.util';
|
||||||
import {
|
import {
|
||||||
MAX_TEU_SLOTS_PER_WAGON,
|
MAX_TEU_SLOTS_PER_WAGON,
|
||||||
containerWagonsForLines,
|
containerWagonsForLines,
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
teuSlotsForSizeFt,
|
teuSlotsForSizeFt,
|
||||||
type SlotLoadType,
|
type SlotLoadType,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
} from './wagon-plan.util';
|
} from './utils/wagon-plan.util';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wagon types allowed to carry each container type / bulk cargo type — the
|
* Wagon types allowed to carry each container type / bulk cargo type — the
|
||||||
|
|||||||
@@ -1425,6 +1425,9 @@ export class WarehouseInventoryService {
|
|||||||
WHERE b.deleted_at IS NULL
|
WHERE b.deleted_at IS NULL
|
||||||
AND b.payment_status = 'PAID'
|
AND b.payment_status = 'PAID'
|
||||||
AND inv.id IS NULL
|
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`,
|
ORDER BY b.scheduled_date DESC NULLS LAST`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -482,6 +482,13 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:invoices:eims_resolve",
|
"edr_freight_app:invoices:eims_resolve",
|
||||||
"Resolve a blocked MoR EIMS submission",
|
"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
|
// E. First / last mile operations
|
||||||
@@ -1640,6 +1647,7 @@ export const FREIGHT_PERMS = {
|
|||||||
export: "edr_freight_app:invoices:export",
|
export: "edr_freight_app:invoices:export",
|
||||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||||
|
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||||
},
|
},
|
||||||
firstMile: {
|
firstMile: {
|
||||||
view: "edr_freight_app:first_mile:view",
|
view: "edr_freight_app:first_mile:view",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
|||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||||
|
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||||
@@ -266,6 +267,14 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="usd-payments"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
|
||||||
|
<UsdPaymentsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="invoices/:id"
|
path="invoices/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
|
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
|
||||||
|
|
||||||
const fmt = (n: number) =>
|
const fmt = (n: number) =>
|
||||||
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
`${booking.paymentCurrency} ${n.toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||||
@@ -74,7 +77,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
|||||||
{li.description}
|
{li.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
<Text size="sm" fw={600} style={{ fontVariantNumeric: "tabular-nums" }}>
|
||||||
{Number(li.amount).toLocaleString()} {li.currency}
|
{Number(li.amount).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
})}{" "}
|
||||||
|
{li.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ import { SectionCard } from "./SectionCard";
|
|||||||
import { MetricTile } from "./MetricTile";
|
import { MetricTile } from "./MetricTile";
|
||||||
|
|
||||||
const money = (amount: number, currency: string) =>
|
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
|
* Cargo costs (booking-level totals) plus the same truck-import block the
|
||||||
|
|||||||
@@ -348,6 +348,9 @@ export default function GlCreateBookingForm() {
|
|||||||
// Intercity shipments ride a passing import/export train staff pick at
|
// Intercity shipments ride a passing import/export train staff pick at
|
||||||
// finalize time — no shipment day is chosen and no window gate applies.
|
// finalize time — no shipment day is chosen and no window gate applies.
|
||||||
const isIntercity = contract?.tradeDirection === "DOMESTIC";
|
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
|
// ONE_TIME split-remainder mode: a previous booking on this contract was
|
||||||
// split on train capacity, so the capacity endpoint reports the outstanding
|
// split on train capacity, so the capacity endpoint reports the outstanding
|
||||||
@@ -1757,12 +1760,15 @@ export default function GlCreateBookingForm() {
|
|||||||
Billing currency
|
Billing currency
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" mb={8}>
|
<Text size="xs" c="dimmed" mb={8}>
|
||||||
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."}
|
||||||
</Text>
|
</Text>
|
||||||
<CurrencySelector
|
<CurrencySelector
|
||||||
value={isIntercity ? "ETB" : paymentCurrency}
|
value={isIntercity ? "ETB" : paymentCurrency}
|
||||||
onChange={setPaymentCurrency}
|
onChange={setPaymentCurrency}
|
||||||
disabled={isIntercity}
|
disabled={isIntercity}
|
||||||
|
allowUsd={isImport}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Hammer,
|
Hammer,
|
||||||
History,
|
History,
|
||||||
|
Landmark,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -111,6 +112,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
icon: <Receipt />,
|
icon: <Receipt />,
|
||||||
permission: FREIGHT_PERMS.invoices.view,
|
permission: FREIGHT_PERMS.invoices.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "USD Payments",
|
||||||
|
href: "/dashboard/usd-payments",
|
||||||
|
icon: <Landmark />,
|
||||||
|
permission: FREIGHT_PERMS.invoices.view,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Support",
|
label: "Support",
|
||||||
href: "/dashboard/support",
|
href: "/dashboard/support",
|
||||||
|
|||||||
@@ -561,7 +561,7 @@ const RuleEngineFormDialog = ({
|
|||||||
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
|
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
|
||||||
// so let the field carry decimals and let a 400 catch the rest.
|
// so let the field carry decimals and let a 400 catch the rest.
|
||||||
step={isNumber ? "any" : undefined}
|
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]) ?? "")}
|
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const next = e.currentTarget.value;
|
const next = e.currentTarget.value;
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const QUERY_KEYS = {
|
|||||||
list: (filter?: InvoiceListFilter) =>
|
list: (filter?: InvoiceListFilter) =>
|
||||||
["invoices", "list", filter ?? {}] as const,
|
["invoices", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["invoices", "detail", id] 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,
|
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export const URL_CONSTANTS = {
|
|||||||
INVOICES: "/billing/invoices",
|
INVOICES: "/billing/invoices",
|
||||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||||
|
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.
|
// 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`,
|
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
||||||
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
||||||
`/bookings/${id}/carriage-acceptance-sheet`,
|
`/bookings/${id}/carriage-acceptance-sheet`,
|
||||||
|
EXPORT_HANDOVER_MODE: (id: string) =>
|
||||||
|
`/bookings/${id}/export-handover-mode`,
|
||||||
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
||||||
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
||||||
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const FREIGHT_PERMS = {
|
|||||||
invoices: {
|
invoices: {
|
||||||
view: "edr_freight_app:invoices:view",
|
view: "edr_freight_app:invoices:view",
|
||||||
export: "edr_freight_app:invoices:export",
|
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
|
// 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.
|
// irreversible at the tax authority, and resolving clears a system-wide filing block.
|
||||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Button,
|
Button,
|
||||||
Box,
|
Box,
|
||||||
|
SegmentedControl,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
|
||||||
import { PageContainer } from "@/components/page";
|
import { PageContainer } from "@/components/page";
|
||||||
@@ -258,6 +259,44 @@ export default function BookingRequestDetailPage() {
|
|||||||
booking={booking}
|
booking={booking}
|
||||||
mutations={mutations}
|
mutations={mutations}
|
||||||
/>
|
/>
|
||||||
|
{booking.tradeDirection === "EXPORT" && (
|
||||||
|
<Paper withBorder radius="md" p="sm">
|
||||||
|
<Stack gap={6}>
|
||||||
|
<Text size="sm" fw={600}>
|
||||||
|
How the cargo reaches the train
|
||||||
|
</Text>
|
||||||
|
<SegmentedControl
|
||||||
|
fullWidth
|
||||||
|
size="xs"
|
||||||
|
value={booking.exportHandoverMode ?? "WAREHOUSE"}
|
||||||
|
data={[
|
||||||
|
{ value: "WAREHOUSE", label: "Warehouse then train" },
|
||||||
|
{ value: "DIRECT_TO_TRAIN", label: "Direct truck to train" },
|
||||||
|
]}
|
||||||
|
onChange={async (value) => {
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{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."}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
)}
|
||||||
{booking.isGovernment && booking.contractSummary && (
|
{booking.isGovernment && booking.contractSummary && (
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Modal,
|
||||||
|
SegmentedControl,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { CheckCircle2, ExternalLink, RefreshCw, Search, X } from "lucide-react";
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import {
|
||||||
|
InvoiceStatusBadge,
|
||||||
|
formatMoney,
|
||||||
|
humanize,
|
||||||
|
} from "@/components/customers";
|
||||||
|
import { PhasedFileDropzone } from "@/components/contracts/PhasedFileDropzone";
|
||||||
|
import { PageContainer, PageHeader } from "@/components/page";
|
||||||
|
import { useAuth } from "@/auth/useAuth";
|
||||||
|
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
import type { OfflineUsdInvoice } from "@/types/invoice";
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableFooter,
|
||||||
|
usePagination,
|
||||||
|
type ColumnDef,
|
||||||
|
} from "@edr/ui-common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The customer's pay window, counted down live. Finance must confirm the bank
|
||||||
|
* transfer before it closes — past the deadline the booking expires like any
|
||||||
|
* unpaid one and the API refuses the confirmation.
|
||||||
|
*/
|
||||||
|
function formatRemaining(deadlineMs: number, now: number): string | null {
|
||||||
|
const diff = deadlineMs - now;
|
||||||
|
if (diff <= 0) return null;
|
||||||
|
const total = Math.floor(diff / 1000);
|
||||||
|
const days = Math.floor(total / 86400);
|
||||||
|
const hours = Math.floor((total % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((total % 3600) / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return days > 0
|
||||||
|
? `${days}d ${pad(hours)}:${pad(minutes)}:${pad(seconds)}`
|
||||||
|
: `${pad(hours)}:${pad(minutes)}:${pad(seconds)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PayWindowCell({ deadline }: { deadline: string | null }) {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!deadline) return;
|
||||||
|
const interval = setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [deadline]);
|
||||||
|
|
||||||
|
if (!deadline) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = formatRemaining(new Date(deadline).getTime(), now);
|
||||||
|
if (!remaining) {
|
||||||
|
return (
|
||||||
|
<Badge color="red" variant="light" radius="sm">
|
||||||
|
Window closed
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text size="sm" fw={600} c="edr-text" ff="monospace">
|
||||||
|
{remaining}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True once the pay window has closed — the API refuses confirmation then. */
|
||||||
|
function windowClosed(row: OfflineUsdInvoice): boolean {
|
||||||
|
const deadline = row.booking?.paymentDeadline;
|
||||||
|
return Boolean(deadline && new Date(deadline).getTime() <= Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UsdPaymentsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { pagination, setPagination } = usePagination({ pageSize: 10 });
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [debouncedQuery] = useDebouncedValue(query, 300);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<"" | Freight.InvoiceStatus>(
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
const [confirming, setConfirming] = useState<OfflineUsdInvoice | null>(null);
|
||||||
|
const [slip, setSlip] = useState<File | null>(null);
|
||||||
|
const [reference, setReference] = useState("");
|
||||||
|
|
||||||
|
const { user } = useAuth();
|
||||||
|
const canConfirm = hasPermission(
|
||||||
|
user,
|
||||||
|
FREIGHT_PERMS.invoices.confirmOffline,
|
||||||
|
);
|
||||||
|
|
||||||
|
const filter = useMemo(
|
||||||
|
() => ({
|
||||||
|
page: pagination.pageIndex + 1,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
search: debouncedQuery,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
}),
|
||||||
|
[pagination.pageIndex, pagination.pageSize, debouncedQuery, statusFilter],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch, isFetching } = useQuery(
|
||||||
|
api.invoices.listOfflineUsd.queryOptions({ input: { filter } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const confirm = useMutation(api.invoices.confirmOffline.mutationOptions());
|
||||||
|
|
||||||
|
const rows = data?.items ?? [];
|
||||||
|
const total = data?.total ?? 0;
|
||||||
|
const pageCount = Math.max(1, Math.ceil(total / pagination.pageSize));
|
||||||
|
|
||||||
|
const closeConfirm = () => {
|
||||||
|
setConfirming(null);
|
||||||
|
setSlip(null);
|
||||||
|
setReference("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitConfirm = async () => {
|
||||||
|
if (!confirming || !slip) return;
|
||||||
|
try {
|
||||||
|
await confirm.mutateAsync({
|
||||||
|
id: confirming.id,
|
||||||
|
file: slip,
|
||||||
|
reference: reference.trim() || undefined,
|
||||||
|
});
|
||||||
|
toast.success(`${confirming.invoiceNumber} confirmed as paid`);
|
||||||
|
closeConfirm();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : "Confirmation failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<OfflineUsdInvoice>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: "invoiceNumber",
|
||||||
|
header: "Invoice",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{row.original.invoiceNumber}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "billedTo",
|
||||||
|
header: "Customer",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="edr-text">
|
||||||
|
{row.original.company?.name ?? "—"}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "booking",
|
||||||
|
header: "Booking",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const booking = row.original.booking;
|
||||||
|
if (!booking) {
|
||||||
|
return (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{humanize(row.original.source)}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="compact-sm"
|
||||||
|
rightSection={<ExternalLink size={13} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/dashboard/booking-requests/${booking.id}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{booking.reference}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "status",
|
||||||
|
header: "Status",
|
||||||
|
cell: ({ row }) => <InvoiceStatusBadge status={row.original.status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "amount",
|
||||||
|
header: "Amount",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" fw={600} c="edr-text">
|
||||||
|
{formatMoney(row.original.totalAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "balance",
|
||||||
|
header: "Balance",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatMoney(row.original.balanceAmount, row.original.currency)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "payWindow",
|
||||||
|
header: "Pay window",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<PayWindowCell deadline={row.original.booking?.paymentDeadline ?? null} />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "action",
|
||||||
|
header: "",
|
||||||
|
meta: { headerClassName: "text-right", cellClassName: "text-right" },
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const paid = row.original.status === "PAID";
|
||||||
|
if (paid || !canConfirm) return null;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size="compact-sm"
|
||||||
|
color="edr-green"
|
||||||
|
leftSection={<CheckCircle2 size={14} />}
|
||||||
|
disabled={windowClosed(row.original)}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setConfirming(row.original);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Confirm paid
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[canConfirm, navigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<PageHeader
|
||||||
|
title="USD Payments"
|
||||||
|
subtitle="USD invoices are paid by bank transfer. Upload the customer's slip and confirm the payment before the pay window closes."
|
||||||
|
action={
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
aria-label="Refresh"
|
||||||
|
loading={isFetching}
|
||||||
|
onClick={() => void refetch()}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card p={0}>
|
||||||
|
<Stack gap={0}>
|
||||||
|
<Box px="md" pt="md" pb="sm" w="100%">
|
||||||
|
<Group justify="space-between" gap="md" wrap="wrap">
|
||||||
|
<TextInput
|
||||||
|
placeholder="Search by invoice number…"
|
||||||
|
leftSection={<Search size={18} />}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
rightSection={
|
||||||
|
query ? (
|
||||||
|
<ActionIcon
|
||||||
|
size="sm"
|
||||||
|
color="gray"
|
||||||
|
radius="md"
|
||||||
|
variant="transparent"
|
||||||
|
onClick={() => setQuery("")}
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
style={{ flex: 1, minWidth: "240px" }}
|
||||||
|
radius="lg"
|
||||||
|
/>
|
||||||
|
<SegmentedControl
|
||||||
|
size="sm"
|
||||||
|
radius="md"
|
||||||
|
value={statusFilter || "open"}
|
||||||
|
onChange={(v) => {
|
||||||
|
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" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{total} record{total !== 1 ? "s" : ""}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box style={{ overflowX: "auto" }} w="100%">
|
||||||
|
<Box miw={1040}>
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={rows}
|
||||||
|
status={isLoading ? "loading" : isError ? "error" : "success"}
|
||||||
|
onRowClick={(row) => 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}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={confirming !== null}
|
||||||
|
onClose={closeConfirm}
|
||||||
|
title={
|
||||||
|
<Text fw={700}>Confirm bank transfer payment</Text>
|
||||||
|
}
|
||||||
|
radius="md"
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
{confirming && (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
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.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<PhasedFileDropzone
|
||||||
|
label="Bank payment slip"
|
||||||
|
description="PDF or image of the customer's transfer slip."
|
||||||
|
value={slip}
|
||||||
|
onChange={setSlip}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
label="Bank reference"
|
||||||
|
description="Optional — the transfer reference from the slip."
|
||||||
|
placeholder="e.g. FT24091234567"
|
||||||
|
value={reference}
|
||||||
|
onChange={(e) => setReference(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" gap="sm">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
onClick={closeConfirm}
|
||||||
|
disabled={confirm.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="edr-green"
|
||||||
|
loading={confirm.isPending}
|
||||||
|
disabled={!slip}
|
||||||
|
leftSection={<CheckCircle2 size={16} />}
|
||||||
|
onClick={() => void submitConfirm()}
|
||||||
|
>
|
||||||
|
Confirm as paid
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ export interface FormFieldDef {
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
disabled?: boolean;
|
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). */
|
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
|
||||||
suffix?: string;
|
suffix?: string;
|
||||||
/** Hide this field when another field currently equals one of these values. */
|
/** Hide this field when another field currently equals one of these values. */
|
||||||
@@ -389,7 +391,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
|||||||
],
|
],
|
||||||
formFields: [
|
formFields: [
|
||||||
{ name: "label", label: "Label", type: "text", required: true },
|
{ 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).
|
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
|
||||||
{
|
{
|
||||||
name: "wagonTypeIds",
|
name: "wagonTypeIds",
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import type {
|
|||||||
Invoice,
|
Invoice,
|
||||||
InvoiceListFilter,
|
InvoiceListFilter,
|
||||||
PaginatedInvoices,
|
PaginatedInvoices,
|
||||||
|
PaginatedOfflineUsdInvoices,
|
||||||
} from "@/types/invoice";
|
} from "@/types/invoice";
|
||||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||||
import {
|
import {
|
||||||
@@ -2917,6 +2918,29 @@ export const api = {
|
|||||||
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
({ 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>(
|
eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>(
|
||||||
"invoices",
|
"invoices",
|
||||||
"eimsStatus",
|
"eimsStatus",
|
||||||
|
|||||||
@@ -458,6 +458,13 @@ export const bookingsService = {
|
|||||||
return (unwrap(response.data) ?? []) as BookingDetail[];
|
return (unwrap(response.data) ?? []) as BookingDetail[];
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setExportHandoverMode: async (
|
||||||
|
id: string,
|
||||||
|
exportHandoverMode: "DIRECT_TO_TRAIN" | "WAREHOUSE",
|
||||||
|
): Promise<void> => {
|
||||||
|
await client.patch(B.EXPORT_HANDOVER_MODE(id), { exportHandoverMode });
|
||||||
|
},
|
||||||
|
|
||||||
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
|
downloadCarriageAcceptanceSheet: async (id: string): Promise<Blob> => {
|
||||||
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
Invoice,
|
Invoice,
|
||||||
InvoiceListFilter,
|
InvoiceListFilter,
|
||||||
PaginatedInvoices,
|
PaginatedInvoices,
|
||||||
|
PaginatedOfflineUsdInvoices,
|
||||||
} from "@/types/invoice";
|
} from "@/types/invoice";
|
||||||
|
|
||||||
const cleanParams = (params: object) =>
|
const cleanParams = (params: object) =>
|
||||||
@@ -33,4 +34,25 @@ export const invoicesService = {
|
|||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
||||||
|
listOfflineUsd(
|
||||||
|
filter: InvoiceListFilter,
|
||||||
|
): Promise<PaginatedOfflineUsdInvoices> {
|
||||||
|
return apiClient
|
||||||
|
.get<PaginatedOfflineUsdInvoices>(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<Invoice> {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
if (reference) body.append("reference", reference);
|
||||||
|
return apiClient
|
||||||
|
.post<Invoice>(URL_CONSTANTS.BILLING.CONFIRM_OFFLINE(id), body)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -168,6 +168,11 @@ export interface BookingDetail {
|
|||||||
contractType: string;
|
contractType: string;
|
||||||
freightType: "CONTAINER" | "BULK";
|
freightType: "CONTAINER" | "BULK";
|
||||||
tradeDirection: string;
|
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. */
|
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||||
cargoFreeText?: string | null;
|
cargoFreeText?: string | null;
|
||||||
cargoTotalWeightVgm: number;
|
cargoTotalWeightVgm: number;
|
||||||
|
|||||||
@@ -20,3 +20,22 @@ export interface PaginatedInvoices {
|
|||||||
items: Invoice[];
|
items: Invoice[];
|
||||||
total: number;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Freight } from "@edr/types";
|
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. */
|
/** A pending customer action surfaced on the home "needs attention" card. */
|
||||||
export interface ActionItem {
|
export interface ActionItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,6 +15,8 @@ export interface ActionItem {
|
|||||||
targetId: string;
|
targetId: string;
|
||||||
/** Highlighted as action-required in the home card. */
|
/** Highlighted as action-required in the home card. */
|
||||||
urgent?: boolean;
|
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 === "FULLY_EXECUTED"
|
||||||
: b.status === "SELECTED_FOR_BATCH");
|
: b.status === "SELECTED_FOR_BATCH");
|
||||||
if (canPay) {
|
if (canPay) {
|
||||||
|
const offlinePay = isUsdOfflineBooking(b);
|
||||||
items.push({
|
items.push({
|
||||||
id: `pay-${b.id}`,
|
id: `pay-${b.id}`,
|
||||||
kind: "pay",
|
kind: "pay",
|
||||||
reference: b.reference,
|
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,
|
targetId: b.id,
|
||||||
urgent: true,
|
urgent: true,
|
||||||
|
offlinePay,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
const handleClick = (item: ActionItem) => {
|
const handleClick = (item: ActionItem) => {
|
||||||
switch (item.kind) {
|
switch (item.kind) {
|
||||||
case "pay":
|
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;
|
break;
|
||||||
case "sign":
|
case "sign":
|
||||||
navigate(`/contracts/${item.targetId}/view`);
|
navigate(`/contracts/${item.targetId}/view`);
|
||||||
@@ -151,7 +154,9 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
|||||||
leftSection={<FilePlus2 size={14} />}
|
leftSection={<FilePlus2 size={14} />}
|
||||||
>
|
>
|
||||||
{item.kind === "pay"
|
{item.kind === "pay"
|
||||||
? "Pay now"
|
? item.offlinePay
|
||||||
|
? "Pay by bank"
|
||||||
|
: "Pay now"
|
||||||
: item.kind === "sign"
|
: item.kind === "sign"
|
||||||
? "Sign"
|
? "Sign"
|
||||||
: "Book"}
|
: "Book"}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Center,
|
Center,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
CreditCard,
|
CreditCard,
|
||||||
Download,
|
Download,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
Landmark,
|
||||||
Receipt,
|
Receipt,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
@@ -30,6 +32,7 @@ import { invoicesService } from "@/services/invoices.service";
|
|||||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||||
|
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
import { formatCurrency } from "@/lib/currency";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||||
@@ -224,7 +227,7 @@ export default function InvoiceDetailPage() {
|
|||||||
Receipt
|
Receipt
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{payable && (
|
{payable && !isUsdCurrency(invoice.currency) && (
|
||||||
<Button
|
<Button
|
||||||
color="edr-green"
|
color="edr-green"
|
||||||
radius="md"
|
radius="md"
|
||||||
@@ -239,6 +242,18 @@ export default function InvoiceDetailPage() {
|
|||||||
Pay {formatCurrency(amountDue, invoice.currency)}
|
Pay {formatCurrency(amountDue, invoice.currency)}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
{payable && isUsdCurrency(invoice.currency) && (
|
||||||
|
<Badge
|
||||||
|
size="lg"
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
leftSection={<Landmark size={12} />}
|
||||||
|
styles={{ label: { textTransform: "none", fontWeight: 700 } }}
|
||||||
|
>
|
||||||
|
Pay by bank transfer — send the slip to Finance
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { Freight } from "@edr/types";
|
|||||||
|
|
||||||
import { api } from "@/services/api";
|
import { api } from "@/services/api";
|
||||||
import { formatCurrency } from "@/lib/currency";
|
import { formatCurrency } from "@/lib/currency";
|
||||||
|
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
|
||||||
import {
|
import {
|
||||||
BORDER,
|
BORDER,
|
||||||
GREEN,
|
GREEN,
|
||||||
@@ -276,7 +277,10 @@ export default function InvoicesList() {
|
|||||||
{!isLoading &&
|
{!isLoading &&
|
||||||
!isError &&
|
!isError &&
|
||||||
pageRows.map((inv) => {
|
pageRows.map((inv) => {
|
||||||
const payable = isPayable(inv.status);
|
// USD invoices are paid by bank transfer — the detail page
|
||||||
|
// shows the instructions, so the row action reads "View".
|
||||||
|
const payable =
|
||||||
|
isPayable(inv.status) && !isUsdCurrency(inv.currency);
|
||||||
return (
|
return (
|
||||||
<Table.Tr
|
<Table.Tr
|
||||||
key={inv.id}
|
key={inv.id}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { StatusHero } from "./components/StatusHero";
|
|||||||
import { StepGhostButton, StepLine } from "./components/Steps";
|
import { StepGhostButton, StepLine } from "./components/Steps";
|
||||||
import { SupportCard } from "./components/SupportCard";
|
import { SupportCard } from "./components/SupportCard";
|
||||||
import { BodyGrid } from "./components/layout";
|
import { BodyGrid } from "./components/layout";
|
||||||
|
import { formatAmount } from "./utils";
|
||||||
|
|
||||||
export function DraftBookingView({
|
export function DraftBookingView({
|
||||||
booking,
|
booking,
|
||||||
@@ -439,7 +440,7 @@ export function DraftBookingView({
|
|||||||
Previous total
|
Previous total
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" td="line-through">
|
<Text size="sm" td="line-through">
|
||||||
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
|
{formatAmount(priceChangeModal.previousTotalAmount)}{" "}
|
||||||
{priceChangeModal.currency}
|
{priceChangeModal.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -447,7 +448,7 @@ export function DraftBookingView({
|
|||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text fw={700}>New total</Text>
|
<Text fw={700}>New total</Text>
|
||||||
<Text fw={800} c="edr-green">
|
<Text fw={800} c="edr-green">
|
||||||
{priceChangeModal.totalAmount.toLocaleString()}{" "}
|
{formatAmount(priceChangeModal.totalAmount)}{" "}
|
||||||
{priceChangeModal.currency}
|
{priceChangeModal.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -459,7 +460,7 @@ export function DraftBookingView({
|
|||||||
{item.description}
|
{item.description}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{item.amount.toLocaleString()} {item.currency}
|
{formatAmount(item.amount)} {item.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import { WagonsTab } from "./components/WagonsTab";
|
|||||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||||
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
import { useBookingPayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||||
|
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||||
|
|
||||||
// Pre-payment statuses the customer may self-cancel from this view (free of
|
// Pre-payment statuses the customer may self-cancel from this view (free of
|
||||||
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
|
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
|
||||||
@@ -188,7 +189,7 @@ export function ReadonlyBookingView({
|
|||||||
{canApproveDelivery && (
|
{canApproveDelivery && (
|
||||||
<ApproveDeliveryButton bookingId={booking.id} />
|
<ApproveDeliveryButton bookingId={booking.id} />
|
||||||
)}
|
)}
|
||||||
{canPay && !showCountdown && (
|
{canPay && !showCountdown && !isUsdOfflineBooking(booking) && (
|
||||||
<HeaderButton
|
<HeaderButton
|
||||||
green
|
green
|
||||||
icon={<CreditCard size={16} />}
|
icon={<CreditCard size={16} />}
|
||||||
|
|||||||
@@ -17,9 +17,16 @@ import type { Freight } from "@edr/types";
|
|||||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||||
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||||
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
|
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
|
||||||
|
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
|
|
||||||
import { fmtDate, priceLineItems, priceTotal, type Pricing } from "../utils";
|
import {
|
||||||
|
fmtDate,
|
||||||
|
formatAmount,
|
||||||
|
priceLineItems,
|
||||||
|
priceTotal,
|
||||||
|
type Pricing,
|
||||||
|
} from "../utils";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
|
|
||||||
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
|
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
|
||||||
@@ -190,12 +197,13 @@ export function BookingPaymentPanel({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const paid = booking.paymentStatus === "PAID";
|
const paid = booking.paymentStatus === "PAID";
|
||||||
|
const offlineUsd = isUsdOfflineBooking(booking);
|
||||||
const isAdjusted =
|
const isAdjusted =
|
||||||
booking.adjustedTotalAmount !== null &&
|
booking.adjustedTotalAmount !== null &&
|
||||||
booking.adjustedTotalAmount !== undefined;
|
booking.adjustedTotalAmount !== undefined;
|
||||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||||
const total = isAdjusted
|
const total = isAdjusted
|
||||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
? `${formatAmount(booking.adjustedTotalAmount)} ${currency}`
|
||||||
: priceTotal(pricing);
|
: priceTotal(pricing);
|
||||||
const items = priceLineItems(pricing);
|
const items = priceLineItems(pricing);
|
||||||
|
|
||||||
@@ -268,11 +276,36 @@ export function BookingPaymentPanel({
|
|||||||
<PartialOfferNotice offer={booking.activeBatchOffer} />
|
<PartialOfferNotice offer={booking.activeBatchOffer} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* USD: no online payment — bank transfer + slip to Finance, who confirm
|
||||||
|
the payment (backoffice flow lands in a later phase). Shown for any
|
||||||
|
unpaid USD booking, with or without an open pay window. */}
|
||||||
|
{!paid && offlineUsd && (
|
||||||
|
<Box
|
||||||
|
mt={14}
|
||||||
|
p={14}
|
||||||
|
style={{
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: "#FEF6E6",
|
||||||
|
border: "1px solid #F3E2B8",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text fz="13px" fw={800} c="#9A5B00">
|
||||||
|
Pay by bank transfer
|
||||||
|
</Text>
|
||||||
|
<Text mt={4} fz="12.5px" c="#7A5A1E" lh={1.55}>
|
||||||
|
Online payment isn't available for USD bookings. Transfer the
|
||||||
|
total amount to EDR's bank account before the payment deadline,
|
||||||
|
then send the payment slip to the EDR Finance department — they
|
||||||
|
will confirm your payment.
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
{showCountdown && booking.paymentDeadline && (
|
{showCountdown && booking.paymentDeadline && (
|
||||||
<Box mt={16}>
|
<Box mt={16}>
|
||||||
<Countdown
|
<Countdown
|
||||||
deadline={booking.paymentDeadline}
|
deadline={booking.paymentDeadline}
|
||||||
onPay={onPay}
|
onPay={offlineUsd ? undefined : onPay}
|
||||||
paying={paying}
|
paying={paying}
|
||||||
/>
|
/>
|
||||||
{showConsolidationNote && (
|
{showConsolidationNote && (
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ interface ProviderOption {
|
|||||||
// ponytail: ETB pays via CBE bill only for now — restore the Telebirr entry
|
// ponytail: ETB pays via CBE bill only for now — restore the Telebirr entry
|
||||||
// ({ method: "TELEBIRR", currencies: ["ETB"] }) when mobile money returns.
|
// ({ method: "TELEBIRR", currencies: ["ETB"] }) when mobile money returns.
|
||||||
const PROVIDERS: ProviderOption[] = [
|
const PROVIDERS: ProviderOption[] = [
|
||||||
|
// {
|
||||||
// {
|
// {
|
||||||
// method: "TELEBIRR",
|
// method: "TELEBIRR",
|
||||||
// label: "telebirr",
|
// label: "telebirr",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { OperationDatePicker } from "@/pages/bookings/clearance";
|
|||||||
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
|
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||||
|
|
||||||
import type { BookingDetail } from "../booking-detail-types";
|
import type { BookingDetail } from "../booking-detail-types";
|
||||||
import { fmtDate } from "../utils";
|
import { fmtDate, formatAmount } from "../utils";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ function StatusPill({ status }: { status: WagonCancellation["status"] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fmtMoney = (amount: number | string, currency: string) =>
|
const fmtMoney = (amount: number | string, currency: string) =>
|
||||||
`${Number(amount).toLocaleString()} ${currency}`;
|
`${formatAmount(amount)} ${currency}`;
|
||||||
|
|
||||||
const apiErrorMessage = (error: unknown, fallback: string) => {
|
const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||||
const data = (
|
const data = (
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type PortalWarehouseInvoice,
|
type PortalWarehouseInvoice,
|
||||||
} from "@/services/warehouse-invoices.service";
|
} from "@/services/warehouse-invoices.service";
|
||||||
import { saveBlob } from "@/utils/download";
|
import { saveBlob } from "@/utils/download";
|
||||||
|
import { formatAmount } from "../utils";
|
||||||
|
|
||||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||||
import { CardTitle, SectionCard } from "./layout";
|
import { CardTitle, SectionCard } from "./layout";
|
||||||
@@ -20,7 +21,7 @@ const isPayable = (inv: PortalWarehouseInvoice) =>
|
|||||||
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
|
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
|
||||||
|
|
||||||
const money = (amount: number | string | null | undefined, currency: string) =>
|
const money = (amount: number | string | null | undefined, currency: string) =>
|
||||||
`${Number(amount ?? 0).toLocaleString()} ${currency}`;
|
`${formatAmount(amount)} ${currency}`;
|
||||||
|
|
||||||
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
|
const STATUS_STYLE: Record<string, { bg: string; fg: string }> = {
|
||||||
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
|
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
|
||||||
|
|||||||
@@ -151,15 +151,27 @@ export function bookingSubtitle(b: BookingDetail) {
|
|||||||
|
|
||||||
export type Pricing = Freight.PricingBreakdown | null | undefined;
|
export type Pricing = Freight.PricingBreakdown | null | undefined;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money always prints its cents. Bare toLocaleString() defaults to
|
||||||
|
* maximumFractionDigits: 0, which silently hid the cents the customer is
|
||||||
|
* actually charged — CBE bills the exact amount, so the shown figure must match.
|
||||||
|
*/
|
||||||
|
export function formatAmount(amount: number | string | null | undefined) {
|
||||||
|
return Number(amount ?? 0).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function priceLineItems(pricing: Pricing) {
|
export function priceLineItems(pricing: Pricing) {
|
||||||
return (pricing?.lineItems ?? []).map((li) => ({
|
return (pricing?.lineItems ?? []).map((li) => ({
|
||||||
label: li.description,
|
label: li.description,
|
||||||
value: `${li.amount.toLocaleString()} ${li.currency}`,
|
value: `${formatAmount(li.amount)} ${li.currency}`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function priceTotal(pricing: Pricing) {
|
export function priceTotal(pricing: Pricing) {
|
||||||
if (!pricing) return "—";
|
if (!pricing) return "—";
|
||||||
const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0);
|
const total = pricing.lineItems.reduce((s, li) => s + li.amount, 0);
|
||||||
return `${total.toLocaleString()} ${pricing.currency}`;
|
return `${formatAmount(total)} ${pricing.currency}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -683,7 +683,11 @@ export default function EditBookingPage() {
|
|||||||
/>
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<PaymentCurrencyField control={form.control} />
|
{/* USD billing is import-only; export/intercity stay ETB. */}
|
||||||
|
<PaymentCurrencyField
|
||||||
|
control={form.control}
|
||||||
|
allowUsd={operationType === "import"}
|
||||||
|
/>
|
||||||
|
|
||||||
{(selectedService?.includesFirstMile ||
|
{(selectedService?.includesFirstMile ||
|
||||||
selectedService?.includesLastMile ||
|
selectedService?.includesLastMile ||
|
||||||
|
|||||||
@@ -19,14 +19,25 @@ const CURRENCY_ICONS: Record<
|
|||||||
|
|
||||||
export function PaymentCurrencyField({
|
export function PaymentCurrencyField({
|
||||||
control,
|
control,
|
||||||
|
allowUsd = false,
|
||||||
}: {
|
}: {
|
||||||
control: Control<BookingFormInputValues, any, BookingFormValues>;
|
control: Control<BookingFormInputValues, any, BookingFormValues>;
|
||||||
|
/**
|
||||||
|
* Offer USD alongside ETB. Import shipments only — export and domestic
|
||||||
|
* traffic is always invoiced in ETB.
|
||||||
|
*/
|
||||||
|
allowUsd?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const options = allowUsd
|
||||||
|
? PAYMENT_CURRENCY_OPTIONS
|
||||||
|
: PAYMENT_CURRENCY_OPTIONS.filter((o) => o.value !== "USD");
|
||||||
return (
|
return (
|
||||||
<Box mt={24}>
|
<Box mt={24}>
|
||||||
<StepLabel>Payment currency</StepLabel>
|
<StepLabel>Payment currency</StepLabel>
|
||||||
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
<Text fz={12} c="#6B7C8E" mt={4} mb={12}>
|
||||||
Choose the currency for your freight quote and invoices.
|
{allowUsd
|
||||||
|
? "Choose the currency for your freight quote and invoices. USD is paid by bank transfer, not online."
|
||||||
|
: "Choose the currency for your freight quote and invoices."}
|
||||||
</Text>
|
</Text>
|
||||||
<Controller
|
<Controller
|
||||||
name="paymentCurrency"
|
name="paymentCurrency"
|
||||||
@@ -44,7 +55,7 @@ export function PaymentCurrencyField({
|
|||||||
border: "1px solid #E6ECF2",
|
border: "1px solid #E6ECF2",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
|
{options.map((option) => {
|
||||||
const Icon = CURRENCY_ICONS[option.value].icon;
|
const Icon = CURRENCY_ICONS[option.value].icon;
|
||||||
const selected = field.value === option.value;
|
const selected = field.value === option.value;
|
||||||
return (
|
return (
|
||||||
@@ -97,10 +108,7 @@ export function PaymentCurrencyField({
|
|||||||
</Group>
|
</Group>
|
||||||
{/* Description for the active currency, kept subtle. */}
|
{/* Description for the active currency, kept subtle. */}
|
||||||
<Text fz={11.5} c="#6B7C8E" mt={8}>
|
<Text fz={11.5} c="#6B7C8E" mt={8}>
|
||||||
{
|
{options.find((o) => o.value === field.value)?.description}
|
||||||
PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value)
|
|
||||||
?.description
|
|
||||||
}
|
|
||||||
</Text>
|
</Text>
|
||||||
<OptionFieldError error={fieldState.error} />
|
<OptionFieldError error={fieldState.error} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -81,11 +81,16 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
|||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
}> = [
|
}> = [
|
||||||
// ponytail: ETB-only for now — re-add the USD option when multi-currency billing returns.
|
|
||||||
{
|
{
|
||||||
value: "ETB",
|
value: "ETB",
|
||||||
label: "ETB",
|
label: "ETB",
|
||||||
description: "Ethiopian Birr — local pricing and invoicing.",
|
description: "Ethiopian Birr — pay online through the payment gateway.",
|
||||||
|
},
|
||||||
|
// Import shipments only; the field is hidden on export/domestic traffic.
|
||||||
|
{
|
||||||
|
value: "USD",
|
||||||
|
label: "USD",
|
||||||
|
description: "US Dollar — paid by bank transfer, slip sent to Finance.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ export function Step2ServiceType({
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PaymentCurrencyField control={form.control} />
|
{/* USD billing is import-only; export/intercity stay ETB. */}
|
||||||
|
<PaymentCurrencyField
|
||||||
|
control={form.control}
|
||||||
|
allowUsd={operationType === "import"}
|
||||||
|
/>
|
||||||
|
|
||||||
{showServiceSections && (
|
{showServiceSections && (
|
||||||
<Stack gap={12} mt={24}>
|
<Stack gap={12} mt={24}>
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { Button, type ButtonProps } from "@mantine/core";
|
import { Badge, Button, type ButtonProps } from "@mantine/core";
|
||||||
import { CreditCard } from "lucide-react";
|
import { CreditCard, Landmark } from "lucide-react";
|
||||||
|
|
||||||
import { Freight } from "@edr/types";
|
import { Freight } from "@edr/types";
|
||||||
|
|
||||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||||
import { priceTotal } from "../BookingDetailPage/utils";
|
import { priceTotal } from "../BookingDetailPage/utils";
|
||||||
|
import { isUsdOfflineBooking } from "./offline-payment";
|
||||||
import { useBookingPayment } from "./useBookingPayment";
|
import { useBookingPayment } from "./useBookingPayment";
|
||||||
|
|
||||||
interface PayNowButtonProps {
|
interface PayNowButtonProps {
|
||||||
@@ -29,6 +30,23 @@ export function PayNowButton({
|
|||||||
const pay = useBookingPayment(booking.id);
|
const pay = useBookingPayment(booking.id);
|
||||||
const pricing = booking.pricingBreakdown;
|
const pricing = booking.pricingBreakdown;
|
||||||
|
|
||||||
|
// USD is paid by bank transfer and confirmed by Finance — no online payment.
|
||||||
|
if (isUsdOfflineBooking(booking)) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
size={size === "xs" ? "md" : "lg"}
|
||||||
|
radius="md"
|
||||||
|
variant="light"
|
||||||
|
color="yellow"
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
leftSection={<Landmark size={12} />}
|
||||||
|
styles={{ label: { textTransform: "none", fontWeight: 700 } }}
|
||||||
|
>
|
||||||
|
Pay by bank transfer
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalSafeWrapper>
|
<ModalSafeWrapper>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Freight } from "@edr/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* USD bookings are never paid online: the customer pays by bank transfer and
|
||||||
|
* the Finance department confirms the payment from the slip. Phase 1 is
|
||||||
|
* portal-only — Finance's confirm flow lands in the backoffice later.
|
||||||
|
*/
|
||||||
|
export function isUsdCurrency(currency?: string | null): boolean {
|
||||||
|
return currency?.toUpperCase() === "USD";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUsdOfflineBooking(booking: Freight.IBooking): boolean {
|
||||||
|
return isUsdCurrency(
|
||||||
|
booking.pricingBreakdown?.currency ?? booking.paymentCurrency,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
} from "@/pages/bookings/booking-display";
|
} from "@/pages/bookings/booking-display";
|
||||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||||
|
import { formatAmount } from "./new-shipment-form/total";
|
||||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||||
import { getContractBookingAction } from "./contract-booking-action";
|
import { getContractBookingAction } from "./contract-booking-action";
|
||||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||||
@@ -719,7 +720,7 @@ export default function ContractDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text fz={14} fw={700} style={{ color: GREEN }}>
|
<Text fz={14} fw={700} style={{ color: GREEN }}>
|
||||||
{(item.unitPrice ?? 0).toLocaleString()} {pricing.currency}{" "}
|
{formatAmount(item.unitPrice)} {pricing.currency}{" "}
|
||||||
<Text span fz={12} fw={600} c="dimmed">
|
<Text span fz={12} fw={600} c="dimmed">
|
||||||
/ {formatRateUnit(item.unit)}
|
/ {formatRateUnit(item.unit)}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1336,9 +1337,7 @@ export default function ContractDetailPage() {
|
|||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{amount > 0
|
{amount > 0 ? `ETB ${formatAmount(amount)}` : "—"}
|
||||||
? `ETB ${amount.toLocaleString()}`
|
|
||||||
: "—"}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ import {
|
|||||||
createShipmentFormSchema,
|
createShipmentFormSchema,
|
||||||
initialShipmentFormValues,
|
initialShipmentFormValues,
|
||||||
} from "./new-shipment-form/schema";
|
} from "./new-shipment-form/schema";
|
||||||
import { computeShipmentTotal } from "./new-shipment-form/total";
|
import { computeShipmentTotal, formatAmount } from "./new-shipment-form/total";
|
||||||
import {
|
import {
|
||||||
downloadContainerImportTemplate,
|
downloadContainerImportTemplate,
|
||||||
parseContainerExcel,
|
parseContainerExcel,
|
||||||
@@ -1014,7 +1014,7 @@ function PriceConfirmModal({
|
|||||||
))}
|
))}
|
||||||
<Text fz="xs" c="#9A5B00" mt={2}>
|
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||||
{overweightSurchargeAmount > 0
|
{overweightSurchargeAmount > 0
|
||||||
? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${
|
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
|
||||||
validation?.currency ?? total?.currency ?? ""
|
validation?.currency ?? total?.currency ?? ""
|
||||||
} applies (included in the total below). You can still submit, or go back and adjust weights.`
|
} applies (included in the total below). You can still submit, or go back and adjust weights.`
|
||||||
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
|
: "An overweight surcharge applies. You can still submit, or go back and adjust weights."}
|
||||||
@@ -1038,7 +1038,7 @@ function PriceConfirmModal({
|
|||||||
</Text>
|
</Text>
|
||||||
<Text fz="xs" c="dimmed">
|
<Text fz="xs" c="dimmed">
|
||||||
{line.quantity.toLocaleString()} ×{" "}
|
{line.quantity.toLocaleString()} ×{" "}
|
||||||
{line.unitPrice.toLocaleString()} {total.currency} ·{" "}
|
{formatAmount(line.unitPrice)} {total.currency} ·{" "}
|
||||||
{formatRateUnit(line.unit)}
|
{formatRateUnit(line.unit)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -1048,7 +1048,7 @@ function PriceConfirmModal({
|
|||||||
c="#10202F"
|
c="#10202F"
|
||||||
style={{ whiteSpace: "nowrap" }}
|
style={{ whiteSpace: "nowrap" }}
|
||||||
>
|
>
|
||||||
{line.amount.toLocaleString()} {total.currency}
|
{formatAmount(line.amount)} {total.currency}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
@@ -1070,7 +1070,7 @@ function PriceConfirmModal({
|
|||||||
Total
|
Total
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={800} fz={28} c="#10202F">
|
<Text fw={800} fz={28} c="#10202F">
|
||||||
{total.total.toLocaleString()}{" "}
|
{formatAmount(total.total)}{" "}
|
||||||
<Text span fz={16} fw={700} c="edr-muted">
|
<Text span fz={16} fw={700} c="edr-muted">
|
||||||
{total.currency}
|
{total.currency}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -1215,6 +1215,9 @@ function ScheduleStep({
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const isIntercity = contract.tradeDirection === "DOMESTIC";
|
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";
|
||||||
const { data: availableDays, isLoading } = useQuery({
|
const { data: availableDays, isLoading } = useQuery({
|
||||||
...api.bookings.getAvailableDaysForCargo.queryOptions({
|
...api.bookings.getAvailableDaysForCargo.queryOptions({
|
||||||
input:
|
input:
|
||||||
@@ -1310,12 +1313,15 @@ function ScheduleStep({
|
|||||||
<Box mb="lg">
|
<Box mb="lg">
|
||||||
<StepLabel>Billing currency *</StepLabel>
|
<StepLabel>Billing currency *</StepLabel>
|
||||||
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
<Text fz={12.5} c="dimmed" mt={4} mb={10}>
|
||||||
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."}
|
||||||
</Text>
|
</Text>
|
||||||
<CurrencySelector
|
<CurrencySelector
|
||||||
value={field.value || ""}
|
value={field.value || ""}
|
||||||
onChange={(v) => field.onChange(v)}
|
onChange={(v) => field.onChange(v)}
|
||||||
error={fieldState.error?.message}
|
error={fieldState.error?.message}
|
||||||
|
allowUsd={isImport}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ export interface ShipmentTotal {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Money always prints its cents. Bare toLocaleString() defaults to
|
||||||
|
* maximumFractionDigits: 0, which rounded the total away from the line items it
|
||||||
|
* sums (118,171.21 shown as 118,171) — and the customer is billed the exact
|
||||||
|
* amount, so the shown figure must match to the cent.
|
||||||
|
*/
|
||||||
|
export function formatAmount(amount: number | string | null | undefined) {
|
||||||
|
return Number(amount ?? 0).toLocaleString(undefined, {
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both
|
* Quantity a bulk rate bills, in ITS OWN unit. PER_ITEM cargo carries both
|
||||||
* figures — the item count prices the booking, the tonnage sizes the wagons —
|
* figures — the item count prices the booking, the tonnage sizes the wagons —
|
||||||
|
|||||||
@@ -89,7 +89,6 @@ export default function TabCompanyProfile({
|
|||||||
profile,
|
profile,
|
||||||
mode = "edit",
|
mode = "edit",
|
||||||
onCreateSuccess,
|
onCreateSuccess,
|
||||||
user,
|
|
||||||
}: TabCompanyProfileProps) {
|
}: TabCompanyProfileProps) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const isCreate = mode === "create";
|
const isCreate = mode === "create";
|
||||||
|
|||||||
@@ -8,17 +8,27 @@ export interface CurrencySelectorProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Validation error shown under the cards. */
|
/** Validation error shown under the cards. */
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/**
|
||||||
|
* Offer USD alongside ETB. Import shipments only — export and domestic
|
||||||
|
* traffic is invoiced in ETB, so the option stays hidden everywhere else.
|
||||||
|
* USD is settled by bank transfer, never through the online gateway.
|
||||||
|
*/
|
||||||
|
allowUsd?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ponytail: ETB-only for now — restore the USD entry when multi-currency billing returns.
|
const ETB_OPTION = {
|
||||||
const OPTIONS = [
|
code: "ETB",
|
||||||
{
|
symbol: "Br",
|
||||||
code: "ETB",
|
name: "Ethiopian Birr",
|
||||||
symbol: "Br",
|
hint: "Pay online through the payment gateway",
|
||||||
name: "Ethiopian Birr",
|
} as const;
|
||||||
hint: "All shipments are invoiced in ETB",
|
|
||||||
},
|
const USD_OPTION = {
|
||||||
] as const;
|
code: "USD",
|
||||||
|
symbol: "$",
|
||||||
|
name: "US Dollar",
|
||||||
|
hint: "Paid by bank transfer — send the slip to Finance",
|
||||||
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Card-style USD/ETB billing-currency picker. Renders unselected when `value`
|
* Card-style USD/ETB billing-currency picker. Renders unselected when `value`
|
||||||
@@ -29,7 +39,9 @@ export function CurrencySelector({
|
|||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
error,
|
error,
|
||||||
|
allowUsd = false,
|
||||||
}: CurrencySelectorProps) {
|
}: CurrencySelectorProps) {
|
||||||
|
const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION];
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Box
|
<Box
|
||||||
@@ -39,7 +51,7 @@ export function CurrencySelector({
|
|||||||
gap: 10,
|
gap: 10,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{OPTIONS.map((o) => {
|
{options.map((o) => {
|
||||||
const selected = value === o.code;
|
const selected = value === o.code;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
Reference in New Issue
Block a user