mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
feat(billing): USD offline bank-transfer payments
This commit is contained in:
@@ -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, {
|
||||
|
||||
@@ -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,8 +11,12 @@ 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';
|
||||
<<<<<<< Updated upstream
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.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 { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
|
||||
import { SchedulingRescheduleRepository } from './scheduling-reschedule.repository';
|
||||
|
||||
@@ -60,7 +60,25 @@ import { BookingWindowService } from "./booking-window.service";
|
||||
import { IntercityService } from "./intercity.service";
|
||||
import { BillingService } from "../billing/billing.service";
|
||||
|
||||
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.controller.ts
|
||||
@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()
|
||||
@Controller("train-scheduling")
|
||||
export class TrainSchedulingController {
|
||||
@@ -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,18 @@
|
||||
import { BadRequestException, ConflictException } from '@nestjs/common';
|
||||
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 { 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';
|
||||
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.spec.ts
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
|
||||
const nw5 = {
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
Raw,
|
||||
} from 'typeorm';
|
||||
|
||||
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
|
||||
import {
|
||||
buildPaginationMeta,
|
||||
normalizePagination,
|
||||
@@ -86,6 +87,39 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
|
||||
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 { 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 {
|
||||
ImportDjiboutiOperation,
|
||||
type ImportDjiboutiDocumentType,
|
||||
@@ -110,7 +144,7 @@ import {
|
||||
type BookingWagonShortage,
|
||||
type DeferredBookingRow,
|
||||
type FleetAvailabilityRow,
|
||||
} from './fleet-plan.util';
|
||||
} from '../utils/fleet-plan.util';
|
||||
import {
|
||||
applyWagonOrderReversal,
|
||||
planWagonsWithStock,
|
||||
@@ -130,6 +164,7 @@ import {
|
||||
validateMixedTrainLimitsPerEdge,
|
||||
type ContainerPlacementInput,
|
||||
type WagonPlanSlot,
|
||||
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/train-scheduling.service.ts
|
||||
} from './wagon-plan.util';
|
||||
import { CorridorBudget } from './corridor-capacity.util';
|
||||
import { deriveScheduleDirection } from './derive-schedule-direction.util';
|
||||
@@ -179,6 +214,19 @@ import {
|
||||
placementsForBookings,
|
||||
type ContainerUnitForPlacement,
|
||||
} 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;
|
||||
|
||||
@@ -5767,7 +5815,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 ?? [])
|
||||
@@ -5779,6 +5827,7 @@ export class TrainSchedulingService {
|
||||
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
|
||||
* 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) {
|
||||
>>>>>>> Stashed changes:apps/edr-freight-api/src/modules/train-scheduling/services/train-scheduling.service.ts
|
||||
return {
|
||||
id: schedule.id,
|
||||
reference: schedule.reference ?? null,
|
||||
@@ -7555,7 +7607,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,6 +24,7 @@ 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';
|
||||
<<<<<<< Updated upstream
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
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 { NotificationInboxModule } from '../notification-inbox/notification-inbox.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({
|
||||
imports: [
|
||||
|
||||
@@ -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,11 @@
|
||||
<<<<<<< Updated upstream:apps/edr-freight-api/src/modules/train-scheduling/fleet-plan.util.ts
|
||||
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 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 {
|
||||
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,5 +1,6 @@
|
||||
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 { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
@@ -11,6 +12,10 @@ import {
|
||||
bulkTonWagonsRequired,
|
||||
consistViolations,
|
||||
} 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_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;
|
||||
}
|
||||
Reference in New Issue
Block a user