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' }),
|
||||
).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 {
|
||||
id: string;
|
||||
tradeDirection?: string | null;
|
||||
/** 'DIRECT_TO_TRAIN' skips the gate entirely; null/'WAREHOUSE' keeps it. */
|
||||
exportHandoverMode?: string | null;
|
||||
}
|
||||
|
||||
/** Direct truck-to-train: the cargo never sees a warehouse, so it never has a GRN. */
|
||||
export const DIRECT_TO_TRAIN = 'DIRECT_TO_TRAIN';
|
||||
/** Warehouse-then-train: the existing flow. Also what a null mode means. */
|
||||
export const WAREHOUSE = 'WAREHOUSE';
|
||||
|
||||
/**
|
||||
* Export cargo may not be loaded onto its train until it has physically reached
|
||||
* the warehouse and been issued a GRN — whether it got there by first-mile or by
|
||||
@@ -21,12 +28,18 @@ export interface ExportLoadGateBooking {
|
||||
* "Received with a GRN" = an inventory row that has reached the warehouse
|
||||
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
|
||||
* fallback older rows use.
|
||||
*
|
||||
* Export has a second, warehouse-free shape: the customer's truck loads straight
|
||||
* onto the wagon. That cargo is never received and never GRN'd, so a booking
|
||||
* marked DIRECT_TO_TRAIN is outside this gate by definition — its custody is
|
||||
* attested by the carriage acceptance sheet instead.
|
||||
*/
|
||||
export async function assertExportReceivedWithGrn(
|
||||
db: DataSource | EntityManager,
|
||||
booking: ExportLoadGateBooking,
|
||||
): Promise<void> {
|
||||
if (booking.tradeDirection !== 'EXPORT') return;
|
||||
if (booking.exportHandoverMode === DIRECT_TO_TRAIN) return;
|
||||
|
||||
const [row] = await db.query(
|
||||
`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 {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { actorLabel } from "../warehouses/current-actor.util";
|
||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||
import { BillingService } from "./billing.service";
|
||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
@@ -25,6 +37,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||
@BookingStaff([
|
||||
FREIGHT_PERMS.invoices.view,
|
||||
FREIGHT_PERMS.invoices.export,
|
||||
FREIGHT_PERMS.invoices.confirmOffline,
|
||||
])
|
||||
@ApiBearerAuth()
|
||||
export class BillingController {
|
||||
@@ -56,6 +69,36 @@ export class BillingController {
|
||||
return this.billingService.findById(id);
|
||||
}
|
||||
|
||||
@Get("offline-usd")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
|
||||
})
|
||||
findOfflineUsd(@Query() query: FilterInvoiceDto) {
|
||||
return this.billingService.findOfflineUsdPaginated(query);
|
||||
}
|
||||
|
||||
@Post("invoices/:id/confirm-offline")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
|
||||
@UseInterceptors(FileInterceptor("file"))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
|
||||
})
|
||||
confirmOffline(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@UploadedFile() file: Express.Multer.File | undefined,
|
||||
@Body("reference") reference: string | undefined,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.billingService.confirmOfflinePayment(id, file, {
|
||||
reference: reference?.trim() || null,
|
||||
userId: resolveAuthUserId(user),
|
||||
userName: actorLabel(user) ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
@Get("invoices/:id/document")
|
||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||
|
||||
@@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository";
|
||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||
import { PaymentModule } from "../payment/payment.module";
|
||||
import { CompaniesModule } from "../companies/companies.module";
|
||||
import { FilesModule } from "../files/files.module";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
||||
CompaniesModule,
|
||||
DocumentsModule,
|
||||
UserTradeAccessModule,
|
||||
FilesModule,
|
||||
],
|
||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||
|
||||
@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
});
|
||||
|
||||
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
|
||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
|
||||
{} as never, // payment
|
||||
{} as never, // companies
|
||||
{} as never, // invoiceDocuments
|
||||
{} as never, // files
|
||||
);
|
||||
return { service, mg, events };
|
||||
}
|
||||
@@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, defaultManager, txManager, transaction };
|
||||
};
|
||||
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, manager };
|
||||
};
|
||||
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
@@ -669,11 +677,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
|
||||
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
|
||||
// totalAmount — money missing from the bank with the books saying paid.
|
||||
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
|
||||
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
|
||||
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
|
||||
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
|
||||
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
|
||||
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
|
||||
const invoice = {
|
||||
id: "inv-1",
|
||||
status: Freight.InvoiceStatus.Pending,
|
||||
@@ -682,9 +690,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
type: "PREPAID",
|
||||
invoiceNumber: "INV-20260101-00001",
|
||||
currency: "ETB",
|
||||
// .40 — the case Math.round gets wrong (rounds down, underpays).
|
||||
balanceAmount: 12345.4,
|
||||
totalAmount: 12345.4,
|
||||
// .43 — cents that must survive all the way to the bill.
|
||||
balanceAmount: 12345.43,
|
||||
totalAmount: 12345.43,
|
||||
company: { name: "Acme PLC" },
|
||||
paymentId: null,
|
||||
dueAt: null,
|
||||
@@ -703,11 +711,12 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
it("opens the intent for the ceiled balance, never below it", async () => {
|
||||
it("opens the intent for the exact balance, cents included", async () => {
|
||||
const initiate = jest.fn().mockResolvedValue({
|
||||
intentId: "intent-1",
|
||||
immediateSuccess: false,
|
||||
@@ -718,16 +727,16 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
await service.payInvoice("inv-1", { method: "CBE_BILL" });
|
||||
|
||||
expect(initiate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ amountMinor: 12346 }),
|
||||
expect.objectContaining({ amountMinor: 12345.43 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
|
||||
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
|
||||
const { service } = build();
|
||||
|
||||
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
|
||||
stillPayable: true,
|
||||
currentAmountMinor: 12346,
|
||||
currentAmountMinor: 12345.43,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
||||
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
import { CompaniesService } from "../companies/companies.service";
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||
import { PaymentService } from "../payment/payment.service";
|
||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
|
||||
failureUrl?: string;
|
||||
}
|
||||
|
||||
/** Booking context attached to a finance offline-USD invoice row. */
|
||||
export interface OfflineUsdBookingInfo {
|
||||
id: string;
|
||||
reference: string;
|
||||
paymentDeadline: Date | null;
|
||||
paymentStatus: string;
|
||||
}
|
||||
|
||||
/** A single manual/offline settlement to record against an invoice. */
|
||||
export interface RecordPaymentInput {
|
||||
/** Amount settled by this payment; must be > 0. */
|
||||
@@ -150,6 +159,7 @@ export class BillingService {
|
||||
private readonly payment: PaymentService,
|
||||
private readonly companies: CompaniesService,
|
||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||
private readonly files: FilesService,
|
||||
) { }
|
||||
|
||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||
@@ -214,6 +224,146 @@ export class BillingService {
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
|
||||
* never through the gateway), open ones by default or a single status when
|
||||
* filtered. Booking-sourced rows carry the booking's reference and pay-window
|
||||
* deadline so the UI can show the countdown and link to the booking.
|
||||
*/
|
||||
async findOfflineUsdPaginated(
|
||||
filter: {
|
||||
status?: Freight.InvoiceStatus;
|
||||
search?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
} = {},
|
||||
): Promise<{
|
||||
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
|
||||
total: number;
|
||||
}> {
|
||||
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||
const pageSize =
|
||||
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||
|
||||
const qb = this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.createQueryBuilder("invoice")
|
||||
.leftJoinAndSelect("invoice.company", "company")
|
||||
.where("UPPER(invoice.currency) = 'USD'")
|
||||
.orderBy("invoice.issuedAt", "DESC")
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
if (filter.status) {
|
||||
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||
} else {
|
||||
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere(
|
||||
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||
{ search: `%${filter.search}%` },
|
||||
);
|
||||
}
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
const bookingIds = items
|
||||
.filter((i) => i.source === "booking")
|
||||
.map((i) => i.sourceId);
|
||||
const bookings = bookingIds.length
|
||||
? await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(bookingIds) },
|
||||
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
|
||||
})
|
||||
: [];
|
||||
const byId = new Map(bookings.map((b) => [b.id, b]));
|
||||
|
||||
return {
|
||||
items: items.map((inv) => {
|
||||
const b = byId.get(inv.sourceId);
|
||||
return {
|
||||
...inv,
|
||||
booking: b
|
||||
? {
|
||||
id: b.id,
|
||||
reference: b.reference,
|
||||
paymentDeadline: b.paymentDeadline ?? null,
|
||||
paymentStatus: b.paymentStatus,
|
||||
}
|
||||
: null,
|
||||
} as Invoice & { booking: OfflineUsdBookingInfo | null };
|
||||
}),
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
|
||||
* against the invoice and settles the FULL outstanding balance through
|
||||
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||
* the booking advances exactly as if it had been paid through the gateway.
|
||||
*
|
||||
* Guarded by the booking's pay window: past the deadline the booking expires
|
||||
* like any unpaid one, so confirmation is refused.
|
||||
*/
|
||||
async confirmOfflinePayment(
|
||||
invoiceId: string,
|
||||
file: Express.Multer.File | undefined,
|
||||
input: {
|
||||
reference?: string | null;
|
||||
userId?: string | null;
|
||||
userName?: string | null;
|
||||
},
|
||||
): Promise<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. */
|
||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||
const invoice = await this.invoices.findById(id, {
|
||||
@@ -1191,11 +1341,11 @@ export class BillingService {
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
|
||||
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
|
||||
// land below the outstanding balance — Math.round would let a .40 balance
|
||||
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
|
||||
// ceil in billQuery keeps the quoted and debited amounts identical.
|
||||
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
|
||||
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
|
||||
// matches the debited amount to the cent (amountsMatchToTheCent), so any
|
||||
// rounding here would overcharge the payer and leave the invoice balance
|
||||
// non-zero. billQuery quotes the same unrounded value.
|
||||
amountMinor: round2(Number(invoice.balanceAmount)),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
@@ -1340,9 +1490,10 @@ export class BillingService {
|
||||
});
|
||||
|
||||
if (open) {
|
||||
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
|
||||
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
|
||||
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
|
||||
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
|
||||
// to be the amount the intent was opened for, to the cent, or /cbe/payment
|
||||
// sees a mismatch.
|
||||
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
|
||||
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
|
||||
return {
|
||||
stillPayable: balance > 0 && !expired,
|
||||
@@ -1377,7 +1528,7 @@ export class BillingService {
|
||||
return {
|
||||
stillPayable: false,
|
||||
payerName: latest.company?.name ?? null,
|
||||
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
|
||||
currentAmountMinor: round2(Number(latest.totalAmount)),
|
||||
currency: latest.currency,
|
||||
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
|
||||
reason: closedInvoiceReason(latest.status),
|
||||
|
||||
@@ -279,9 +279,10 @@ export class BookingPricingService {
|
||||
|
||||
return {
|
||||
lineItems,
|
||||
// Grand total is billed in whole currency units — fractional line sums
|
||||
// (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
|
||||
totalAmount: Math.round(total),
|
||||
// Grand total keeps its cents, matching the line items it sums — rounding
|
||||
// to whole birr made the total disagree with the breakdown (135,375.61 of
|
||||
// lines shown as a 135,376.00 total) and CBE bills this figure to the cent.
|
||||
totalAmount: round2(total),
|
||||
currency: booking.paymentCurrency,
|
||||
usedRates: [...usedRatesMap.values()],
|
||||
appliedModifiers: ruleResult.appliedModifiers,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
|
||||
@@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import {
|
||||
@@ -757,6 +758,18 @@ export class BookingsController {
|
||||
return this.customerTruckService.getLoadableContainers(id);
|
||||
}
|
||||
|
||||
@Patch(':id/export-handover-mode')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first',
|
||||
})
|
||||
setExportHandoverMode(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: SetExportHandoverModeDto,
|
||||
) {
|
||||
return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode);
|
||||
}
|
||||
|
||||
@Post(':id/customer-trucks/:assignmentId/load')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
|
||||
|
||||
@@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
@@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
@@ -285,10 +285,24 @@ export class BookingsService {
|
||||
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
|
||||
// and never appears on this sheet — it is only the signal that EDR has taken
|
||||
// the cargo, which is what the customer's sheet attests to.
|
||||
const isDirectExport =
|
||||
booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN;
|
||||
const pendingWagons = wagons.length === 0;
|
||||
if (pendingWagons) {
|
||||
const receivedLines: CarriageAcceptanceReceivedRow[] =
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
// Direct truck-to-train cargo never enters the warehouse, so there is no
|
||||
// GRN'd inventory to build the sheet from. Choosing direct handover is
|
||||
// itself the acceptance, so the sheet issues off the booking's own
|
||||
// containers (or its VGM weight when the cargo is bulk).
|
||||
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
|
||||
? await this.dataSource.query(
|
||||
`SELECT NULL::numeric AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
FROM freight.containers c
|
||||
WHERE c.booking_id = $1 AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number`,
|
||||
[bookingId],
|
||||
)
|
||||
: booking.tradeDirection === 'EXPORT'
|
||||
? await this.dataSource.query(
|
||||
`SELECT inv.weight AS "allocatedWeightTons",
|
||||
c.container_number AS "containerNumbers"
|
||||
@@ -304,6 +318,15 @@ export class BookingsService {
|
||||
[bookingId],
|
||||
)
|
||||
: [];
|
||||
// Bulk direct cargo has no containers — one line carrying the booking's
|
||||
// declared weight still makes a valid sheet.
|
||||
if (isDirectExport && receivedLines.length === 0) {
|
||||
receivedLines.push({
|
||||
allocatedWeightTons:
|
||||
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
|
||||
containerNumbers: null,
|
||||
});
|
||||
}
|
||||
if (receivedLines.length === 0) {
|
||||
throw new BadRequestException(
|
||||
booking.tradeDirection === 'EXPORT'
|
||||
@@ -1997,6 +2020,47 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
/** Get a single booking by ID with files. */
|
||||
/**
|
||||
* EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes
|
||||
* the booking out of the warehouse flow entirely — no receipt, no GRN, and the
|
||||
* carriage acceptance sheet becomes issuable straight away.
|
||||
*
|
||||
* Switching to direct is refused once the goods are already in the shed:
|
||||
* inventory exists, so the cargo demonstrably went the warehouse route and its
|
||||
* GRN paperwork must stand.
|
||||
*/
|
||||
async setExportHandoverMode(
|
||||
bookingId: string,
|
||||
mode: string,
|
||||
): Promise<{ bookingId: string; exportHandoverMode: string }> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') {
|
||||
throw new BadRequestException('Handover mode applies to export bookings only');
|
||||
}
|
||||
if (mode === DIRECT_TO_TRAIN) {
|
||||
const [stored]: Array<{ one: number }> = await this.dataSource.query(
|
||||
`SELECT 1 AS one
|
||||
FROM freight.warehouse_inventory
|
||||
WHERE booking_id = $1 AND deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (stored) {
|
||||
throw new BadRequestException(
|
||||
'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train',
|
||||
);
|
||||
}
|
||||
}
|
||||
await this.dataSource.query(
|
||||
`UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`,
|
||||
[bookingId, mode],
|
||||
);
|
||||
return { bookingId, exportHandoverMode: mode };
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
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 })
|
||||
customerTruckArrivedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* EXPORT only. How the cargo reaches the train:
|
||||
* - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No
|
||||
* warehouse, so no GRN is ever raised and the carriage acceptance sheet is
|
||||
* the only document handed over.
|
||||
* - WAREHOUSE (also null) — received into the warehouse and GRN'd first.
|
||||
*
|
||||
* Null is treated as WAREHOUSE so existing bookings keep the GRN gate.
|
||||
*/
|
||||
@Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true })
|
||||
exportHandoverMode?: string | null;
|
||||
|
||||
/**
|
||||
* Did the goods need re-handling in the warehouse? Recorded by warehouse
|
||||
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;
|
||||
|
||||
@@ -32,7 +32,7 @@ export class Container extends BaseEntity {
|
||||
type: 'varchar',
|
||||
nullable: true,
|
||||
})
|
||||
sealNumber!: string | null;
|
||||
sealNumber!: string | null;
|
||||
|
||||
@Column({ type: 'varchar', default: 'AVAILABLE' })
|
||||
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
|
||||
|
||||
@@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
|
||||
@@ -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)', {
|
||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||
})
|
||||
// A ONE_TIME contract allows a single booking, so once that booking
|
||||
// exists the contract is spent and can never carry another shipment.
|
||||
// A ONE_TIME contract allows a single booking, so once that booking is
|
||||
// PAID the contract is spent and can never carry another shipment.
|
||||
// Without this it kept blocking new requests on the same service type +
|
||||
// route until its validity lapsed — locking a customer out of a lane for
|
||||
// the rest of the term after one completed shipment.
|
||||
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
|
||||
// booking must keep the contract blocking, otherwise a customer holds an
|
||||
// unpaid booking and requests an identical contract alongside it.
|
||||
.andWhere(
|
||||
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||
SELECT 1 FROM freight.bookings b
|
||||
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||
AND b.payment_status = 'PAID'
|
||||
))`,
|
||||
)
|
||||
.getMany();
|
||||
|
||||
@@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
|
||||
@@ -37,7 +37,7 @@ import { BookingNotifierService } from './booking-notifier.service';
|
||||
import {
|
||||
TrainSchedulingService,
|
||||
effectiveWindowConfig,
|
||||
} from './train-scheduling.service';
|
||||
} from './services/train-scheduling.service';
|
||||
import { eatDay, listConfigBookingWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_BOARD_STATUSES,
|
||||
@@ -90,7 +90,7 @@ import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
containerWagonsForLines,
|
||||
} from './wagon-plan.util';
|
||||
} from './utils/wagon-plan.util';
|
||||
import {
|
||||
Capacity,
|
||||
CorridorBudget,
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '../notifications/resolve-company-phone.util';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
|
||||
import { TrainSchedulingService, effectiveWindowConfig } from './services/train-scheduling.service';
|
||||
import { BATCH_TIMEZONE } from './booking-batch.constants';
|
||||
import {
|
||||
bookingCloseCutoff,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ContainerPlacementInput } from './wagon-plan.util';
|
||||
import type { ContainerPlacementInput } from './utils/wagon-plan.util';
|
||||
|
||||
export type ContainerUnitForPlacement = {
|
||||
bookingId: string;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
import type { AuthUserPayload } from "../../../common/resolve-auth-user-id";
|
||||
import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
|
||||
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
||||
|
||||
import {
|
||||
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
|
||||
@@ -19,46 +19,46 @@ import {
|
||||
TrainSchedulingRulesManage,
|
||||
TrainSchedulingUpdate,
|
||||
TrainSchedulingView,
|
||||
} from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
|
||||
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
|
||||
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
|
||||
import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto";
|
||||
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
|
||||
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
|
||||
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
|
||||
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
|
||||
import { PinWagonsDto } from "./dto/pin-wagons.dto";
|
||||
import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto";
|
||||
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
|
||||
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
|
||||
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
|
||||
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
||||
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||
} from "../../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
|
||||
import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto";
|
||||
import { AssignBookingsDto } from "../dto/assign-bookings.dto";
|
||||
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
|
||||
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
|
||||
import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto";
|
||||
import { GetEligibleBookingsDto } from "../dto/get-eligible-bookings.dto";
|
||||
import { GetEligibleBulkBookingsDto } from "../dto/get-eligible-bulk-bookings.dto";
|
||||
import { GetEligibleContainerBookingsDto } from "../dto/get-eligible-container-bookings.dto";
|
||||
import { PinWagonsDto } from "../dto/pin-wagons.dto";
|
||||
import { MoveWagonLoadDto } from "../dto/move-wagon-load.dto";
|
||||
import { UpdateContainerItemDto } from "../dto/update-container-item.dto";
|
||||
import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-status.dto";
|
||||
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
|
||||
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
|
||||
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto";
|
||||
import {
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from "./dto/import-djibouti-operation.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
|
||||
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
|
||||
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
|
||||
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
|
||||
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
import { BookingJourneyService } from "./booking-journey.service";
|
||||
import { BookingWindowService } from "./booking-window.service";
|
||||
import { IntercityService } from "./intercity.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
} from "../dto/import-djibouti-operation.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
|
||||
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto";
|
||||
import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
|
||||
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
|
||||
import { ListTrainSchedulesQueryDto } from "../dto/list-train-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
|
||||
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
|
||||
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
|
||||
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
|
||||
import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto";
|
||||
import { TrainSchedulingService } from "../services/train-scheduling.service";
|
||||
import { BookingBatchService } from "../booking-batch.service";
|
||||
import { BookingJourneyService } from "../booking-journey.service";
|
||||
import { BookingWindowService } from "../booking-window.service";
|
||||
import { IntercityService } from "../intercity.service";
|
||||
import { BillingService } from "../../billing/billing.service";
|
||||
|
||||
@ApiTags("train-scheduling")
|
||||
@ApiBearerAuth()
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { TrainSchedulingService } from './services/train-scheduling.service';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||
|
||||
@Injectable()
|
||||
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
|
||||
@@ -1,11 +1,11 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
import { WagonStatus } from '@edr/types';
|
||||
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
|
||||
const nw5 = {
|
||||
@@ -36,71 +36,71 @@ import {
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
} from '../../common/utils/pagination.util';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { Train } from '../trains/entities/train.entity';
|
||||
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../train-sets/entities/train-set.entity';
|
||||
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
|
||||
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
|
||||
import { AssignBookingsDto } from './dto/assign-bookings.dto';
|
||||
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
|
||||
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
|
||||
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
|
||||
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
|
||||
} from '../../../common/utils/pagination.util';
|
||||
import { BookingsRepository } from '../../bookings/bookings.repository';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
|
||||
import { ClearanceMilestone } from '../../contracts/entities/clearance-milestone.entity';
|
||||
import { ClearanceMilestoneService } from '../../contracts/clearance-milestone.service';
|
||||
import { Contract } from '../../contracts/entities/contract.entity';
|
||||
import { Container } from '../../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
|
||||
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
|
||||
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
|
||||
import { Train } from '../../trains/entities/train.entity';
|
||||
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
|
||||
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
|
||||
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
|
||||
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
|
||||
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
|
||||
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
|
||||
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
|
||||
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
|
||||
import { Yard } from '../../rule-engine/entities/yard.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
|
||||
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
|
||||
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
|
||||
import { GetEligibleBookingsDto } from '../dto/get-eligible-bookings.dto';
|
||||
import { GetEligibleBulkBookingsDto } from '../dto/get-eligible-bulk-bookings.dto';
|
||||
import { GetEligibleContainerBookingsDto } from '../dto/get-eligible-container-bookings.dto';
|
||||
import {
|
||||
ListTrainSchedulesQueryDto,
|
||||
TrainScheduleFreightType,
|
||||
} from './dto/list-train-schedules-query.dto';
|
||||
import { PinWagonsDto } from './dto/pin-wagons.dto';
|
||||
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
|
||||
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
|
||||
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
} from '../dto/list-train-schedules-query.dto';
|
||||
import { PinWagonsDto } from '../dto/pin-wagons.dto';
|
||||
import { MoveWagonLoadDto } from '../dto/move-wagon-load.dto';
|
||||
import { UpdateContainerItemDto } from '../dto/update-container-item.dto';
|
||||
import { UpdateImportLoadingStatusDto } from '../dto/update-import-loading-status.dto';
|
||||
import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.dto';
|
||||
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
|
||||
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
||||
import {
|
||||
ImportDjiboutiOperation,
|
||||
type ImportDjiboutiDocumentType,
|
||||
} from './entities/import-djibouti-operation.entity';
|
||||
} from '../entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from './dto/import-djibouti-operation.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
|
||||
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
|
||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from './booking-window.config';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
} from '../dto/import-djibouti-operation.dto';
|
||||
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
|
||||
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
|
||||
import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
|
||||
import { type BookingWindowConfig } from '../booking-window.config';
|
||||
import { BookingWindowGateway } from '../booking-window.gateway';
|
||||
import { BookingNotifierService } from '../booking-notifier.service';
|
||||
import { BookingBatchService } from '../booking-batch.service';
|
||||
import {
|
||||
computeFleetAvailability,
|
||||
summarizeFleetWarnings,
|
||||
@@ -109,13 +109,13 @@ import {
|
||||
type BookingWagonShortage,
|
||||
type DeferredBookingRow,
|
||||
type FleetAvailabilityRow,
|
||||
} from './fleet-plan.util';
|
||||
} from '../utils/fleet-plan.util';
|
||||
import {
|
||||
applyWagonOrderReversal,
|
||||
planWagonsWithStock,
|
||||
type AllowedWagonTypeMap,
|
||||
type WagonStock,
|
||||
} from './wagon-plan-flex.util';
|
||||
} from '../wagon-plan-flex.util';
|
||||
import {
|
||||
containerWagonsForLines,
|
||||
expandBookingContainerUnits,
|
||||
@@ -129,10 +129,10 @@ import {
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
type ContainerPlacementInput,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
import { CorridorBudget } from './corridor-capacity.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
|
||||
} from '../utils/wagon-plan.util';
|
||||
import { CorridorBudget } from '../corridor-capacity.util';
|
||||
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||||
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
@@ -144,7 +144,7 @@ import {
|
||||
wagonTypeDimensionsFromEntity,
|
||||
LocomotiveLimits,
|
||||
WagonTypeDimensions,
|
||||
} from './train-capacity.util';
|
||||
} from '../train-capacity.util';
|
||||
import {
|
||||
DEFAULT_BULK_WAGON_CAPACITY_TONS,
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
@@ -153,8 +153,8 @@ import {
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
paymentDrainEndsAtIso,
|
||||
} from './booking-batch.constants';
|
||||
import { orderConsistWagons } from './consist-order.util';
|
||||
} from '../booking-batch.constants';
|
||||
import { orderConsistWagons } from '../consist-order.util';
|
||||
import {
|
||||
computeExportWindowTimes,
|
||||
computeImportWindowTimes,
|
||||
@@ -162,22 +162,22 @@ import {
|
||||
eatDay,
|
||||
eatDayToUtc,
|
||||
shiftEatDay,
|
||||
} from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
} from '../batch-window.util';
|
||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from '../booking-journey.service';
|
||||
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
|
||||
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
|
||||
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
isPlaceholderContainerNumber,
|
||||
placementsForBookings,
|
||||
type ContainerUnitForPlacement,
|
||||
} from './container-placement.util';
|
||||
} from '../container-placement.util';
|
||||
|
||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||
|
||||
@@ -5709,7 +5709,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private resolveScheduleFreightType(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
||||
const types = new Set(
|
||||
(schedule.scheduleBookings ?? [])
|
||||
@@ -5751,7 +5751,7 @@ export class TrainSchedulingService {
|
||||
throw new ConflictException('Could not allocate a unique schedule reference');
|
||||
}
|
||||
|
||||
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -6907,7 +6907,7 @@ export class TrainSchedulingService {
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): Promise<
|
||||
import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
|
||||
import('../../train-schedules/entities/train-schedule.entity').TrainSchedule[]
|
||||
> {
|
||||
const schedules = await this.trainSchedulesRepository.findAll({
|
||||
where: {
|
||||
@@ -7497,7 +7497,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private async mapScheduleDetail(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
) {
|
||||
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
|
||||
(w) => w.allocations ?? [],
|
||||
@@ -24,9 +24,9 @@ import { WarehousesModule } from '../warehouses/warehouses.module';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { TrainCheckpointEventsRepository } from './repositories/train-checkpoint-events.repository';
|
||||
import { TrainSchedulingController } from './controllers/train-scheduling.controller';
|
||||
import { TrainSchedulingService } from './services/train-scheduling.service';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
||||
|
||||
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
|
||||
export const deriveScheduleDirection = deriveTradeDirection;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
computeFleetAvailability,
|
||||
selectBookingsWithinFleetCap,
|
||||
@@ -1,6 +1,6 @@
|
||||
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
|
||||
import type { Booking } from '../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { bookingCargoTons, bulkWagonsForAllowedTypes } from '../train-capacity.util';
|
||||
import type { Booking } from '../../bookings/entities/booking.entity';
|
||||
import type { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
buildBulkWagonPlan,
|
||||
buildContainerWagonPlan,
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
buildBulkWagonPlan,
|
||||
buildContainerWagonPlan,
|
||||
@@ -1,8 +1,8 @@
|
||||
import { AllocationLoadType } from '@edr/types';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Booking } from '../../bookings/entities/booking.entity';
|
||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../../rule-engine/container-type.util';
|
||||
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||
import {
|
||||
bookingCargoTons,
|
||||
bulkItemsFitFor,
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
bulkTonsPerWagon,
|
||||
bulkTonWagonsRequired,
|
||||
consistViolations,
|
||||
} from './train-capacity.util';
|
||||
} from '../train-capacity.util';
|
||||
|
||||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||
@@ -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,
|
||||
planWagonsWithStock,
|
||||
} from './wagon-plan-flex.util';
|
||||
import type { WagonPlanSlot } from './wagon-plan.util';
|
||||
import type { WagonPlanSlot } from './utils/wagon-plan.util';
|
||||
|
||||
const nw6: WagonType = {
|
||||
id: 'wt-nw6',
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
sortBookingsForScheduling,
|
||||
type BookingWagonShortage,
|
||||
type DeferredBookingRow,
|
||||
} from './fleet-plan.util';
|
||||
} from './utils/fleet-plan.util';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
containerWagonsForLines,
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
teuSlotsForSizeFt,
|
||||
type SlotLoadType,
|
||||
type WagonPlanSlot,
|
||||
} from './wagon-plan.util';
|
||||
} from './utils/wagon-plan.util';
|
||||
|
||||
/**
|
||||
* Wagon types allowed to carry each container type / bulk cargo type — the
|
||||
|
||||
@@ -1425,6 +1425,9 @@ export class WarehouseInventoryService {
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.payment_status = 'PAID'
|
||||
AND inv.id IS NULL
|
||||
-- Direct truck-to-train cargo never comes to the warehouse, so never
|
||||
-- offer it for receipt.
|
||||
AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN'
|
||||
ORDER BY b.scheduled_date DESC NULLS LAST`,
|
||||
);
|
||||
|
||||
|
||||
@@ -482,6 +482,13 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
||||
"edr_freight_app:invoices:eims_resolve",
|
||||
"Resolve a blocked MoR EIMS submission",
|
||||
),
|
||||
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
||||
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||
perm(
|
||||
"d2b00001-0001-4000-8000-000000000007",
|
||||
"edr_freight_app:invoices:confirm_offline",
|
||||
"Confirm offline (bank transfer) invoice payment",
|
||||
),
|
||||
];
|
||||
|
||||
// E. First / last mile operations
|
||||
@@ -1640,6 +1647,7 @@ export const FREIGHT_PERMS = {
|
||||
export: "edr_freight_app:invoices:export",
|
||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
},
|
||||
firstMile: {
|
||||
view: "edr_freight_app:first_mile:view",
|
||||
|
||||
@@ -36,6 +36,7 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
||||
import CustomersPage from "./pages/customers/CustomersPage";
|
||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||
@@ -266,6 +267,14 @@ const App = () => {
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="usd-payments"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
|
||||
<UsdPaymentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="invoices/:id"
|
||||
element={
|
||||
|
||||
@@ -19,7 +19,10 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
const lineItems = booking.pricingBreakdown?.lineItems ?? [];
|
||||
|
||||
const fmt = (n: number) =>
|
||||
`${booking.paymentCurrency} ${n.toLocaleString(undefined, { minimumFractionDigits: 2 })}`;
|
||||
`${booking.paymentCurrency} ${n.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
<SectionCard icon={Banknote} title="Pricing & payment">
|
||||
@@ -74,7 +77,11 @@ export function BookingPricingSummary({ booking }: { booking: BookingDetail }) {
|
||||
{li.description}
|
||||
</Text>
|
||||
<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>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
@@ -11,7 +11,10 @@ import { SectionCard } from "./SectionCard";
|
||||
import { MetricTile } from "./MetricTile";
|
||||
|
||||
const money = (amount: number, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
`${Number(amount).toLocaleString(undefined, {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${currency === "ETB" ? "Birr (ETB)" : currency}`;
|
||||
|
||||
/**
|
||||
* Cargo costs (booking-level totals) plus the same truck-import block the
|
||||
|
||||
@@ -348,6 +348,9 @@ export default function GlCreateBookingForm() {
|
||||
// Intercity shipments ride a passing import/export train staff pick at
|
||||
// finalize time — no shipment day is chosen and no window gate applies.
|
||||
const isIntercity = contract?.tradeDirection === "DOMESTIC";
|
||||
// USD billing is offered on import traffic only — export and domestic
|
||||
// shipments are always invoiced in ETB.
|
||||
const isImport = contract?.tradeDirection === "IMPORT";
|
||||
|
||||
// ONE_TIME split-remainder mode: a previous booking on this contract was
|
||||
// split on train capacity, so the capacity endpoint reports the outstanding
|
||||
@@ -1757,12 +1760,15 @@ export default function GlCreateBookingForm() {
|
||||
Billing currency
|
||||
</Text>
|
||||
<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>
|
||||
<CurrencySelector
|
||||
value={isIntercity ? "ETB" : paymentCurrency}
|
||||
onChange={setPaymentCurrency}
|
||||
disabled={isIntercity}
|
||||
allowUsd={isImport}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FileText,
|
||||
Hammer,
|
||||
History,
|
||||
Landmark,
|
||||
LayoutDashboard,
|
||||
LayoutGrid,
|
||||
MapPin,
|
||||
@@ -111,6 +112,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
||||
icon: <Receipt />,
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
},
|
||||
{
|
||||
label: "USD Payments",
|
||||
href: "/dashboard/usd-payments",
|
||||
icon: <Landmark />,
|
||||
permission: FREIGHT_PERMS.invoices.view,
|
||||
},
|
||||
{
|
||||
label: "Support",
|
||||
href: "/dashboard/support",
|
||||
|
||||
@@ -561,7 +561,7 @@ const RuleEngineFormDialog = ({
|
||||
// numbers (@IsInt on points/sizes/order, @IsNumber on money, tons, km),
|
||||
// so let the field carry decimals and let a 400 catch the rest.
|
||||
step={isNumber ? "any" : undefined}
|
||||
disabled={field.disabled || computed !== undefined}
|
||||
disabled={field.disabled || (field.disabledOnEdit && !!initialRecord) || computed !== undefined}
|
||||
value={String((computed !== undefined ? computed : values[field.name]) ?? "")}
|
||||
onChange={(e) => {
|
||||
const next = e.currentTarget.value;
|
||||
|
||||
@@ -51,6 +51,8 @@ export const QUERY_KEYS = {
|
||||
list: (filter?: InvoiceListFilter) =>
|
||||
["invoices", "list", filter ?? {}] as const,
|
||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||
offlineUsd: (filter?: InvoiceListFilter) =>
|
||||
["invoices", "offline-usd", filter ?? {}] as const,
|
||||
eimsStatus: (id: string) => ["invoices", "eims", id] as const,
|
||||
},
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ export const URL_CONSTANTS = {
|
||||
INVOICES: "/billing/invoices",
|
||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||
OFFLINE_USD: "/billing/offline-usd",
|
||||
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
|
||||
},
|
||||
|
||||
// MoR EIMS filing. Mounted on /invoices, not /billing/invoices — see EimsInvoiceController.
|
||||
@@ -153,6 +155,8 @@ export const URL_CONSTANTS = {
|
||||
CONTRACT_DOWNLOAD: (id: string) => `/bookings/${id}/contract`,
|
||||
CARRIAGE_ACCEPTANCE_SHEET: (id: string) =>
|
||||
`/bookings/${id}/carriage-acceptance-sheet`,
|
||||
EXPORT_HANDOVER_MODE: (id: string) =>
|
||||
`/bookings/${id}/export-handover-mode`,
|
||||
SUMMARY: (id: string) => `/bookings/${id}/summary`,
|
||||
CUSTOMER_SIGN: (id: string) => `/bookings/${id}/customer/sign`,
|
||||
MARKETING_APPROVE: (id: string) => `/bookings/${id}/marketing/approve`,
|
||||
|
||||
@@ -128,6 +128,7 @@ export const FREIGHT_PERMS = {
|
||||
invoices: {
|
||||
view: "edr_freight_app:invoices:view",
|
||||
export: "edr_freight_app:invoices:export",
|
||||
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||
// Filing with MoR EIMS. Held by named admins rather than a role preset: registration is
|
||||
// irreversible at the tax authority, and resolving clears a system-wide filing block.
|
||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Paper,
|
||||
Button,
|
||||
Box,
|
||||
SegmentedControl,
|
||||
} from "@mantine/core";
|
||||
|
||||
import { PageContainer } from "@/components/page";
|
||||
@@ -258,6 +259,44 @@ export default function BookingRequestDetailPage() {
|
||||
booking={booking}
|
||||
mutations={mutations}
|
||||
/>
|
||||
{booking.tradeDirection === "EXPORT" && (
|
||||
<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 && (
|
||||
<Button
|
||||
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;
|
||||
description?: string;
|
||||
disabled?: boolean;
|
||||
/** Editable on create, locked when editing an existing record. */
|
||||
disabledOnEdit?: boolean;
|
||||
/** Trailing unit label shown inside the input (e.g. "USD" on a rate value). */
|
||||
suffix?: string;
|
||||
/** Hide this field when another field currently equals one of these values. */
|
||||
@@ -389,7 +391,7 @@ export const RULE_ENGINE_RESOURCES: RuleEngineResourceConfig[] = [
|
||||
],
|
||||
formFields: [
|
||||
{ name: "label", label: "Label", type: "text", required: true },
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true },
|
||||
{ name: "sizeFt", label: "Size (ft)", type: "number", required: true, disabledOnEdit: true },
|
||||
// Options injected at render from useWagonTypeOptions (RuleEngineResourcePage).
|
||||
{
|
||||
name: "wagonTypeIds",
|
||||
|
||||
@@ -41,6 +41,7 @@ import type {
|
||||
Invoice,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
} from "@/types/invoice";
|
||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||
import {
|
||||
@@ -2917,6 +2918,29 @@ export const api = {
|
||||
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||
),
|
||||
|
||||
listOfflineUsd: endpoint<
|
||||
{ filter: InvoiceListFilter },
|
||||
PaginatedOfflineUsdInvoices
|
||||
>(
|
||||
"invoices",
|
||||
"listOfflineUsd",
|
||||
({ filter }) => invoicesService.listOfflineUsd(filter),
|
||||
({ filter }) => QUERY_KEYS.INVOICES.offlineUsd(filter),
|
||||
),
|
||||
|
||||
confirmOffline: endpoint<
|
||||
{ id: string; file: File; reference?: string },
|
||||
Invoice
|
||||
>(
|
||||
"invoices",
|
||||
"confirmOffline",
|
||||
({ id, file, reference }) =>
|
||||
invoicesService.confirmOffline(id, file, reference),
|
||||
undefined,
|
||||
// Settling the invoice also advances the booking, so refresh both trees.
|
||||
() => [QUERY_KEYS.INVOICES.ROOT, QUERY_KEYS.BOOKINGS.ROOT],
|
||||
),
|
||||
|
||||
eimsStatus: endpoint<{ id: string }, EimsInvoiceStatusView>(
|
||||
"invoices",
|
||||
"eimsStatus",
|
||||
|
||||
@@ -458,6 +458,13 @@ export const bookingsService = {
|
||||
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> => {
|
||||
const response = await client.get(B.CARRIAGE_ACCEPTANCE_SHEET(id), {
|
||||
responseType: "blob",
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
Invoice,
|
||||
InvoiceListFilter,
|
||||
PaginatedInvoices,
|
||||
PaginatedOfflineUsdInvoices,
|
||||
} from "@/types/invoice";
|
||||
|
||||
const cleanParams = (params: object) =>
|
||||
@@ -33,4 +34,25 @@ export const invoicesService = {
|
||||
responseType: "blob",
|
||||
});
|
||||
},
|
||||
|
||||
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
||||
listOfflineUsd(
|
||||
filter: InvoiceListFilter,
|
||||
): Promise<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;
|
||||
freightType: "CONTAINER" | "BULK";
|
||||
tradeDirection: string;
|
||||
/**
|
||||
* EXPORT only. DIRECT_TO_TRAIN = customer truck loads straight onto the wagon
|
||||
* (no warehouse, no GRN). null/WAREHOUSE = received and GRN'd first.
|
||||
*/
|
||||
exportHandoverMode?: "DIRECT_TO_TRAIN" | "WAREHOUSE" | null;
|
||||
/** What the containers carry / bulk commodity label — entered at booking time. */
|
||||
cargoFreeText?: string | null;
|
||||
cargoTotalWeightVgm: number;
|
||||
|
||||
@@ -20,3 +20,22 @@ export interface PaginatedInvoices {
|
||||
items: Invoice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
|
||||
* carry the shipment's pay-window deadline so the list can show the same
|
||||
* countdown the customer sees — Finance must confirm before it closes.
|
||||
*/
|
||||
export interface OfflineUsdInvoice extends Invoice {
|
||||
booking: {
|
||||
id: string;
|
||||
reference: string;
|
||||
paymentDeadline: string | null;
|
||||
paymentStatus: string;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface PaginatedOfflineUsdInvoices {
|
||||
items: OfflineUsdInvoice[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Freight } from "@edr/types";
|
||||
|
||||
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||
|
||||
/** A pending customer action surfaced on the home "needs attention" card. */
|
||||
export interface ActionItem {
|
||||
id: string;
|
||||
@@ -13,6 +15,8 @@ export interface ActionItem {
|
||||
targetId: string;
|
||||
/** Highlighted as action-required in the home card. */
|
||||
urgent?: boolean;
|
||||
/** USD booking: paid by bank transfer, no online payment modal. */
|
||||
offlinePay?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,13 +64,17 @@ export function deriveActionItems(
|
||||
? b.status === "FULLY_EXECUTED"
|
||||
: b.status === "SELECTED_FOR_BATCH");
|
||||
if (canPay) {
|
||||
const offlinePay = isUsdOfflineBooking(b);
|
||||
items.push({
|
||||
id: `pay-${b.id}`,
|
||||
kind: "pay",
|
||||
reference: b.reference,
|
||||
description: "Payment due for this shipment",
|
||||
description: offlinePay
|
||||
? "Payment due — pay by bank transfer and send the slip to Finance"
|
||||
: "Payment due for this shipment",
|
||||
targetId: b.id,
|
||||
urgent: true,
|
||||
offlinePay,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,10 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
||||
const handleClick = (item: ActionItem) => {
|
||||
switch (item.kind) {
|
||||
case "pay":
|
||||
setPayItem(item);
|
||||
// USD is paid by bank transfer — the booking page shows the countdown
|
||||
// and the pay-by-bank instructions instead of the payment modal.
|
||||
if (item.offlinePay) navigate(`/bookings/${item.targetId}`);
|
||||
else setPayItem(item);
|
||||
break;
|
||||
case "sign":
|
||||
navigate(`/contracts/${item.targetId}/view`);
|
||||
@@ -151,7 +154,9 @@ export function ActionNeededSection({ items: base }: ActionNeededSectionProps) {
|
||||
leftSection={<FilePlus2 size={14} />}
|
||||
>
|
||||
{item.kind === "pay"
|
||||
? "Pay now"
|
||||
? item.offlinePay
|
||||
? "Pay by bank"
|
||||
: "Pay now"
|
||||
: item.kind === "sign"
|
||||
? "Sign"
|
||||
: "Book"}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Center,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
CreditCard,
|
||||
Download,
|
||||
ExternalLink,
|
||||
Landmark,
|
||||
Receipt,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -30,6 +32,7 @@ import { invoicesService } from "@/services/invoices.service";
|
||||
import { useInvoicePayment } from "@/hooks/useInvoicePayment";
|
||||
import { warehouseInvoicesService } from "@/services/warehouse-invoices.service";
|
||||
import { PaymentMethodModal } from "@/pages/bookings/BookingDetailPage/components/PaymentMethodModal";
|
||||
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { BORDER, INK, MUTED } from "../contracts/contract-ui";
|
||||
@@ -224,7 +227,7 @@ export default function InvoiceDetailPage() {
|
||||
Receipt
|
||||
</Button>
|
||||
)}
|
||||
{payable && (
|
||||
{payable && !isUsdCurrency(invoice.currency) && (
|
||||
<Button
|
||||
color="edr-green"
|
||||
radius="md"
|
||||
@@ -239,6 +242,18 @@ export default function InvoiceDetailPage() {
|
||||
Pay {formatCurrency(amountDue, invoice.currency)}
|
||||
</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>
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ import { Freight } from "@edr/types";
|
||||
|
||||
import { api } from "@/services/api";
|
||||
import { formatCurrency } from "@/lib/currency";
|
||||
import { isUsdCurrency } from "@/pages/bookings/payments/offline-payment";
|
||||
import {
|
||||
BORDER,
|
||||
GREEN,
|
||||
@@ -276,7 +277,10 @@ export default function InvoicesList() {
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
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 (
|
||||
<Table.Tr
|
||||
key={inv.id}
|
||||
|
||||
@@ -45,6 +45,7 @@ import { StatusHero } from "./components/StatusHero";
|
||||
import { StepGhostButton, StepLine } from "./components/Steps";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
import { BodyGrid } from "./components/layout";
|
||||
import { formatAmount } from "./utils";
|
||||
|
||||
export function DraftBookingView({
|
||||
booking,
|
||||
@@ -439,7 +440,7 @@ export function DraftBookingView({
|
||||
Previous total
|
||||
</Text>
|
||||
<Text size="sm" td="line-through">
|
||||
{priceChangeModal.previousTotalAmount.toLocaleString()}{" "}
|
||||
{formatAmount(priceChangeModal.previousTotalAmount)}{" "}
|
||||
{priceChangeModal.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -447,7 +448,7 @@ export function DraftBookingView({
|
||||
<Group justify="space-between">
|
||||
<Text fw={700}>New total</Text>
|
||||
<Text fw={800} c="edr-green">
|
||||
{priceChangeModal.totalAmount.toLocaleString()}{" "}
|
||||
{formatAmount(priceChangeModal.totalAmount)}{" "}
|
||||
{priceChangeModal.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
@@ -459,7 +460,7 @@ export function DraftBookingView({
|
||||
{item.description}
|
||||
</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{item.amount.toLocaleString()} {item.currency}
|
||||
{formatAmount(item.amount)} {item.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
|
||||
@@ -53,6 +53,7 @@ import { WagonsTab } from "./components/WagonsTab";
|
||||
import { fmtDate, isNegative, priceTotal } from "./utils";
|
||||
import { useScrollToHash } from "@/hooks/useScrollToHash";
|
||||
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
|
||||
// charge). DRAFT / CHANGES_REQUESTED render their own views and drafts can
|
||||
@@ -188,7 +189,7 @@ export function ReadonlyBookingView({
|
||||
{canApproveDelivery && (
|
||||
<ApproveDeliveryButton bookingId={booking.id} />
|
||||
)}
|
||||
{canPay && !showCountdown && (
|
||||
{canPay && !showCountdown && !isUsdOfflineBooking(booking) && (
|
||||
<HeaderButton
|
||||
green
|
||||
icon={<CreditCard size={16} />}
|
||||
|
||||
@@ -17,9 +17,16 @@ import type { Freight } from "@edr/types";
|
||||
import { invoicesService, type PortalInvoice } from "@/services/invoices.service";
|
||||
import { InvoiceStatusBadge, titleCase } from "@/pages/billing/invoice-ui";
|
||||
import { paymentStatusLabel } from "@/pages/bookings/booking-display";
|
||||
import { isUsdOfflineBooking } from "@/pages/bookings/payments/offline-payment";
|
||||
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";
|
||||
|
||||
const Divider = () => <Box my={16} h={1} w="100%" bg="#EEF2F6" />;
|
||||
@@ -190,12 +197,13 @@ export function BookingPaymentPanel({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
const offlineUsd = isUsdOfflineBooking(booking);
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
const currency = pricing?.currency ?? booking.paymentCurrency;
|
||||
const total = isAdjusted
|
||||
? `${Number(booking.adjustedTotalAmount).toLocaleString()} ${currency}`
|
||||
? `${formatAmount(booking.adjustedTotalAmount)} ${currency}`
|
||||
: priceTotal(pricing);
|
||||
const items = priceLineItems(pricing);
|
||||
|
||||
@@ -268,11 +276,36 @@ export function BookingPaymentPanel({
|
||||
<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 && (
|
||||
<Box mt={16}>
|
||||
<Countdown
|
||||
deadline={booking.paymentDeadline}
|
||||
onPay={onPay}
|
||||
onPay={offlineUsd ? undefined : onPay}
|
||||
paying={paying}
|
||||
/>
|
||||
{showConsolidationNote && (
|
||||
|
||||
@@ -31,6 +31,7 @@ interface ProviderOption {
|
||||
// ponytail: ETB pays via CBE bill only for now — restore the Telebirr entry
|
||||
// ({ method: "TELEBIRR", currencies: ["ETB"] }) when mobile money returns.
|
||||
const PROVIDERS: ProviderOption[] = [
|
||||
// {
|
||||
// {
|
||||
// method: "TELEBIRR",
|
||||
// label: "telebirr",
|
||||
|
||||
@@ -29,7 +29,7 @@ import { OperationDatePicker } from "@/pages/bookings/clearance";
|
||||
import { useFeeInvoicePayment } from "@/pages/bookings/payments/useBookingPayment";
|
||||
|
||||
import type { BookingDetail } from "../booking-detail-types";
|
||||
import { fmtDate } from "../utils";
|
||||
import { fmtDate, formatAmount } from "../utils";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
|
||||
@@ -62,7 +62,7 @@ function StatusPill({ status }: { status: WagonCancellation["status"] }) {
|
||||
}
|
||||
|
||||
const fmtMoney = (amount: number | string, currency: string) =>
|
||||
`${Number(amount).toLocaleString()} ${currency}`;
|
||||
`${formatAmount(amount)} ${currency}`;
|
||||
|
||||
const apiErrorMessage = (error: unknown, fallback: string) => {
|
||||
const data = (
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type PortalWarehouseInvoice,
|
||||
} from "@/services/warehouse-invoices.service";
|
||||
import { saveBlob } from "@/utils/download";
|
||||
import { formatAmount } from "../utils";
|
||||
|
||||
import { PaymentMethodModal } from "./PaymentMethodModal";
|
||||
import { CardTitle, SectionCard } from "./layout";
|
||||
@@ -20,7 +21,7 @@ const isPayable = (inv: PortalWarehouseInvoice) =>
|
||||
PAYABLE_STATUSES.has(inv.status) && Number(inv.balanceAmount ?? 0) > 0;
|
||||
|
||||
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 }> = {
|
||||
DRAFT: { bg: "#EEF2F6", fg: "#64748B" },
|
||||
|
||||
@@ -151,15 +151,27 @@ export function bookingSubtitle(b: BookingDetail) {
|
||||
|
||||
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) {
|
||||
return (pricing?.lineItems ?? []).map((li) => ({
|
||||
label: li.description,
|
||||
value: `${li.amount.toLocaleString()} ${li.currency}`,
|
||||
value: `${formatAmount(li.amount)} ${li.currency}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export function priceTotal(pricing: Pricing) {
|
||||
if (!pricing) return "—";
|
||||
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>
|
||||
|
||||
<PaymentCurrencyField control={form.control} />
|
||||
{/* USD billing is import-only; export/intercity stay ETB. */}
|
||||
<PaymentCurrencyField
|
||||
control={form.control}
|
||||
allowUsd={operationType === "import"}
|
||||
/>
|
||||
|
||||
{(selectedService?.includesFirstMile ||
|
||||
selectedService?.includesLastMile ||
|
||||
|
||||
@@ -19,14 +19,25 @@ const CURRENCY_ICONS: Record<
|
||||
|
||||
export function PaymentCurrencyField({
|
||||
control,
|
||||
allowUsd = false,
|
||||
}: {
|
||||
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 (
|
||||
<Box mt={24}>
|
||||
<StepLabel>Payment currency</StepLabel>
|
||||
<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>
|
||||
<Controller
|
||||
name="paymentCurrency"
|
||||
@@ -44,7 +55,7 @@ export function PaymentCurrencyField({
|
||||
border: "1px solid #E6ECF2",
|
||||
}}
|
||||
>
|
||||
{PAYMENT_CURRENCY_OPTIONS.map((option) => {
|
||||
{options.map((option) => {
|
||||
const Icon = CURRENCY_ICONS[option.value].icon;
|
||||
const selected = field.value === option.value;
|
||||
return (
|
||||
@@ -97,10 +108,7 @@ export function PaymentCurrencyField({
|
||||
</Group>
|
||||
{/* Description for the active currency, kept subtle. */}
|
||||
<Text fz={11.5} c="#6B7C8E" mt={8}>
|
||||
{
|
||||
PAYMENT_CURRENCY_OPTIONS.find((o) => o.value === field.value)
|
||||
?.description
|
||||
}
|
||||
{options.find((o) => o.value === field.value)?.description}
|
||||
</Text>
|
||||
<OptionFieldError error={fieldState.error} />
|
||||
</div>
|
||||
|
||||
@@ -81,11 +81,16 @@ export const PAYMENT_CURRENCY_OPTIONS: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
}> = [
|
||||
// ponytail: ETB-only for now — re-add the USD option when multi-currency billing returns.
|
||||
{
|
||||
value: "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 && (
|
||||
<Stack gap={12} mt={24}>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Button, type ButtonProps } from "@mantine/core";
|
||||
import { CreditCard } from "lucide-react";
|
||||
import { Badge, Button, type ButtonProps } from "@mantine/core";
|
||||
import { CreditCard, Landmark } from "lucide-react";
|
||||
|
||||
import { Freight } from "@edr/types";
|
||||
|
||||
import { ModalSafeWrapper } from "@/components/customer-actions/ModalSafeWrapper";
|
||||
import { PaymentMethodModal } from "../BookingDetailPage/components/PaymentMethodModal";
|
||||
import { priceTotal } from "../BookingDetailPage/utils";
|
||||
import { isUsdOfflineBooking } from "./offline-payment";
|
||||
import { useBookingPayment } from "./useBookingPayment";
|
||||
|
||||
interface PayNowButtonProps {
|
||||
@@ -29,6 +30,23 @@ export function PayNowButton({
|
||||
const pay = useBookingPayment(booking.id);
|
||||
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 (
|
||||
<ModalSafeWrapper>
|
||||
<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";
|
||||
import { InitiateBookingButton } from "@/components/customer-actions/ContractCustomerAction";
|
||||
import { formatRateUnit } from "./new-contract-form/unit-rates";
|
||||
import { formatAmount } from "./new-shipment-form/total";
|
||||
import { bookingDocNoun } from "@/pages/bookings/clearance/bookingNextAction";
|
||||
import { getContractBookingAction } from "./contract-booking-action";
|
||||
import { closedWindowMessage, hasOpenWindow } from "./booking-window";
|
||||
@@ -719,7 +720,7 @@ export default function ContractDetailPage() {
|
||||
)}
|
||||
</Box>
|
||||
<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">
|
||||
/ {formatRateUnit(item.unit)}
|
||||
</Text>
|
||||
@@ -1336,9 +1337,7 @@ export default function ContractDetailPage() {
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{amount > 0
|
||||
? `ETB ${amount.toLocaleString()}`
|
||||
: "—"}
|
||||
{amount > 0 ? `ETB ${formatAmount(amount)}` : "—"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -75,7 +75,7 @@ import {
|
||||
createShipmentFormSchema,
|
||||
initialShipmentFormValues,
|
||||
} from "./new-shipment-form/schema";
|
||||
import { computeShipmentTotal } from "./new-shipment-form/total";
|
||||
import { computeShipmentTotal, formatAmount } from "./new-shipment-form/total";
|
||||
import {
|
||||
downloadContainerImportTemplate,
|
||||
parseContainerExcel,
|
||||
@@ -1014,7 +1014,7 @@ function PriceConfirmModal({
|
||||
))}
|
||||
<Text fz="xs" c="#9A5B00" mt={2}>
|
||||
{overweightSurchargeAmount > 0
|
||||
? `An overweight surcharge of ${overweightSurchargeAmount.toLocaleString()} ${
|
||||
? `An overweight surcharge of ${formatAmount(overweightSurchargeAmount)} ${
|
||||
validation?.currency ?? total?.currency ?? ""
|
||||
} 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."}
|
||||
@@ -1038,7 +1038,7 @@ function PriceConfirmModal({
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{line.quantity.toLocaleString()} ×{" "}
|
||||
{line.unitPrice.toLocaleString()} {total.currency} ·{" "}
|
||||
{formatAmount(line.unitPrice)} {total.currency} ·{" "}
|
||||
{formatRateUnit(line.unit)}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -1048,7 +1048,7 @@ function PriceConfirmModal({
|
||||
c="#10202F"
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
>
|
||||
{line.amount.toLocaleString()} {total.currency}
|
||||
{formatAmount(line.amount)} {total.currency}
|
||||
</Text>
|
||||
</Group>
|
||||
))}
|
||||
@@ -1070,7 +1070,7 @@ function PriceConfirmModal({
|
||||
Total
|
||||
</Text>
|
||||
<Text fw={800} fz={28} c="#10202F">
|
||||
{total.total.toLocaleString()}{" "}
|
||||
{formatAmount(total.total)}{" "}
|
||||
<Text span fz={16} fw={700} c="edr-muted">
|
||||
{total.currency}
|
||||
</Text>
|
||||
@@ -1215,6 +1215,9 @@ function ScheduleStep({
|
||||
})();
|
||||
|
||||
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({
|
||||
...api.bookings.getAvailableDaysForCargo.queryOptions({
|
||||
input:
|
||||
@@ -1310,12 +1313,15 @@ function ScheduleStep({
|
||||
<Box mb="lg">
|
||||
<StepLabel>Billing currency *</StepLabel>
|
||||
<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>
|
||||
<CurrencySelector
|
||||
value={field.value || ""}
|
||||
onChange={(v) => field.onChange(v)}
|
||||
error={fieldState.error?.message}
|
||||
allowUsd={isImport}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,19 @@ export interface ShipmentTotal {
|
||||
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
|
||||
* figures — the item count prices the booking, the tonnage sizes the wagons —
|
||||
|
||||
@@ -89,7 +89,6 @@ export default function TabCompanyProfile({
|
||||
profile,
|
||||
mode = "edit",
|
||||
onCreateSuccess,
|
||||
user,
|
||||
}: TabCompanyProfileProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const isCreate = mode === "create";
|
||||
|
||||
@@ -8,17 +8,27 @@ export interface CurrencySelectorProps {
|
||||
disabled?: boolean;
|
||||
/** Validation error shown under the cards. */
|
||||
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 OPTIONS = [
|
||||
{
|
||||
const ETB_OPTION = {
|
||||
code: "ETB",
|
||||
symbol: "Br",
|
||||
name: "Ethiopian Birr",
|
||||
hint: "All shipments are invoiced in ETB",
|
||||
},
|
||||
] as const;
|
||||
hint: "Pay online through the payment gateway",
|
||||
} as const;
|
||||
|
||||
const USD_OPTION = {
|
||||
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`
|
||||
@@ -29,7 +39,9 @@ export function CurrencySelector({
|
||||
onChange,
|
||||
disabled = false,
|
||||
error,
|
||||
allowUsd = false,
|
||||
}: CurrencySelectorProps) {
|
||||
const options = allowUsd ? [ETB_OPTION, USD_OPTION] : [ETB_OPTION];
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
@@ -39,7 +51,7 @@ export function CurrencySelector({
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
{OPTIONS.map((o) => {
|
||||
{options.map((o) => {
|
||||
const selected = value === o.code;
|
||||
return (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user