mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
feat(billing): USD offline bank-transfer payments
This commit is contained in:
@@ -1,19 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Res,
|
Res,
|
||||||
|
UploadedFile,
|
||||||
|
UseInterceptors,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
import { FileInterceptor } from "@nestjs/platform-express";
|
||||||
|
import {
|
||||||
|
ApiBearerAuth,
|
||||||
|
ApiConsumes,
|
||||||
|
ApiOperation,
|
||||||
|
ApiTags,
|
||||||
|
} from "@nestjs/swagger";
|
||||||
import type { Response } from "express";
|
import type { Response } from "express";
|
||||||
|
|
||||||
import { CurrentUser } from "@edr/api-common";
|
import { CurrentUser } from "@edr/api-common";
|
||||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||||
|
|
||||||
import { BookingStaff } from "../../common/booking-guards";
|
import { BookingStaff } from "../../common/booking-guards";
|
||||||
|
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||||
|
import { actorLabel } from "../warehouses/current-actor.util";
|
||||||
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
|
||||||
import { BillingService } from "./billing.service";
|
import { BillingService } from "./billing.service";
|
||||||
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
||||||
@@ -25,6 +37,7 @@ import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
|
|||||||
@BookingStaff([
|
@BookingStaff([
|
||||||
FREIGHT_PERMS.invoices.view,
|
FREIGHT_PERMS.invoices.view,
|
||||||
FREIGHT_PERMS.invoices.export,
|
FREIGHT_PERMS.invoices.export,
|
||||||
|
FREIGHT_PERMS.invoices.confirmOffline,
|
||||||
])
|
])
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
export class BillingController {
|
export class BillingController {
|
||||||
@@ -56,6 +69,36 @@ export class BillingController {
|
|||||||
return this.billingService.findById(id);
|
return this.billingService.findById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("offline-usd")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Finance worklist: USD invoices settled offline by bank transfer, with booking pay-window context",
|
||||||
|
})
|
||||||
|
findOfflineUsd(@Query() query: FilterInvoiceDto) {
|
||||||
|
return this.billingService.findOfflineUsdPaginated(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("invoices/:id/confirm-offline")
|
||||||
|
@BookingStaff(FREIGHT_PERMS.invoices.confirmOffline)
|
||||||
|
@UseInterceptors(FileInterceptor("file"))
|
||||||
|
@ApiConsumes("multipart/form-data")
|
||||||
|
@ApiOperation({
|
||||||
|
summary:
|
||||||
|
"Finance confirms a USD invoice paid by bank transfer — slip file required, settles the full balance",
|
||||||
|
})
|
||||||
|
confirmOffline(
|
||||||
|
@Param("id", ParseUUIDPipe) id: string,
|
||||||
|
@UploadedFile() file: Express.Multer.File | undefined,
|
||||||
|
@Body("reference") reference: string | undefined,
|
||||||
|
@CurrentUser() user: TCurrentUser,
|
||||||
|
) {
|
||||||
|
return this.billingService.confirmOfflinePayment(id, file, {
|
||||||
|
reference: reference?.trim() || null,
|
||||||
|
userId: resolveAuthUserId(user),
|
||||||
|
userName: actorLabel(user) ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@Get("invoices/:id/document")
|
@Get("invoices/:id/document")
|
||||||
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
@BookingStaff(FREIGHT_PERMS.invoices.export)
|
||||||
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
@ApiOperation({ summary: "Download the sealed invoice PDF" })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { InvoiceRepository } from "./invoice.repository";
|
|||||||
import { InvoiceLineRepository } from "./invoice-line.repository";
|
import { InvoiceLineRepository } from "./invoice-line.repository";
|
||||||
import { PaymentModule } from "../payment/payment.module";
|
import { PaymentModule } from "../payment/payment.module";
|
||||||
import { CompaniesModule } from "../companies/companies.module";
|
import { CompaniesModule } from "../companies/companies.module";
|
||||||
|
import { FilesModule } from "../files/files.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -21,6 +22,7 @@ import { CompaniesModule } from "../companies/companies.module";
|
|||||||
CompaniesModule,
|
CompaniesModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
UserTradeAccessModule,
|
UserTradeAccessModule,
|
||||||
|
FilesModule,
|
||||||
],
|
],
|
||||||
controllers: [BillingController, PortalBillingController, PaymentController],
|
controllers: [BillingController, PortalBillingController, PaymentController],
|
||||||
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
providers: [BillingService, InvoiceRepository, InvoiceLineRepository],
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
|
|
||||||
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
|
||||||
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
|
|||||||
{} as never, // payment
|
{} as never, // payment
|
||||||
{} as never, // companies
|
{} as never, // companies
|
||||||
{} as never, // invoiceDocuments
|
{} as never, // invoiceDocuments
|
||||||
|
{} as never, // files
|
||||||
);
|
);
|
||||||
return { service, mg, events };
|
return { service, mg, events };
|
||||||
}
|
}
|
||||||
@@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, defaultManager, txManager, transaction };
|
return { service, defaultManager, txManager, transaction };
|
||||||
};
|
};
|
||||||
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
|
|||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, manager };
|
return { service, manager };
|
||||||
};
|
};
|
||||||
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
|
|||||||
payment as never,
|
payment as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
@@ -703,6 +711,7 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
|
|||||||
payment as never,
|
payment as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
{} as never,
|
||||||
);
|
);
|
||||||
return { service, repo };
|
return { service, repo };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
|
|||||||
|
|
||||||
import { Booking } from "../bookings/entities/booking.entity";
|
import { Booking } from "../bookings/entities/booking.entity";
|
||||||
import { CompaniesService } from "../companies/companies.service";
|
import { CompaniesService } from "../companies/companies.service";
|
||||||
|
import { FilesService } from "../files/files.service";
|
||||||
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
|
||||||
import { PaymentService } from "../payment/payment.service";
|
import { PaymentService } from "../payment/payment.service";
|
||||||
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
|
||||||
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
|
|||||||
failureUrl?: string;
|
failureUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Booking context attached to a finance offline-USD invoice row. */
|
||||||
|
export interface OfflineUsdBookingInfo {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
paymentDeadline: Date | null;
|
||||||
|
paymentStatus: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** A single manual/offline settlement to record against an invoice. */
|
/** A single manual/offline settlement to record against an invoice. */
|
||||||
export interface RecordPaymentInput {
|
export interface RecordPaymentInput {
|
||||||
/** Amount settled by this payment; must be > 0. */
|
/** Amount settled by this payment; must be > 0. */
|
||||||
@@ -150,6 +159,7 @@ export class BillingService {
|
|||||||
private readonly payment: PaymentService,
|
private readonly payment: PaymentService,
|
||||||
private readonly companies: CompaniesService,
|
private readonly companies: CompaniesService,
|
||||||
private readonly invoiceDocuments: InvoiceDocumentService,
|
private readonly invoiceDocuments: InvoiceDocumentService,
|
||||||
|
private readonly files: FilesService,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
// ── Reads ──────────────────────────────────────────────────────────────────
|
// ── Reads ──────────────────────────────────────────────────────────────────
|
||||||
@@ -214,6 +224,146 @@ export class BillingService {
|
|||||||
return { items, total };
|
return { items, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
|
||||||
|
* never through the gateway), open ones by default or a single status when
|
||||||
|
* filtered. Booking-sourced rows carry the booking's reference and pay-window
|
||||||
|
* deadline so the UI can show the countdown and link to the booking.
|
||||||
|
*/
|
||||||
|
async findOfflineUsdPaginated(
|
||||||
|
filter: {
|
||||||
|
status?: Freight.InvoiceStatus;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
} = {},
|
||||||
|
): Promise<{
|
||||||
|
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
const page = filter.page && filter.page > 0 ? filter.page : 1;
|
||||||
|
const pageSize =
|
||||||
|
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
|
||||||
|
|
||||||
|
const qb = this.dataSource
|
||||||
|
.getRepository(Invoice)
|
||||||
|
.createQueryBuilder("invoice")
|
||||||
|
.leftJoinAndSelect("invoice.company", "company")
|
||||||
|
.where("UPPER(invoice.currency) = 'USD'")
|
||||||
|
.orderBy("invoice.issuedAt", "DESC")
|
||||||
|
.skip((page - 1) * pageSize)
|
||||||
|
.take(pageSize);
|
||||||
|
|
||||||
|
if (filter.status) {
|
||||||
|
qb.andWhere("invoice.status = :status", { status: filter.status });
|
||||||
|
} else {
|
||||||
|
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
|
||||||
|
}
|
||||||
|
if (filter.search) {
|
||||||
|
qb.andWhere(
|
||||||
|
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
|
||||||
|
{ search: `%${filter.search}%` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [items, total] = await qb.getManyAndCount();
|
||||||
|
|
||||||
|
const bookingIds = items
|
||||||
|
.filter((i) => i.source === "booking")
|
||||||
|
.map((i) => i.sourceId);
|
||||||
|
const bookings = bookingIds.length
|
||||||
|
? await this.dataSource.getRepository(Booking).find({
|
||||||
|
where: { id: In(bookingIds) },
|
||||||
|
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const byId = new Map(bookings.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: items.map((inv) => {
|
||||||
|
const b = byId.get(inv.sourceId);
|
||||||
|
return {
|
||||||
|
...inv,
|
||||||
|
booking: b
|
||||||
|
? {
|
||||||
|
id: b.id,
|
||||||
|
reference: b.reference,
|
||||||
|
paymentDeadline: b.paymentDeadline ?? null,
|
||||||
|
paymentStatus: b.paymentStatus,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
} as Invoice & { booking: OfflineUsdBookingInfo | null };
|
||||||
|
}),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
|
||||||
|
* against the invoice and settles the FULL outstanding balance through
|
||||||
|
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
|
||||||
|
* emits `booking.invoice.paid` — the same event an online payment fires, so
|
||||||
|
* the booking advances exactly as if it had been paid through the gateway.
|
||||||
|
*
|
||||||
|
* Guarded by the booking's pay window: past the deadline the booking expires
|
||||||
|
* like any unpaid one, so confirmation is refused.
|
||||||
|
*/
|
||||||
|
async confirmOfflinePayment(
|
||||||
|
invoiceId: string,
|
||||||
|
file: Express.Multer.File | undefined,
|
||||||
|
input: {
|
||||||
|
reference?: string | null;
|
||||||
|
userId?: string | null;
|
||||||
|
userName?: string | null;
|
||||||
|
},
|
||||||
|
): Promise<Invoice> {
|
||||||
|
const invoice = await this.invoices.findById(invoiceId);
|
||||||
|
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||||
|
if (invoice.currency?.toUpperCase() !== "USD") {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"Offline confirmation is only for USD invoices — this invoice is paid online.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!file) {
|
||||||
|
throw new BadRequestException("The bank payment slip file is required.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.source === "booking") {
|
||||||
|
const booking = await this.dataSource.getRepository(Booking).findOne({
|
||||||
|
where: { id: invoice.sourceId },
|
||||||
|
select: ["id", "paymentDeadline"],
|
||||||
|
});
|
||||||
|
const deadline = booking?.paymentDeadline;
|
||||||
|
if (deadline && new Date(deadline).getTime() < Date.now()) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
"The payment window has closed — this booking can no longer be confirmed as paid.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slip = await this.files.upload({
|
||||||
|
resource: "invoice",
|
||||||
|
resourceId: invoice.id,
|
||||||
|
code: "OFFLINE_PAYMENT_SLIP",
|
||||||
|
file,
|
||||||
|
title: "Bank payment slip",
|
||||||
|
uploadedByUserId: input.userId ?? null,
|
||||||
|
uploadedByName: input.userName ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.recordPayment(invoiceId, {
|
||||||
|
amount: Number(invoice.balanceAmount),
|
||||||
|
method: "BANK_TRANSFER",
|
||||||
|
reference: input.reference || slip.name,
|
||||||
|
metadata: {
|
||||||
|
offline: true,
|
||||||
|
slipFileId: slip.id,
|
||||||
|
confirmedByUserId: input.userId ?? null,
|
||||||
|
confirmedByName: input.userName ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** Invoice header plus its line items. */
|
/** Invoice header plus its line items. */
|
||||||
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
|
||||||
const invoice = await this.invoices.findById(id, {
|
const invoice = await this.invoices.findById(id, {
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { ContractsRepository } from './contracts.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A ONE_TIME contract stops blocking a duplicate request only once its booking
|
||||||
|
* is PAID. The existing duplicate-guard spec stubs the repository out, so the
|
||||||
|
* candidate SQL itself is unchecked there — this pins the predicate.
|
||||||
|
*/
|
||||||
|
describe('findDuplicateCandidates ONE_TIME paid gate', () => {
|
||||||
|
const candidateSql = (): string => {
|
||||||
|
const conditions: string[] = [];
|
||||||
|
const qb = {
|
||||||
|
leftJoinAndSelect: () => qb,
|
||||||
|
where: () => qb,
|
||||||
|
andWhere: (condition: string) => {
|
||||||
|
if (typeof condition === 'string') conditions.push(condition);
|
||||||
|
return qb;
|
||||||
|
},
|
||||||
|
getMany: async () => [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const repository = new ContractsRepository(
|
||||||
|
{ createQueryBuilder: () => qb } as never,
|
||||||
|
{} as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
void repository.findDuplicateCandidates('company-1', 'svc-1');
|
||||||
|
return conditions.join(' AND ');
|
||||||
|
};
|
||||||
|
|
||||||
|
it('spends the contract on payment, not on the booking row existing', () => {
|
||||||
|
const sql = candidateSql();
|
||||||
|
|
||||||
|
expect(sql).toContain("contract.contract_kind <> 'ONE_TIME'");
|
||||||
|
// The gate: an unpaid booking must NOT free the lane.
|
||||||
|
expect(sql).toContain("b.payment_status = 'PAID'");
|
||||||
|
expect(sql).toContain('b.deleted_at IS NULL');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -104,15 +104,19 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
|||||||
.andWhere('contract.status NOT IN (:...terminal)', {
|
.andWhere('contract.status NOT IN (:...terminal)', {
|
||||||
terminal: TERMINAL_CONTRACT_STATUSES,
|
terminal: TERMINAL_CONTRACT_STATUSES,
|
||||||
})
|
})
|
||||||
// A ONE_TIME contract allows a single booking, so once that booking
|
// A ONE_TIME contract allows a single booking, so once that booking is
|
||||||
// exists the contract is spent and can never carry another shipment.
|
// PAID the contract is spent and can never carry another shipment.
|
||||||
// Without this it kept blocking new requests on the same service type +
|
// Without this it kept blocking new requests on the same service type +
|
||||||
// route until its validity lapsed — locking a customer out of a lane for
|
// route until its validity lapsed — locking a customer out of a lane for
|
||||||
// the rest of the term after one completed shipment.
|
// the rest of the term after one completed shipment.
|
||||||
|
// Payment is the gate, not the booking row: a DRAFT or abandoned unpaid
|
||||||
|
// booking must keep the contract blocking, otherwise a customer holds an
|
||||||
|
// unpaid booking and requests an identical contract alongside it.
|
||||||
.andWhere(
|
.andWhere(
|
||||||
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
`(contract.contract_kind <> 'ONE_TIME' OR NOT EXISTS (
|
||||||
SELECT 1 FROM freight.bookings b
|
SELECT 1 FROM freight.bookings b
|
||||||
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
WHERE b.contract_id = contract.id AND b.deleted_at IS NULL
|
||||||
|
AND b.payment_status = 'PAID'
|
||||||
))`,
|
))`,
|
||||||
)
|
)
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|||||||
@@ -11,8 +11,12 @@ import { Booking } from '../bookings/entities/booking.entity';
|
|||||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||||
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
|
||||||
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
|
||||||
|
<<<<<<< Updated upstream
|
||||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||||
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
|
||||||
|
=======
|
||||||
|
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||||
|
>>>>>>> Stashed changes
|
||||||
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
|
||||||
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||||
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||||
|
|||||||
@@ -60,7 +60,25 @@ import { BookingWindowService } from "./booking-window.service";
|
|||||||
import { IntercityService } from "./intercity.service";
|
import { IntercityService } from "./intercity.service";
|
||||||
import { BillingService } from "../billing/billing.service";
|
import { BillingService } from "../billing/billing.service";
|
||||||
|
|
||||||
|
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
|
||||||
@ApiTags("train-scheduling")
|
@ApiTags("train-scheduling")
|
||||||
|
=======
|
||||||
|
import { TrainSchedulingManage, TrainSchedulingView } from '../../../common/booking-guards';
|
||||||
|
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 { PinWagonsDto } from '../dto/pin-wagons.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 { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
|
||||||
|
import { TrainSchedulingService } from '../services/train-scheduling.service';
|
||||||
|
|
||||||
|
@ApiTags('train-scheduling')
|
||||||
|
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/controllers/train-scheduling.controller.ts
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@Controller("train-scheduling")
|
@Controller("train-scheduling")
|
||||||
export class TrainSchedulingController {
|
export class TrainSchedulingController {
|
||||||
@@ -3,7 +3,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
|
export class TrainCheckpointEventsRepository extends BaseRepository<TrainCheckpointEvent> {
|
||||||
@@ -1,11 +1,18 @@
|
|||||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||||
import { WagonStatus } from '@edr/types';
|
import { WagonStatus } from '@edr/types';
|
||||||
|
|
||||||
|
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.spec.ts
|
||||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||||
|
=======
|
||||||
|
import { 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';
|
||||||
|
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
|
|
||||||
const nw5 = {
|
const nw5 = {
|
||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
Raw,
|
Raw,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
|
|
||||||
|
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
|
||||||
import {
|
import {
|
||||||
buildPaginationMeta,
|
buildPaginationMeta,
|
||||||
normalizePagination,
|
normalizePagination,
|
||||||
@@ -86,6 +87,39 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
|
|||||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
|
=======
|
||||||
|
import { BookingsRepository } from '../../bookings/bookings.repository';
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
|
||||||
|
import { Container } from '../../container-management/entities/container.entity';
|
||||||
|
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
|
||||||
|
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
|
||||||
|
import { Route } from '../../routes/entities/route.entity';
|
||||||
|
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
|
||||||
|
import { TrainSet } from '../../train-sets/entities/train-set.entity';
|
||||||
|
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
|
||||||
|
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.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 { 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 { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
|
||||||
|
import { Wagon } from '../../wagons/entities/wagon.entity';
|
||||||
|
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 { PinWagonsDto } from '../dto/pin-wagons.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';
|
||||||
|
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
|
||||||
import {
|
import {
|
||||||
ImportDjiboutiOperation,
|
ImportDjiboutiOperation,
|
||||||
type ImportDjiboutiDocumentType,
|
type ImportDjiboutiDocumentType,
|
||||||
@@ -110,7 +144,7 @@ import {
|
|||||||
type BookingWagonShortage,
|
type BookingWagonShortage,
|
||||||
type DeferredBookingRow,
|
type DeferredBookingRow,
|
||||||
type FleetAvailabilityRow,
|
type FleetAvailabilityRow,
|
||||||
} from './fleet-plan.util';
|
} from '../utils/fleet-plan.util';
|
||||||
import {
|
import {
|
||||||
applyWagonOrderReversal,
|
applyWagonOrderReversal,
|
||||||
planWagonsWithStock,
|
planWagonsWithStock,
|
||||||
@@ -130,6 +164,7 @@ import {
|
|||||||
validateMixedTrainLimitsPerEdge,
|
validateMixedTrainLimitsPerEdge,
|
||||||
type ContainerPlacementInput,
|
type ContainerPlacementInput,
|
||||||
type WagonPlanSlot,
|
type WagonPlanSlot,
|
||||||
|
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
|
||||||
} from './wagon-plan.util';
|
} from './wagon-plan.util';
|
||||||
import { CorridorBudget } from './corridor-capacity.util';
|
import { CorridorBudget } from './corridor-capacity.util';
|
||||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||||
@@ -179,6 +214,19 @@ import {
|
|||||||
placementsForBookings,
|
placementsForBookings,
|
||||||
type ContainerUnitForPlacement,
|
type ContainerUnitForPlacement,
|
||||||
} from './container-placement.util';
|
} from './container-placement.util';
|
||||||
|
=======
|
||||||
|
} from '../utils/wagon-plan.util';
|
||||||
|
import {
|
||||||
|
getDefaultContainerWagonTypeCode,
|
||||||
|
pickBulkWagonType,
|
||||||
|
} from '../utils/wagon-type-resolver.util';
|
||||||
|
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
|
||||||
|
import { flipReadiness, wagonReadinessMatchesSchedule } from '../utils/wagon-readiness.util';
|
||||||
|
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||||
|
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
|
||||||
|
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
|
||||||
|
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
|
||||||
|
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
|
||||||
|
|
||||||
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
|
||||||
|
|
||||||
@@ -5767,7 +5815,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private resolveScheduleFreightType(
|
private resolveScheduleFreightType(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
|
||||||
const types = new Set(
|
const types = new Set(
|
||||||
(schedule.scheduleBookings ?? [])
|
(schedule.scheduleBookings ?? [])
|
||||||
@@ -5779,6 +5827,7 @@ export class TrainSchedulingService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
|
||||||
/**
|
/**
|
||||||
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
|
* Insert a schedule with a freshly generated S-<year>-NNNNN reference, retrying
|
||||||
* past a concurrent insert that grabbed the same sequence (the unique index
|
* past a concurrent insert that grabbed the same sequence (the unique index
|
||||||
@@ -5810,6 +5859,9 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||||
|
=======
|
||||||
|
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
|
||||||
|
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
|
||||||
return {
|
return {
|
||||||
id: schedule.id,
|
id: schedule.id,
|
||||||
reference: schedule.reference ?? null,
|
reference: schedule.reference ?? null,
|
||||||
@@ -7555,7 +7607,7 @@ export class TrainSchedulingService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async mapScheduleDetail(
|
private async mapScheduleDetail(
|
||||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||||
) {
|
) {
|
||||||
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
|
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
|
||||||
(w) => w.allocations ?? [],
|
(w) => w.allocations ?? [],
|
||||||
@@ -24,6 +24,7 @@ import { WarehousesModule } from '../warehouses/warehouses.module';
|
|||||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||||
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
|
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
|
||||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||||
|
<<<<<<< Updated upstream
|
||||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||||
import { TrainSchedulingService } from './train-scheduling.service';
|
import { TrainSchedulingService } from './train-scheduling.service';
|
||||||
@@ -41,6 +42,11 @@ import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
|||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
|
||||||
import { ContractsModule } from '../contracts/contracts.module';
|
import { ContractsModule } from '../contracts/contracts.module';
|
||||||
|
=======
|
||||||
|
import { TrainCheckpointEventsRepository } from './repositories/train-checkpoint-events.repository';
|
||||||
|
import { TrainSchedulingController } from './controllers/train-scheduling.controller';
|
||||||
|
import { TrainSchedulingService } from './services/train-scheduling.service';
|
||||||
|
>>>>>>> Stashed changes
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import {
|
import {
|
||||||
computeFleetAvailability,
|
computeFleetAvailability,
|
||||||
selectBookingsWithinFleetCap,
|
selectBookingsWithinFleetCap,
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
|
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
|
||||||
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
|
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
|
||||||
import type { Booking } from '../bookings/entities/booking.entity';
|
import type { Booking } from '../bookings/entities/booking.entity';
|
||||||
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
|
=======
|
||||||
|
import type { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import type { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/utils/fleet-plan.util.ts
|
||||||
import {
|
import {
|
||||||
buildBulkWagonPlan,
|
buildBulkWagonPlan,
|
||||||
buildContainerWagonPlan,
|
buildContainerWagonPlan,
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AllocationLoadType } from '@edr/types';
|
import { AllocationLoadType } from '@edr/types';
|
||||||
|
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
import {
|
import {
|
||||||
buildBulkWagonPlan,
|
buildBulkWagonPlan,
|
||||||
buildContainerWagonPlan,
|
buildContainerWagonPlan,
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { AllocationLoadType } from '@edr/types';
|
import { AllocationLoadType } from '@edr/types';
|
||||||
|
|
||||||
|
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/wagon-plan.util.ts
|
||||||
import { Booking } from '../bookings/entities/booking.entity';
|
import { Booking } from '../bookings/entities/booking.entity';
|
||||||
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||||
@@ -11,6 +12,10 @@ import {
|
|||||||
bulkTonWagonsRequired,
|
bulkTonWagonsRequired,
|
||||||
consistViolations,
|
consistViolations,
|
||||||
} from './train-capacity.util';
|
} from './train-capacity.util';
|
||||||
|
=======
|
||||||
|
import { Booking } from '../../bookings/entities/booking.entity';
|
||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/utils/wagon-plan.util.ts
|
||||||
|
|
||||||
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
export const MAX_TRAIN_WEIGHT_TONS = 3500;
|
||||||
export const MAX_TRAIN_LENGTH_METERS = 760;
|
export const MAX_TRAIN_LENGTH_METERS = 760;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
|
||||||
|
|
||||||
|
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
|
||||||
|
COFFEE: 'KW2',
|
||||||
|
GRAIN: 'KW2',
|
||||||
|
WHEAT: 'KW2',
|
||||||
|
SORGHUM: 'KW2',
|
||||||
|
CORN: 'KW2',
|
||||||
|
FERTILIZER: 'PW2',
|
||||||
|
SUGAR: 'PW2',
|
||||||
|
COAL: 'KW3',
|
||||||
|
STEEL: 'CW3',
|
||||||
|
ORE: 'CW3',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
|
||||||
|
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve wagon type code from cargo type code for bulk freight.
|
||||||
|
*/
|
||||||
|
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
|
||||||
|
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
|
||||||
|
const normalized = cargoTypeCode.trim().toUpperCase();
|
||||||
|
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the best matching wagon type entity for bulk cargo.
|
||||||
|
*/
|
||||||
|
export function pickBulkWagonType(
|
||||||
|
wagonTypes: WagonType[],
|
||||||
|
cargoTypeCode?: string | null,
|
||||||
|
): WagonType | undefined {
|
||||||
|
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
|
||||||
|
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
|
||||||
|
if (direct) return direct;
|
||||||
|
|
||||||
|
return wagonTypes.find(
|
||||||
|
(wt) =>
|
||||||
|
wt.isActive &&
|
||||||
|
!wt.supportsContainer &&
|
||||||
|
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultContainerWagonTypeCode(): string {
|
||||||
|
return DEFAULT_CONTAINER_WAGON_TYPE;
|
||||||
|
}
|
||||||
@@ -482,6 +482,13 @@ export const FINANCE_PERMISSIONS: FreightPermissionSeed[] = [
|
|||||||
"edr_freight_app:invoices:eims_resolve",
|
"edr_freight_app:invoices:eims_resolve",
|
||||||
"Resolve a blocked MoR EIMS submission",
|
"Resolve a blocked MoR EIMS submission",
|
||||||
),
|
),
|
||||||
|
// USD bookings are paid by bank transfer; Finance uploads the slip and settles
|
||||||
|
// the invoice. Moves money state, so it is its own grant, not part of view.
|
||||||
|
perm(
|
||||||
|
"d2b00001-0001-4000-8000-000000000007",
|
||||||
|
"edr_freight_app:invoices:confirm_offline",
|
||||||
|
"Confirm offline (bank transfer) invoice payment",
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
// E. First / last mile operations
|
// E. First / last mile operations
|
||||||
@@ -1630,6 +1637,7 @@ export const FREIGHT_PERMS = {
|
|||||||
export: "edr_freight_app:invoices:export",
|
export: "edr_freight_app:invoices:export",
|
||||||
eimsRegister: "edr_freight_app:invoices:eims_register",
|
eimsRegister: "edr_freight_app:invoices:eims_register",
|
||||||
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
eimsResolve: "edr_freight_app:invoices:eims_resolve",
|
||||||
|
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||||
},
|
},
|
||||||
firstMile: {
|
firstMile: {
|
||||||
view: "edr_freight_app:first_mile:view",
|
view: "edr_freight_app:first_mile:view",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import CustomerDetailPage from "./pages/customers/CustomerDetailPage";
|
|||||||
import CustomersPage from "./pages/customers/CustomersPage";
|
import CustomersPage from "./pages/customers/CustomersPage";
|
||||||
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
import InvoiceDetailPage from "./pages/invoices/InvoiceDetailPage";
|
||||||
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
import InvoicesPage from "./pages/invoices/InvoicesPage";
|
||||||
|
import UsdPaymentsPage from "./pages/invoices/UsdPaymentsPage";
|
||||||
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
import MyProfilePage from "./pages/dashboard/MyProfilePage";
|
||||||
import OverviewPage from "./pages/dashboard/OverviewPage";
|
import OverviewPage from "./pages/dashboard/OverviewPage";
|
||||||
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
import ReportsHubPage from "./pages/reports/ReportsHubPage";
|
||||||
@@ -265,6 +266,14 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="usd-payments"
|
||||||
|
element={
|
||||||
|
<RequirePermission permission={FREIGHT_PERMS.invoices.view}>
|
||||||
|
<UsdPaymentsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="invoices/:id"
|
path="invoices/:id"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Hammer,
|
Hammer,
|
||||||
History,
|
History,
|
||||||
|
Landmark,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
MapPin,
|
MapPin,
|
||||||
@@ -110,6 +111,12 @@ export const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[]
|
|||||||
icon: <Receipt />,
|
icon: <Receipt />,
|
||||||
permission: FREIGHT_PERMS.invoices.view,
|
permission: FREIGHT_PERMS.invoices.view,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "USD Payments",
|
||||||
|
href: "/dashboard/usd-payments",
|
||||||
|
icon: <Landmark />,
|
||||||
|
permission: FREIGHT_PERMS.invoices.view,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Support",
|
label: "Support",
|
||||||
href: "/dashboard/support",
|
href: "/dashboard/support",
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export const QUERY_KEYS = {
|
|||||||
list: (filter?: InvoiceListFilter) =>
|
list: (filter?: InvoiceListFilter) =>
|
||||||
["invoices", "list", filter ?? {}] as const,
|
["invoices", "list", filter ?? {}] as const,
|
||||||
byId: (id: string) => ["invoices", "detail", id] as const,
|
byId: (id: string) => ["invoices", "detail", id] as const,
|
||||||
|
offlineUsd: (filter?: InvoiceListFilter) =>
|
||||||
|
["invoices", "offline-usd", filter ?? {}] as const,
|
||||||
},
|
},
|
||||||
|
|
||||||
BOOKINGS: {
|
BOOKINGS: {
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ export const URL_CONSTANTS = {
|
|||||||
INVOICES: "/billing/invoices",
|
INVOICES: "/billing/invoices",
|
||||||
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
INVOICE_BY_ID: (id: string) => `/billing/invoices/${id}`,
|
||||||
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
INVOICE_DOCUMENT: (id: string) => `/billing/invoices/${id}/document`,
|
||||||
|
OFFLINE_USD: "/billing/offline-usd",
|
||||||
|
CONFIRM_OFFLINE: (id: string) => `/billing/invoices/${id}/confirm-offline`,
|
||||||
},
|
},
|
||||||
|
|
||||||
CUSTOMERS_API: {
|
CUSTOMERS_API: {
|
||||||
|
|||||||
@@ -128,6 +128,7 @@ export const FREIGHT_PERMS = {
|
|||||||
invoices: {
|
invoices: {
|
||||||
view: "edr_freight_app:invoices:view",
|
view: "edr_freight_app:invoices:view",
|
||||||
export: "edr_freight_app:invoices:export",
|
export: "edr_freight_app:invoices:export",
|
||||||
|
confirmOffline: "edr_freight_app:invoices:confirm_offline",
|
||||||
},
|
},
|
||||||
firstMile: {
|
firstMile: {
|
||||||
view: "edr_freight_app:first_mile:view",
|
view: "edr_freight_app:first_mile:view",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ import type {
|
|||||||
Invoice,
|
Invoice,
|
||||||
InvoiceListFilter,
|
InvoiceListFilter,
|
||||||
PaginatedInvoices,
|
PaginatedInvoices,
|
||||||
|
PaginatedOfflineUsdInvoices,
|
||||||
} from "@/types/invoice";
|
} from "@/types/invoice";
|
||||||
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
import type { IOverviewDashboard, OverviewRange } from "@/types/overview";
|
||||||
import {
|
import {
|
||||||
@@ -2914,6 +2915,29 @@ export const api = {
|
|||||||
({ id }) => invoicesService.getById(id),
|
({ id }) => invoicesService.getById(id),
|
||||||
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
({ id }) => QUERY_KEYS.INVOICES.byId(id),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
listOfflineUsd: endpoint<
|
||||||
|
{ filter: InvoiceListFilter },
|
||||||
|
PaginatedOfflineUsdInvoices
|
||||||
|
>(
|
||||||
|
"invoices",
|
||||||
|
"listOfflineUsd",
|
||||||
|
({ filter }) => invoicesService.listOfflineUsd(filter),
|
||||||
|
({ filter }) => QUERY_KEYS.INVOICES.offlineUsd(filter),
|
||||||
|
),
|
||||||
|
|
||||||
|
confirmOffline: endpoint<
|
||||||
|
{ id: string; file: File; reference?: string },
|
||||||
|
Invoice
|
||||||
|
>(
|
||||||
|
"invoices",
|
||||||
|
"confirmOffline",
|
||||||
|
({ id, file, reference }) =>
|
||||||
|
invoicesService.confirmOffline(id, file, reference),
|
||||||
|
undefined,
|
||||||
|
// Settling the invoice also advances the booking, so refresh both trees.
|
||||||
|
() => [QUERY_KEYS.INVOICES.ROOT, QUERY_KEYS.BOOKINGS.ROOT],
|
||||||
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
overview: {
|
overview: {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
Invoice,
|
Invoice,
|
||||||
InvoiceListFilter,
|
InvoiceListFilter,
|
||||||
PaginatedInvoices,
|
PaginatedInvoices,
|
||||||
|
PaginatedOfflineUsdInvoices,
|
||||||
} from "@/types/invoice";
|
} from "@/types/invoice";
|
||||||
|
|
||||||
const cleanParams = (params: object) =>
|
const cleanParams = (params: object) =>
|
||||||
@@ -33,4 +34,25 @@ export const invoicesService = {
|
|||||||
responseType: "blob",
|
responseType: "blob",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Finance worklist: USD invoices awaiting bank-transfer confirmation. */
|
||||||
|
listOfflineUsd(
|
||||||
|
filter: InvoiceListFilter,
|
||||||
|
): Promise<PaginatedOfflineUsdInvoices> {
|
||||||
|
return apiClient
|
||||||
|
.get<PaginatedOfflineUsdInvoices>(URL_CONSTANTS.BILLING.OFFLINE_USD, {
|
||||||
|
params: cleanParams(filter),
|
||||||
|
})
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Confirm a USD invoice paid by bank transfer — the slip file is required. */
|
||||||
|
confirmOffline(id: string, file: File, reference?: string): Promise<Invoice> {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
if (reference) body.append("reference", reference);
|
||||||
|
return apiClient
|
||||||
|
.post<Invoice>(URL_CONSTANTS.BILLING.CONFIRM_OFFLINE(id), body)
|
||||||
|
.then((r) => r.data);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,3 +20,22 @@ export interface PaginatedInvoices {
|
|||||||
items: Invoice[];
|
items: Invoice[];
|
||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A USD invoice on Finance's offline-settlement worklist. Booking-sourced rows
|
||||||
|
* carry the shipment's pay-window deadline so the list can show the same
|
||||||
|
* countdown the customer sees — Finance must confirm before it closes.
|
||||||
|
*/
|
||||||
|
export interface OfflineUsdInvoice extends Invoice {
|
||||||
|
booking: {
|
||||||
|
id: string;
|
||||||
|
reference: string;
|
||||||
|
paymentDeadline: string | null;
|
||||||
|
paymentStatus: string;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedOfflineUsdInvoices {
|
||||||
|
items: OfflineUsdInvoice[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user