mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
@@ -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 };
|
||||
};
|
||||
@@ -703,6 +711,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
||||
payment as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo };
|
||||
};
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 = {
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { SCHEDULE_BOOKINGS_CTE } from '../../common/schedule-bookings.sql';
|
||||
import {
|
||||
DataSource,
|
||||
EntityManager,
|
||||
@@ -37,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,
|
||||
@@ -110,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,
|
||||
@@ -130,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,
|
||||
@@ -145,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,
|
||||
@@ -154,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,
|
||||
@@ -163,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;
|
||||
|
||||
@@ -2474,63 +2473,6 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* EXPORT ONLY. An export train must not leave carrying nothing while its cargo
|
||||
* sits in the shed: the goods are received into the origin warehouse, GRN'd and
|
||||
* loaded onto the wagons allocated to the booking, so anything still in the
|
||||
* warehouse at dispatch is being left behind. Blocks dispatch when an allocated
|
||||
* booking has warehouse inventory that never made it onto a wagon (received /
|
||||
* stored / ready but not LOADED) — either load it from the Load-to-Train queue,
|
||||
* or drop the booking's wagon allocation so it rides a later train.
|
||||
*
|
||||
* Import/domestic are untouched: their cargo isn't loaded out of an origin
|
||||
* warehouse, so warehouse inventory says nothing about what's aboard.
|
||||
*
|
||||
* Bookings with no warehouse inventory at all are NOT blocked — allocating a
|
||||
* wagon before the goods arrive is normal planning; they simply aren't aboard.
|
||||
*/
|
||||
private async assertAllocatedCargoLoaded(scheduleId: string): Promise<void> {
|
||||
const [route]: Array<{ originCountry: string | null; destinationCountry: string | null }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.id = $1 AND ts.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (!route) return;
|
||||
const direction = deriveTradeDirection(
|
||||
{ country: route.originCountry },
|
||||
{ country: route.destinationCountry },
|
||||
);
|
||||
if (direction !== 'EXPORT') return;
|
||||
|
||||
// Only bookings boarding at the schedule's ORIGIN station gate dispatch —
|
||||
// a mid-corridor boarder (origin B on an A→B→C→D run) is loaded when the
|
||||
// train reaches its yard, so its warehouse state says nothing at departure.
|
||||
const rows: Array<{ reference: string | null; status: string }> = await this.dataSource.query(
|
||||
`WITH ${SCHEDULE_BOOKINGS_CTE}
|
||||
SELECT DISTINCT b.reference AS "reference", inv.status AS "status"
|
||||
FROM sched_bookings sb
|
||||
JOIN freight.bookings b ON b.id = sb.booking_id AND b.deleted_at IS NULL
|
||||
JOIN freight.train_schedules ts ON ts.id = sb.schedule_id
|
||||
JOIN freight.warehouse_inventory inv
|
||||
ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE sb.schedule_id = $1
|
||||
AND b.origin_yard_id = ts.origin_station_id
|
||||
AND inv.status IN ('RECEIVED', 'STORED', 'READY_FOR_LOADING')`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const refs = [...new Set(rows.map((r) => r.reference ?? '?'))].join(', ');
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: cargo for booking(s) ${refs} is in the warehouse but not loaded onto a wagon. ` +
|
||||
`Load it from the warehouse Load-to-Train queue, or remove the booking's wagon allocation so it travels on a later train.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
@@ -2540,8 +2482,8 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
// Export only: don't leave received cargo behind in the warehouse.
|
||||
await this.assertAllocatedCargoLoaded(scheduleId);
|
||||
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
|
||||
// never blocks departure — the dispatch confirm dialog warns and staff decide.
|
||||
// A locomotive may sit on many future schedules, but it can only pull one train
|
||||
// at a time — block dispatch while any set locomotive is out on a dispatched train.
|
||||
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
|
||||
@@ -5767,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 ?? [])
|
||||
@@ -5809,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,
|
||||
@@ -6965,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: {
|
||||
@@ -7555,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`,
|
||||
);
|
||||
|
||||
@@ -1697,7 +1700,6 @@ export class WarehouseInventoryService {
|
||||
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
||||
private async exportInventoryByStatus(
|
||||
status: WarehouseInventoryStatus,
|
||||
requireInspectionPassed = false,
|
||||
): Promise<ReadyToLoadRow[]> {
|
||||
const rows: Array<
|
||||
ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
@@ -1726,7 +1728,6 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.status = $1
|
||||
${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
|
||||
ORDER BY inv.created_at DESC`,
|
||||
[status],
|
||||
);
|
||||
@@ -1739,9 +1740,13 @@ export class WarehouseInventoryService {
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
|
||||
}
|
||||
|
||||
/** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */
|
||||
/**
|
||||
* EXPORT inventory waiting to be loaded (READY_FOR_LOADING). Inspection state
|
||||
* rides along on each row for the UI to show, but does not filter the queue —
|
||||
* uninspected cargo must still be loadable.
|
||||
*/
|
||||
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING');
|
||||
}
|
||||
|
||||
/** EXPORT inventory received at the facility and awaiting inspection. */
|
||||
@@ -3130,9 +3135,8 @@ export class WarehouseInventoryService {
|
||||
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||||
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||||
}
|
||||
if (item.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
|
||||
}
|
||||
// Inspection is tracked, not enforced — uninspected cargo may still be
|
||||
// marked ready and loaded so a train is never held for paperwork.
|
||||
return this.transition(id, 'READY_FOR_LOADING', {
|
||||
timestampField: 'readyForLoadingAt',
|
||||
activityType: 'READY_FOR_LOADING',
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,15 +30,17 @@ import { useToast } from "@/hooks/use-toast";
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
|
||||
import { vehiclesService } from "@/services/vehicles.service";
|
||||
import { gpsTrackingService, type GpsDevice } from "@/services/gps-tracking.service";
|
||||
import {
|
||||
gpsTrackingService,
|
||||
type GpsDevice,
|
||||
} from "@/services/gps-tracking.service";
|
||||
import { freightBrand } from "@/theme/freight-brand";
|
||||
|
||||
// Same default key + env override the portal's LocationPicker uses.
|
||||
// NOTE: fallback key is EXPIRED (ExpiredKeyMapError) — set
|
||||
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key.
|
||||
const GOOGLE_MAPS_API_KEY =
|
||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
||||
// Maps JavaScript API keys are public client-side keys — lock them down by
|
||||
// HTTP-referrer in the Google Cloud console. No fallback: a hardcoded default
|
||||
// used to live here and expired, turning a missing env var into a blank map
|
||||
// that read as broken GPS rather than absent configuration.
|
||||
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 }; // Addis Ababa
|
||||
|
||||
const toNum = (v: number | string | null | undefined): number | null =>
|
||||
@@ -57,8 +59,12 @@ const fmtTime = (iso?: string | null) => {
|
||||
|
||||
const StatBox = ({ label, value }: { label: string; value: string }) => (
|
||||
<Box p="sm" style={{ backgroundColor: "#f8f9fa", borderRadius: 8 }}>
|
||||
<Text size="xs" c="dimmed">{label}</Text>
|
||||
<Text fw={600} size="sm">{value}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{label}
|
||||
</Text>
|
||||
<Text fw={600} size="sm">
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -97,11 +103,20 @@ function useAddress(lat: number, lng: number): string | null {
|
||||
if (typeof google === "undefined" || !google.maps?.Geocoder) return;
|
||||
setAddr(null);
|
||||
let cancelled = false;
|
||||
new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => {
|
||||
if (cancelled) return;
|
||||
setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location");
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
new google.maps.Geocoder().geocode(
|
||||
{ location: { lat, lng } },
|
||||
(res, status) => {
|
||||
if (cancelled) return;
|
||||
setAddr(
|
||||
status === "OK" && res?.[0]
|
||||
? res[0].formatted_address
|
||||
: "Unknown location",
|
||||
);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [lat, lng]);
|
||||
return addr;
|
||||
}
|
||||
@@ -120,16 +135,24 @@ function HoverInfo({
|
||||
}) {
|
||||
const address = useAddress(lat, lng);
|
||||
return (
|
||||
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
|
||||
<InfoWindow
|
||||
position={{ lat, lng }}
|
||||
pixelOffset={[0, -46]}
|
||||
onCloseClick={onClose}
|
||||
>
|
||||
<div style={{ minWidth: 190, fontSize: 13 }}>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>{deviceLabel(device)}</div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 2 }}>
|
||||
{deviceLabel(device)}
|
||||
</div>
|
||||
<div style={{ fontFamily: "monospace" }}>
|
||||
{lat.toFixed(5)}, {lng.toFixed(5)}
|
||||
</div>
|
||||
<div style={{ color: "#555" }}>
|
||||
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
|
||||
</div>
|
||||
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
|
||||
<div style={{ color: "#777", marginTop: 4 }}>
|
||||
{address ?? "Locating…"}
|
||||
</div>
|
||||
</div>
|
||||
</InfoWindow>
|
||||
);
|
||||
@@ -184,7 +207,8 @@ export function TrackingPage() {
|
||||
|
||||
const { data: vehiclesData } = useQuery({
|
||||
queryKey: ["vehicles", "all"],
|
||||
queryFn: async () => (await vehiclesService.getAll({ limit: 1000 })).data ?? [],
|
||||
queryFn: async () =>
|
||||
(await vehiclesService.getAll({ limit: 1000 })).data ?? [],
|
||||
});
|
||||
const vehicleOptions = useMemo(
|
||||
() =>
|
||||
@@ -199,7 +223,10 @@ export function TrackingPage() {
|
||||
() =>
|
||||
devices
|
||||
.map((d) => ({ d, lat: toNum(d.lastLat), lng: toNum(d.lastLng) }))
|
||||
.filter((x): x is { d: GpsDevice; lat: number; lng: number } => x.lat != null && x.lng != null),
|
||||
.filter(
|
||||
(x): x is { d: GpsDevice; lat: number; lng: number } =>
|
||||
x.lat != null && x.lng != null,
|
||||
),
|
||||
[devices],
|
||||
);
|
||||
|
||||
@@ -209,19 +236,31 @@ export function TrackingPage() {
|
||||
// Route history for the selected device's vehicle (chronological trail).
|
||||
const { data: history = [] } = useQuery({
|
||||
queryKey: ["gps", "history", selected?.vehicleId],
|
||||
queryFn: async () => (await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
|
||||
queryFn: async () =>
|
||||
(await gpsTrackingService.history(selected!.vehicleId!, 300)).data ?? [],
|
||||
enabled: Boolean(selected?.vehicleId),
|
||||
});
|
||||
const trail = useMemo(
|
||||
() => [...history].reverse().map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
|
||||
() =>
|
||||
[...history]
|
||||
.reverse()
|
||||
.map((h) => ({ lat: Number(h.lat), lng: Number(h.lng) })),
|
||||
[history],
|
||||
);
|
||||
|
||||
// Teardrop pin colored by state with a white truck glyph inside.
|
||||
const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => {
|
||||
const markerIcon = (
|
||||
d: GpsDevice,
|
||||
selectedFlag: boolean,
|
||||
): google.maps.Icon | undefined => {
|
||||
// Maps API loads async — Size/Point classes may not exist yet at first render.
|
||||
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady) return undefined;
|
||||
const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6";
|
||||
if (typeof google === "undefined" || !google.maps?.Size || !mapsReady)
|
||||
return undefined;
|
||||
const color = selectedFlag
|
||||
? freightBrand.primary
|
||||
: d.online
|
||||
? "#2f80ed"
|
||||
: "#95a5a6";
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48">
|
||||
<path d="M20 2C10 2 2 10 2 20c0 12 18 26 18 26s18-14 18-26C38 10 30 2 20 2Z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
|
||||
<g transform="translate(8,7)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -260,8 +299,13 @@ export function TrackingPage() {
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const description =
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data?.message ?? "Failed";
|
||||
toast({ title: editDevice ? "Update failed" : "Registration failed", description, variant: "destructive" });
|
||||
(err as { response?: { data?: { message?: string } } })?.response?.data
|
||||
?.message ?? "Failed";
|
||||
toast({
|
||||
title: editDevice ? "Update failed" : "Registration failed",
|
||||
description,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -287,15 +331,25 @@ export function TrackingPage() {
|
||||
|
||||
return (
|
||||
<Container size="xl" py="xl" px="lg">
|
||||
<Breadcrumbs items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]} />
|
||||
<Breadcrumbs
|
||||
items={[{ label: "Fleet" }, { label: "Vehicle Tracking" }]}
|
||||
/>
|
||||
|
||||
<Group justify="space-between" mb="xl">
|
||||
<div>
|
||||
<Text fw={700} size="xl">Real-Time Vehicle Tracking</Text>
|
||||
<Text c="dimmed" size="sm">Live GPS positions from GT06 trackers</Text>
|
||||
<Text fw={700} size="xl">
|
||||
Real-Time Vehicle Tracking
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
Live GPS positions from GT06 trackers
|
||||
</Text>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button leftSection={<Plus size={16} />} color="edr-green" onClick={openRegister}>
|
||||
<Button
|
||||
leftSection={<Plus size={16} />}
|
||||
color="edr-green"
|
||||
onClick={openRegister}
|
||||
>
|
||||
Register tracker
|
||||
</Button>
|
||||
)}
|
||||
@@ -314,36 +368,82 @@ export function TrackingPage() {
|
||||
</Group>
|
||||
</Card.Section>
|
||||
<Card.Section p="md">
|
||||
<Box style={{ height: 500, width: "100%", borderRadius: 8, overflow: "hidden" }}>
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={DEFAULT_CENTER}
|
||||
defaultZoom={7}
|
||||
gestureHandling="greedy"
|
||||
disableDefaultUI={false}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
<Box
|
||||
style={{
|
||||
height: 500,
|
||||
width: "100%",
|
||||
borderRadius: 8,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{!GOOGLE_MAPS_API_KEY ? (
|
||||
// Name the missing variable rather than showing an empty map: the
|
||||
// device list beside this still works, so a blank panel reads as
|
||||
// "no GPS fixes" instead of "no map key".
|
||||
<Box
|
||||
style={{
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
textAlign: "center",
|
||||
border: "1px solid #F0D2A8",
|
||||
borderRadius: 8,
|
||||
background: "#FFF9F0",
|
||||
}}
|
||||
>
|
||||
<ReadyProbe onReady={() => setMapsReady(true)} />
|
||||
{positioned.map(({ d, lat, lng }) => (
|
||||
<Marker
|
||||
key={d.id}
|
||||
position={{ lat, lng }}
|
||||
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
|
||||
icon={markerIcon(d, d.id === selectedId)}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
onMouseOver={() => setHoverId(d.id)}
|
||||
<Text fz="sm" c="#8A5A16">
|
||||
Map unavailable — <code>VITE_GOOGLE_MAPS_API_KEY</code> is
|
||||
not set. Add a Google Maps key with the Maps JavaScript
|
||||
API and Places API enabled to this app's{" "}
|
||||
<code>.env</code>, then restart the dev server. Device
|
||||
positions below are unaffected.
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
<GoogleMap
|
||||
defaultCenter={DEFAULT_CENTER}
|
||||
defaultZoom={7}
|
||||
gestureHandling="greedy"
|
||||
disableDefaultUI={false}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<ReadyProbe onReady={() => setMapsReady(true)} />
|
||||
{positioned.map(({ d, lat, lng }) => (
|
||||
<Marker
|
||||
key={d.id}
|
||||
position={{ lat, lng }}
|
||||
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
|
||||
icon={markerIcon(d, d.id === selectedId)}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
onMouseOver={() => setHoverId(d.id)}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const h = positioned.find((p) => p.d.id === hoverId);
|
||||
return h ? (
|
||||
<HoverInfo
|
||||
device={h.d}
|
||||
lat={h.lat}
|
||||
lng={h.lng}
|
||||
onClose={() => setHoverId(null)}
|
||||
/>
|
||||
) : null;
|
||||
})()}
|
||||
<FitBounds
|
||||
points={positioned.map((p) => ({
|
||||
lat: p.lat,
|
||||
lng: p.lng,
|
||||
}))}
|
||||
/>
|
||||
))}
|
||||
{(() => {
|
||||
const h = positioned.find((p) => p.d.id === hoverId);
|
||||
return h ? (
|
||||
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
|
||||
) : null;
|
||||
})()}
|
||||
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
|
||||
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
{selected?.vehicleId && trail.length > 1 && (
|
||||
<RouteTrail path={trail} />
|
||||
)}
|
||||
</GoogleMap>
|
||||
</APIProvider>
|
||||
)}
|
||||
</Box>
|
||||
{positioned.length === 0 && (
|
||||
<Text size="sm" c="dimmed" ta="center" mt="sm">
|
||||
@@ -363,11 +463,19 @@ export function TrackingPage() {
|
||||
<Group justify="space-between">
|
||||
<Text fw={500}>{deviceLabel(selected)}</Text>
|
||||
<Group gap="xs">
|
||||
<Badge color={selected.online ? "edr-green" : "gray"} leftSection={<Activity size={12} />}>
|
||||
<Badge
|
||||
color={selected.online ? "edr-green" : "gray"}
|
||||
leftSection={<Activity size={12} />}
|
||||
>
|
||||
{selected.online ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
{canManage && (
|
||||
<ActionIcon variant="subtle" color="red" aria-label="Remove tracker" onClick={() => deleteMutation.mutate(selected.id)}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
aria-label="Remove tracker"
|
||||
onClick={() => deleteMutation.mutate(selected.id)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
@@ -375,21 +483,55 @@ export function TrackingPage() {
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<StatBox label="Latitude" value={toNum(selected.lastLat)?.toFixed(5) ?? "—"} />
|
||||
<StatBox label="Longitude" value={toNum(selected.lastLng)?.toFixed(5) ?? "—"} />
|
||||
<StatBox label="Speed" value={`${toNum(selected.lastSpeed) ?? 0} km/h`} />
|
||||
<StatBox label="Course" value={`${selected.lastCourse ?? 0}°`} />
|
||||
<StatBox label="Voltage" value={selected.voltageLevel != null ? `${selected.voltageLevel}/6` : "—"} />
|
||||
<StatBox label="GSM" value={selected.gsmLevel != null ? `${selected.gsmLevel}/4` : "—"} />
|
||||
<StatBox
|
||||
label="Latitude"
|
||||
value={toNum(selected.lastLat)?.toFixed(5) ?? "—"}
|
||||
/>
|
||||
<StatBox
|
||||
label="Longitude"
|
||||
value={toNum(selected.lastLng)?.toFixed(5) ?? "—"}
|
||||
/>
|
||||
<StatBox
|
||||
label="Speed"
|
||||
value={`${toNum(selected.lastSpeed) ?? 0} km/h`}
|
||||
/>
|
||||
<StatBox
|
||||
label="Course"
|
||||
value={`${selected.lastCourse ?? 0}°`}
|
||||
/>
|
||||
<StatBox
|
||||
label="Voltage"
|
||||
value={
|
||||
selected.voltageLevel != null
|
||||
? `${selected.voltageLevel}/6`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<StatBox
|
||||
label="GSM"
|
||||
value={
|
||||
selected.gsmLevel != null
|
||||
? `${selected.gsmLevel}/4`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">IMEI</Text>
|
||||
<Text fw={500} size="sm">{selected.imei}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
IMEI
|
||||
</Text>
|
||||
<Text fw={500} size="sm">
|
||||
{selected.imei}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed">Last fix</Text>
|
||||
<Text fw={500} size="sm">{fmtTime(selected.lastFixAt)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Last fix
|
||||
</Text>
|
||||
<Text fw={500} size="sm">
|
||||
{fmtTime(selected.lastFixAt)}
|
||||
</Text>
|
||||
</div>
|
||||
{selected.vehicleId && (
|
||||
<Text size="xs" c="dimmed">
|
||||
@@ -402,7 +544,9 @@ export function TrackingPage() {
|
||||
placeholder="Unassigned"
|
||||
data={vehicleOptions}
|
||||
value={selected.vehicleId ?? null}
|
||||
onChange={(v) => assignMutation.mutate({ id: selected.id, vehicleId: v })}
|
||||
onChange={(v) =>
|
||||
assignMutation.mutate({ id: selected.id, vehicleId: v })
|
||||
}
|
||||
disabled={!canManage}
|
||||
searchable
|
||||
clearable
|
||||
@@ -420,24 +564,43 @@ export function TrackingPage() {
|
||||
{devices.map((d) => (
|
||||
<Table.Tr
|
||||
key={d.id}
|
||||
style={{ cursor: "pointer", backgroundColor: d.id === selectedId ? freightBrand.mutedBg : "transparent" }}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
backgroundColor:
|
||||
d.id === selectedId
|
||||
? freightBrand.mutedBg
|
||||
: "transparent",
|
||||
}}
|
||||
onClick={() => setSelectedId(d.id)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Stack gap={0}>
|
||||
<Text size="sm" fw={600}>{deviceLabel(d)}</Text>
|
||||
<Text size="xs" c="dimmed">{toNum(d.lastSpeed) ?? 0} km/h · {fmtTime(d.lastFixAt)}</Text>
|
||||
<Text size="sm" fw={600}>
|
||||
{deviceLabel(d)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{toNum(d.lastSpeed) ?? 0} km/h ·{" "}
|
||||
{fmtTime(d.lastFixAt)}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap={6} justify="flex-end" wrap="nowrap">
|
||||
<Badge color={d.online ? "edr-green" : "gray"} size="sm">{d.online ? "Live" : "Offline"}</Badge>
|
||||
<Badge
|
||||
color={d.online ? "edr-green" : "gray"}
|
||||
size="sm"
|
||||
>
|
||||
{d.online ? "Live" : "Offline"}
|
||||
</Badge>
|
||||
{canManage && (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
aria-label="Edit tracker"
|
||||
onClick={(e) => { e.stopPropagation(); openEdit(d); }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openEdit(d);
|
||||
}}
|
||||
>
|
||||
<Pencil size={15} />
|
||||
</ActionIcon>
|
||||
@@ -449,7 +612,9 @@ export function TrackingPage() {
|
||||
{devices.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={2}>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">No trackers registered yet.</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" py="md">
|
||||
No trackers registered yet.
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
@@ -495,7 +660,9 @@ export function TrackingPage() {
|
||||
clearable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>Cancel</Button>
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
loading={saveMutation.isPending}
|
||||
disabled={!form.imei.trim()}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -85,14 +85,15 @@ export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryA
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return 'store';
|
||||
case 'STORED':
|
||||
// Reserve is retired: a stored export item goes straight to loading prep
|
||||
// once inspection passes. An import item parked back into storage returns
|
||||
// to pickup — otherwise Store would strand it with no action.
|
||||
// Reserve is retired: a stored export item goes straight to loading prep.
|
||||
// An import item parked back into storage returns to pickup — otherwise
|
||||
// Store would strand it with no action.
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
return 'ready-for-loading';
|
||||
case 'RESERVED':
|
||||
// Export loading is gated on a passed inspection.
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
// Export loading is not gated on inspection — inspection is tracked, but a
|
||||
// train is never held waiting for it.
|
||||
return 'ready-for-loading';
|
||||
case 'READY_FOR_PICKUP':
|
||||
// Issue the DO / release order first, then hand over the goods.
|
||||
return item.releaseDate ? 'deliver' : 'release';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,6 +17,7 @@ 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";
|
||||
@@ -190,6 +191,7 @@ export function BookingPaymentPanel({
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const paid = booking.paymentStatus === "PAID";
|
||||
const offlineUsd = isUsdOfflineBooking(booking);
|
||||
const isAdjusted =
|
||||
booking.adjustedTotalAmount !== null &&
|
||||
booking.adjustedTotalAmount !== undefined;
|
||||
@@ -268,11 +270,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",
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -45,17 +45,16 @@ interface PlacePrediction {
|
||||
}
|
||||
|
||||
// Maps JavaScript API keys are public client-side keys (lock them down by
|
||||
// HTTP-referrer in the Google Cloud console). The env var lets deployments
|
||||
// override the default key without a code change.
|
||||
// HTTP-referrer in the Google Cloud console), so this one env var is the whole
|
||||
// configuration. Requires BOTH "Maps JavaScript API" (tiles) and "Places API"
|
||||
// (the address search) enabled on the key, or the map draws and the search box
|
||||
// silently returns nothing.
|
||||
//
|
||||
// NOTE: the fallback key below is EXPIRED (confirmed via live request —
|
||||
// "Google Maps JavaScript API error: ExpiredKeyMapError"), which renders
|
||||
// this picker's map blank while the search box spins forever. Set
|
||||
// VITE_GOOGLE_MAPS_API_KEY (see .env.example) to a live key to fix it; don't
|
||||
// rely on this default.
|
||||
const GOOGLE_MAPS_API_KEY =
|
||||
import.meta.env.VITE_GOOGLE_MAPS_API_KEY ||
|
||||
"AIzaSyBg4tN31-fgvH_2Ix_TPo6VSfOA2uA5CCI";
|
||||
// There is deliberately no fallback key. A hardcoded default used to live here
|
||||
// and expired, which degraded a missing env var into a blank map with a search
|
||||
// box that spun forever — indistinguishable from a broken picker. Absent config
|
||||
// now says so on screen instead.
|
||||
const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY?.trim();
|
||||
|
||||
// Centre of the EDR corridor (Addis Ababa) — a sensible default view.
|
||||
const DEFAULT_CENTER = { lat: 9.03, lng: 38.74 };
|
||||
@@ -197,10 +196,7 @@ async function resolvePrediction(
|
||||
},
|
||||
(place, status) => {
|
||||
const loc = place?.geometry?.location;
|
||||
if (
|
||||
status !== google.maps.places.PlacesServiceStatus.OK ||
|
||||
!loc
|
||||
) {
|
||||
if (status !== google.maps.places.PlacesServiceStatus.OK || !loc) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
@@ -315,6 +311,7 @@ export function LocationPicker(props: LocationPickerProps) {
|
||||
lat: toFiniteNumber(props.value.lat),
|
||||
lng: toFiniteNumber(props.value.lng),
|
||||
};
|
||||
if (!GOOGLE_MAPS_API_KEY) return <MapUnavailable label={props.label} />;
|
||||
return (
|
||||
<APIProvider apiKey={GOOGLE_MAPS_API_KEY}>
|
||||
{props.variant === "modal" ? (
|
||||
@@ -326,6 +323,42 @@ export function LocationPicker(props: LocationPickerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the picker when the Maps key is absent. Says which variable is
|
||||
* missing rather than rendering an empty map that reads as a broken feature —
|
||||
* the failure this replaced took a live API request to diagnose.
|
||||
*/
|
||||
function MapUnavailable({ label }: { label?: string }) {
|
||||
return (
|
||||
<Box>
|
||||
{label && (
|
||||
<Text fz={13} fw={600} c="#10202F" mb={6}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<Box
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
borderRadius: 12,
|
||||
padding: "12px 14px",
|
||||
border: "1px solid #F0D2A8",
|
||||
background: "#FFF9F0",
|
||||
}}
|
||||
>
|
||||
<MapPin size={16} color="#B45309" style={{ flexShrink: 0 }} />
|
||||
<Text fz={12.5} c="#8A5A16">
|
||||
Map unavailable — <code>VITE_GOOGLE_MAPS_API_KEY</code> is not set.
|
||||
Add a Google Maps key with the Maps JavaScript API and Places API
|
||||
enabled to this app's
|
||||
<code> .env</code>, then restart the dev server.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact trigger + modal wrapper around the inline picker. */
|
||||
function LocationPickerModal({
|
||||
value,
|
||||
@@ -374,7 +407,12 @@ function LocationPickerModal({
|
||||
>
|
||||
<MapPin size={16} />
|
||||
</Box>
|
||||
<Text fz={13.5} c={hasPin ? "#10202F" : "#94A3B8"} lineClamp={1} style={{ flex: 1 }}>
|
||||
<Text
|
||||
fz={13.5}
|
||||
c={hasPin ? "#10202F" : "#94A3B8"}
|
||||
lineClamp={1}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{hasPin ? value.address || "Pinned location" : placeholder}
|
||||
</Text>
|
||||
<Text fz={12.5} fw={600} c="#0A6F4D" style={{ flexShrink: 0 }}>
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
code: "ETB",
|
||||
symbol: "Br",
|
||||
name: "Ethiopian Birr",
|
||||
hint: "All shipments are invoiced in ETB",
|
||||
},
|
||||
] as const;
|
||||
const ETB_OPTION = {
|
||||
code: "ETB",
|
||||
symbol: "Br",
|
||||
name: "Ethiopian Birr",
|
||||
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