Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-09 13:44:18 +00:00
79 changed files with 1463 additions and 239 deletions

View File

@@ -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" })

View File

@@ -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],

View File

@@ -79,6 +79,7 @@ describe("BillingService.generateInvoice", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
});
@@ -140,6 +141,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -193,6 +195,7 @@ describe("BillingService.markInvoiceAsPaid", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
await service.markInvoiceAsPaid("inv-1", "pay-1", mg as never);
@@ -236,6 +239,7 @@ describe("BillingService.settleByPaymentId", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
return { service, mg, events };
}
@@ -347,6 +351,7 @@ describe("BillingService.recordPayment", () => {
{} as never, // payment
{} as never, // companies
{} as never, // invoiceDocuments
{} as never, // files
);
return { service, mg, events };
}
@@ -462,6 +467,7 @@ describe("BillingService.expirePayable — locked write runs in a transaction",
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, defaultManager, txManager, transaction };
};
@@ -533,6 +539,7 @@ describe("BillingService.issuePayable", () => {
{} as never,
{} as never,
{} as never,
{} as never,
);
return { service, manager };
};
@@ -622,6 +629,7 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
payment as never,
{} as never,
{} as never,
{} as never,
);
return { service, repo };
};
@@ -669,11 +677,11 @@ describe("BillingService — CAC Bank (OTP debit)", () => {
});
});
describe("BillingService — CBE bill amounts round UP to whole birr", () => {
// CBE bills whole birr. Ceil, never Math.round: a .40 balance rounded down
// settles 0.40 short while markInvoiceAsPaid still writes paidAmount =
// totalAmount — money missing from the bank with the books saying paid.
// payInvoice and billQuery must agree, or /cbe/payment sees a mismatch.
describe("BillingService — CBE bill amounts carry cents, never rounded", () => {
// CBE settles to the cent (/cbe/payment gates on amountsMatchToTheCent), so the
// bill must quote the exact balance. Rounding UP overcharged the payer by up to
// a birr; rounding DOWN underpaid while markInvoiceAsPaid still wrote paidAmount
// = totalAmount. payInvoice and billQuery must agree, or /cbe/payment mismatches.
const invoice = {
id: "inv-1",
status: Freight.InvoiceStatus.Pending,
@@ -682,9 +690,9 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
type: "PREPAID",
invoiceNumber: "INV-20260101-00001",
currency: "ETB",
// .40the case Math.round gets wrong (rounds down, underpays).
balanceAmount: 12345.4,
totalAmount: 12345.4,
// .43cents that must survive all the way to the bill.
balanceAmount: 12345.43,
totalAmount: 12345.43,
company: { name: "Acme PLC" },
paymentId: null,
dueAt: null,
@@ -703,11 +711,12 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
payment as never,
{} as never,
{} as never,
{} as never,
);
return { service, repo };
};
it("opens the intent for the ceiled balance, never below it", async () => {
it("opens the intent for the exact balance, cents included", async () => {
const initiate = jest.fn().mockResolvedValue({
intentId: "intent-1",
immediateSuccess: false,
@@ -718,16 +727,16 @@ describe("BillingService — CBE bill amounts round UP to whole birr", () => {
await service.payInvoice("inv-1", { method: "CBE_BILL" });
expect(initiate).toHaveBeenCalledWith(
expect.objectContaining({ amountMinor: 12346 }),
expect.objectContaining({ amountMinor: 12345.43 }),
);
});
it("quotes the same ceiled amount on bill-query as payInvoice opened", async () => {
it("quotes the same exact amount on bill-query as payInvoice opened", async () => {
const { service } = build();
await expect(service.billQuery("booking-1")).resolves.toMatchObject({
stillPayable: true,
currentAmountMinor: 12346,
currentAmountMinor: 12345.43,
});
});
});

View File

@@ -12,6 +12,7 @@ import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import { CompaniesService } from "../companies/companies.service";
import { FilesService } from "../files/files.service";
import { applyBookingRefDirectionScope } from "../user-trade-access/trade-scope.util";
import { PaymentService } from "../payment/payment.service";
import { InitiateResponseDto, IntentStatusDto } from "../payment/payments.dto";
@@ -35,6 +36,14 @@ export interface PayInvoiceOptions {
failureUrl?: string;
}
/** Booking context attached to a finance offline-USD invoice row. */
export interface OfflineUsdBookingInfo {
id: string;
reference: string;
paymentDeadline: Date | null;
paymentStatus: string;
}
/** A single manual/offline settlement to record against an invoice. */
export interface RecordPaymentInput {
/** Amount settled by this payment; must be > 0. */
@@ -150,6 +159,7 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
private readonly files: FilesService,
) { }
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -214,6 +224,146 @@ export class BillingService {
return { items, total };
}
/**
* Finance's offline-settlement worklist: USD invoices (paid by bank transfer,
* never through the gateway), open ones by default or a single status when
* filtered. Booking-sourced rows carry the booking's reference and pay-window
* deadline so the UI can show the countdown and link to the booking.
*/
async findOfflineUsdPaginated(
filter: {
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<{
items: (Invoice & { booking: OfflineUsdBookingInfo | null })[];
total: number;
}> {
const page = filter.page && filter.page > 0 ? filter.page : 1;
const pageSize =
filter.pageSize && filter.pageSize > 0 ? filter.pageSize : 20;
const qb = this.dataSource
.getRepository(Invoice)
.createQueryBuilder("invoice")
.leftJoinAndSelect("invoice.company", "company")
.where("UPPER(invoice.currency) = 'USD'")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
} else {
qb.andWhere("invoice.status IN (:...open)", { open: OPEN_STATUSES });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [items, total] = await qb.getManyAndCount();
const bookingIds = items
.filter((i) => i.source === "booking")
.map((i) => i.sourceId);
const bookings = bookingIds.length
? await this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
select: ["id", "reference", "paymentDeadline", "paymentStatus"],
})
: [];
const byId = new Map(bookings.map((b) => [b.id, b]));
return {
items: items.map((inv) => {
const b = byId.get(inv.sourceId);
return {
...inv,
booking: b
? {
id: b.id,
reference: b.reference,
paymentDeadline: b.paymentDeadline ?? null,
paymentStatus: b.paymentStatus,
}
: null,
} as Invoice & { booking: OfflineUsdBookingInfo | null };
}),
total,
};
}
/**
* Finance confirms a USD invoice as paid by bank transfer: stores the slip
* against the invoice and settles the FULL outstanding balance through
* {@link recordPayment}, which flips the invoice to PAID and (for bookings)
* emits `booking.invoice.paid` — the same event an online payment fires, so
* the booking advances exactly as if it had been paid through the gateway.
*
* Guarded by the booking's pay window: past the deadline the booking expires
* like any unpaid one, so confirmation is refused.
*/
async confirmOfflinePayment(
invoiceId: string,
file: Express.Multer.File | undefined,
input: {
reference?: string | null;
userId?: string | null;
userName?: string | null;
},
): Promise<Invoice> {
const invoice = await this.invoices.findById(invoiceId);
if (!invoice) throw new NotFoundException(`Invoice ${invoiceId} not found`);
if (invoice.currency?.toUpperCase() !== "USD") {
throw new BadRequestException(
"Offline confirmation is only for USD invoices — this invoice is paid online.",
);
}
if (!file) {
throw new BadRequestException("The bank payment slip file is required.");
}
if (invoice.source === "booking") {
const booking = await this.dataSource.getRepository(Booking).findOne({
where: { id: invoice.sourceId },
select: ["id", "paymentDeadline"],
});
const deadline = booking?.paymentDeadline;
if (deadline && new Date(deadline).getTime() < Date.now()) {
throw new BadRequestException(
"The payment window has closed — this booking can no longer be confirmed as paid.",
);
}
}
const slip = await this.files.upload({
resource: "invoice",
resourceId: invoice.id,
code: "OFFLINE_PAYMENT_SLIP",
file,
title: "Bank payment slip",
uploadedByUserId: input.userId ?? null,
uploadedByName: input.userName ?? null,
});
return this.recordPayment(invoiceId, {
amount: Number(invoice.balanceAmount),
method: "BANK_TRANSFER",
reference: input.reference || slip.name,
metadata: {
offline: true,
slipFileId: slip.id,
confirmedByUserId: input.userId ?? null,
confirmedByName: input.userName ?? null,
},
});
}
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id, {
@@ -1191,11 +1341,11 @@ export class BillingService {
// service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber.replace(/-/g, "_"),
// Whole birr, always UP. CBE bills this amount verbatim, so it must never
// land below the outstanding balance — Math.round would let a .40 balance
// settle 0.40 short. Ceil overcharges by <1 birr instead, and the same
// ceil in billQuery keeps the quoted and debited amounts identical.
amountMinor: Math.ceil(Number(invoice.balanceAmount)),
// Exact balance, cents included. CBE bills this verbatim and /cbe/payment
// matches the debited amount to the cent (amountsMatchToTheCent), so any
// rounding here would overcharge the payer and leave the invoice balance
// non-zero. billQuery quotes the same unrounded value.
amountMinor: round2(Number(invoice.balanceAmount)),
currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR",
@@ -1340,9 +1490,10 @@ export class BillingService {
});
if (open) {
// Ceil, matching payInvoice — the amount CBE quotes at the counter has to
// be the amount the intent was opened for, or /cbe/payment sees a mismatch.
const balance = Math.ceil(Number(open.balanceAmount ?? open.totalAmount));
// Unrounded, matching payInvoice — the amount CBE quotes at the counter has
// to be the amount the intent was opened for, to the cent, or /cbe/payment
// sees a mismatch.
const balance = round2(Number(open.balanceAmount ?? open.totalAmount));
const expired = open.dueAt && open.dueAt.getTime() < Date.now();
return {
stillPayable: balance > 0 && !expired,
@@ -1377,7 +1528,7 @@ export class BillingService {
return {
stillPayable: false,
payerName: latest.company?.name ?? null,
currentAmountMinor: Math.ceil(Number(latest.totalAmount)),
currentAmountMinor: round2(Number(latest.totalAmount)),
currency: latest.currency,
paymentReason: `Freight invoice ${latest.invoiceNumber}`,
reason: closedInvoiceReason(latest.status),

View File

@@ -279,9 +279,10 @@ export class BookingPricingService {
return {
lineItems,
// Grand total is billed in whole currency units — fractional line sums
// (rate × tons can yield e.g. 260519.2) round to the nearest whole birr/USD.
totalAmount: Math.round(total),
// Grand total keeps its cents, matching the line items it sums — rounding
// to whole birr made the total disagree with the breakdown (135,375.61 of
// lines shown as a 135,376.00 total) and CBE bills this figure to the cent.
totalAmount: round2(total),
currency: booking.paymentCurrency,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,

View File

@@ -20,7 +20,7 @@ import { NotificationInboxService } from '../notification-inbox/notification-inb
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { Rate } from '../rule-engine/entities/rate.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';

View File

@@ -77,6 +77,7 @@ import { LastMileService } from '../last-mile/last-mile.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { SetExportHandoverModeDto } from './dto/set-export-handover-mode.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
import {
@@ -757,6 +758,18 @@ export class BookingsController {
return this.customerTruckService.getLoadableContainers(id);
}
@Patch(':id/export-handover-mode')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({
summary: 'Export only: choose direct truck-to-train (no warehouse, no GRN) or warehouse first',
})
setExportHandoverMode(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SetExportHandoverModeDto,
) {
return this.bookingsService.setExportHandoverMode(id, dto.exportHandoverMode);
}
@Post(':id/customer-trucks/:assignmentId/load')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })

View File

@@ -13,7 +13,7 @@ import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { CompanyKind, CompanyStatus } from '../companies/entities/company.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
@@ -28,7 +28,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
import { assertExportReceivedWithGrn, DIRECT_TO_TRAIN } from '../../common/export-received-gate';
import { Yard } from '../rule-engine/entities/yard.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
@@ -285,10 +285,24 @@ export class BookingsService {
// Receipt is proven by the warehouse GRN, but the GRN is warehouse paperwork
// and never appears on this sheet — it is only the signal that EDR has taken
// the cargo, which is what the customer's sheet attests to.
const isDirectExport =
booking.tradeDirection === 'EXPORT' && booking.exportHandoverMode === DIRECT_TO_TRAIN;
const pendingWagons = wagons.length === 0;
if (pendingWagons) {
const receivedLines: CarriageAcceptanceReceivedRow[] =
booking.tradeDirection === 'EXPORT'
// Direct truck-to-train cargo never enters the warehouse, so there is no
// GRN'd inventory to build the sheet from. Choosing direct handover is
// itself the acceptance, so the sheet issues off the booking's own
// containers (or its VGM weight when the cargo is bulk).
const receivedLines: CarriageAcceptanceReceivedRow[] = isDirectExport
? await this.dataSource.query(
`SELECT NULL::numeric AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
FROM freight.containers c
WHERE c.booking_id = $1 AND c.deleted_at IS NULL
ORDER BY c.container_number`,
[bookingId],
)
: booking.tradeDirection === 'EXPORT'
? await this.dataSource.query(
`SELECT inv.weight AS "allocatedWeightTons",
c.container_number AS "containerNumbers"
@@ -304,6 +318,15 @@ export class BookingsService {
[bookingId],
)
: [];
// Bulk direct cargo has no containers — one line carrying the booking's
// declared weight still makes a valid sheet.
if (isDirectExport && receivedLines.length === 0) {
receivedLines.push({
allocatedWeightTons:
booking.bulkTotalWeightTons == null ? null : String(booking.bulkTotalWeightTons),
containerNumbers: null,
});
}
if (receivedLines.length === 0) {
throw new BadRequestException(
booking.tradeDirection === 'EXPORT'
@@ -1997,6 +2020,47 @@ export class BookingsService {
}
/** Get a single booking by ID with files. */
/**
* EXPORT only. Choose how the cargo reaches the train. DIRECT_TO_TRAIN takes
* the booking out of the warehouse flow entirely — no receipt, no GRN, and the
* carriage acceptance sheet becomes issuable straight away.
*
* Switching to direct is refused once the goods are already in the shed:
* inventory exists, so the cargo demonstrably went the warehouse route and its
* GRN paperwork must stand.
*/
async setExportHandoverMode(
bookingId: string,
mode: string,
): Promise<{ bookingId: string; exportHandoverMode: string }> {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
if ((booking.tradeDirection ?? '').toUpperCase() !== 'EXPORT') {
throw new BadRequestException('Handover mode applies to export bookings only');
}
if (mode === DIRECT_TO_TRAIN) {
const [stored]: Array<{ one: number }> = await this.dataSource.query(
`SELECT 1 AS one
FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[bookingId],
);
if (stored) {
throw new BadRequestException(
'This booking already has cargo in the warehouse, so it cannot be switched to direct truck-to-train',
);
}
}
await this.dataSource.query(
`UPDATE freight.bookings SET export_handover_mode = $2, updated_at = NOW() WHERE id = $1`,
[bookingId, mode],
);
return { bookingId, exportHandoverMode: mode };
}
async findById(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) {

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
import { DIRECT_TO_TRAIN, WAREHOUSE } from '../../../common/export-received-gate';
export class SetExportHandoverModeDto {
@ApiProperty({
enum: [DIRECT_TO_TRAIN, WAREHOUSE],
description:
'DIRECT_TO_TRAIN — the customer truck loads straight onto the wagon (no warehouse, no GRN). ' +
'WAREHOUSE — received at the warehouse and issued a GRN first.',
})
@IsIn([DIRECT_TO_TRAIN, WAREHOUSE])
exportHandoverMode!: string;
}

View File

@@ -302,6 +302,18 @@ export class Booking extends BaseEntity {
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
/**
* EXPORT only. How the cargo reaches the train:
* - DIRECT_TO_TRAIN — the customer's truck loads straight onto the wagon. No
* warehouse, so no GRN is ever raised and the carriage acceptance sheet is
* the only document handed over.
* - WAREHOUSE (also null) — received into the warehouse and GRN'd first.
*
* Null is treated as WAREHOUSE so existing bookings keep the GRN gate.
*/
@Column({ name: 'export_handover_mode', type: 'varchar', length: 20, nullable: true })
exportHandoverMode?: string | null;
/**
* Did the goods need re-handling in the warehouse? Recorded by warehouse
* staff after unloading. Only `true` bills the DOUBLE_HANDLING_FEE rule;

View File

@@ -32,7 +32,7 @@ export class Container extends BaseEntity {
type: 'varchar',
nullable: true,
})
sealNumber!: string | null;
sealNumber!: string | null;
@Column({ type: 'varchar', default: 'AVAILABLE' })
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED

View File

@@ -24,7 +24,7 @@ import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { eatDay } from '../train-scheduling/batch-window.util';
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';

View File

@@ -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');
});
});

View File

@@ -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();

View File

@@ -11,7 +11,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
import { compareSchedulingPriority } from '../scheduling/compare-scheduling-priority.util';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
import { BookingNotifierService } from '../train-scheduling/booking-notifier.service';
import { ExecuteRescheduleDto, PreviewRescheduleDto } from './dto/preview-reschedule.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';

View File

@@ -37,7 +37,7 @@ import { BookingNotifierService } from './booking-notifier.service';
import {
TrainSchedulingService,
effectiveWindowConfig,
} from './train-scheduling.service';
} from './services/train-scheduling.service';
import { eatDay, listConfigBookingWindows } from './batch-window.util';
import {
BATCH_BOARD_STATUSES,
@@ -90,7 +90,7 @@ import { BookingWindowGateway } from './booking-window.gateway';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
} from './wagon-plan.util';
} from './utils/wagon-plan.util';
import {
Capacity,
CorridorBudget,

View File

@@ -19,7 +19,7 @@ import {
} from '../notifications/resolve-company-phone.util';
import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { TrainSchedulingService, effectiveWindowConfig } from './services/train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import {
bookingCloseCutoff,

View File

@@ -1,4 +1,4 @@
import type { ContainerPlacementInput } from './wagon-plan.util';
import type { ContainerPlacementInput } from './utils/wagon-plan.util';
export type ContainerUnitForPlacement = {
bookingId: string;

View File

@@ -1,8 +1,8 @@
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
import { UserTradeAccessService } from "../user-trade-access/user-trade-access.service";
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
import type { AuthUserPayload } from "../../../common/resolve-auth-user-id";
import { UserTradeAccessService } from "../../user-trade-access/user-trade-access.service";
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
import {
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
@@ -19,46 +19,46 @@ import {
TrainSchedulingRulesManage,
TrainSchedulingUpdate,
TrainSchedulingView,
} from "../../common/booking-guards";
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { SwitchGovernmentBookingDto } from "./dto/switch-government-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
import { GetEligibleBookingsDto } from "./dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "./dto/get-eligible-bulk-bookings.dto";
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "./dto/pin-wagons.dto";
import { MoveWagonLoadDto } from "./dto/move-wagon-load.dto";
import { UpdateContainerItemDto } from "./dto/update-container-item.dto";
import { UpdateImportLoadingStatusDto } from "./dto/update-import-loading-status.dto";
import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
} from "../../../common/booking-guards";
import { FREIGHT_PERMS } from "../../../seed/freight-permissions.registry";
import { AcceptIntercityBookingsDto } from "../dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "../dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "../dto/assign-unassigned-booking.dto";
import { SwitchGovernmentBookingDto } from "../dto/switch-government-booking.dto";
import { CreateContainerTrainScheduleDto } from "../dto/create-container-train-schedule.dto";
import { GetEligibleBookingsDto } from "../dto/get-eligible-bookings.dto";
import { GetEligibleBulkBookingsDto } from "../dto/get-eligible-bulk-bookings.dto";
import { GetEligibleContainerBookingsDto } from "../dto/get-eligible-container-bookings.dto";
import { PinWagonsDto } from "../dto/pin-wagons.dto";
import { MoveWagonLoadDto } from "../dto/move-wagon-load.dto";
import { UpdateContainerItemDto } from "../dto/update-container-item.dto";
import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-status.dto";
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto";
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
} from "./dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "./dto/adjust-schedule-consist.dto";
import { AvailableTrainsQueryDto } from "./dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "./dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { ListTrainSchedulesQueryDto } from "./dto/list-train-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { MaintenanceRescheduleDto } from "./dto/maintenance-reschedule.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingJourneyService } from "./booking-journey.service";
import { BookingWindowService } from "./booking-window.service";
import { IntercityService } from "./intercity.service";
import { BillingService } from "../billing/billing.service";
} from "../dto/import-djibouti-operation.dto";
import { AvailableLocomotivesQueryDto } from "../dto/available-locomotives-query.dto";
import { AdjustScheduleConsistDto } from "../dto/adjust-schedule-consist.dto";
import { AvailableTrainsQueryDto } from "../dto/available-trains-query.dto";
import { BatchBoardQueryDto } from "../dto/batch-board-query.dto";
import { BookableSchedulesQueryDto } from "../dto/bookable-schedules-query.dto";
import { ListTrainSchedulesQueryDto } from "../dto/list-train-schedules-query.dto";
import { AvailableDaysQueryDto } from "../dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "../dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "../dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "../dto/update-schedule-window-rule.dto";
import { UpdateScheduleDateDto } from "../dto/update-schedule-date.dto";
import { MaintenanceRescheduleDto } from "../dto/maintenance-reschedule.dto";
import { TrainSchedulingService } from "../services/train-scheduling.service";
import { BookingBatchService } from "../booking-batch.service";
import { BookingJourneyService } from "../booking-journey.service";
import { BookingWindowService } from "../booking-window.service";
import { IntercityService } from "../intercity.service";
import { BillingService } from "../../billing/billing.service";
@ApiTags("train-scheduling")
@ApiBearerAuth()

View File

@@ -1,4 +1,4 @@
import { TrainSchedulingService } from './train-scheduling.service';
import { TrainSchedulingService } from './services/train-scheduling.service';
import { Booking } from '../bookings/entities/booking.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';

View File

@@ -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> {

View File

@@ -1,11 +1,11 @@
import { BadRequestException, ConflictException } from '@nestjs/common';
import { WagonStatus } from '@edr/types';
import { Wagon } from '../wagons/entities/wagon.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {

View File

@@ -36,71 +36,71 @@ import {
import {
buildPaginationMeta,
normalizePagination,
} from '../../common/utils/pagination.util';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { Contract } from '../contracts/entities/contract.entity';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../routes/entities/route.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { TrainSetLocomotive } from '../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../train-schedules/train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { Yard } from '../rule-engine/entities/yard.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from './dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from './dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from './dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
} from '../../../common/utils/pagination.util';
import { BookingsRepository } from '../../bookings/bookings.repository';
import { Booking } from '../../bookings/entities/booking.entity';
import { BookingContainer } from '../../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../../contracts/entities/clearance-milestone.entity';
import { ClearanceMilestoneService } from '../../contracts/clearance-milestone.service';
import { Contract } from '../../contracts/entities/contract.entity';
import { Container } from '../../container-management/entities/container.entity';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../../locomotives/locomotives.repository';
import { formatRouteLabel, Route } from '../../routes/entities/route.entity';
import { WagonMovement } from '../../wagons/entities/wagon-movement.entity';
import { Train } from '../../trains/entities/train.entity';
import { TrainSetLocomotive } from '../../train-sets/entities/train-set-locomotive.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { ScheduleWagonAdjustmentLog } from '../../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainScheduleBooking } from '../../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { WagonAllocationContainerItem } from '../../train-schedules/entities/wagon-allocation-container-item.entity';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from '../../train-schedules/train-schedule-bookings.repository';
import { TrainSchedulesRepository } from '../../train-schedules/train-schedules.repository';
import { TrainCompositionRemovalLogRepository } from '../../train-schedules/train-composition-removal-log.repository';
import { WagonAllocationBulkLoadsRepository } from '../../train-schedules/wagon-allocation-bulk-loads.repository';
import { WagonAllocationContainerItemsRepository } from '../../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../../train-schedules/wagon-booking-allocations.repository';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { WagonTypesRepository } from '../../wagon-types/wagon-types.repository';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { AdjustScheduleConsistDto } from '../dto/adjust-schedule-consist.dto';
import { AssignBookingsDto } from '../dto/assign-bookings.dto';
import { CreateContainerTrainScheduleDto } from '../dto/create-container-train-schedule.dto';
import { GetEligibleBookingsDto } from '../dto/get-eligible-bookings.dto';
import { GetEligibleBulkBookingsDto } from '../dto/get-eligible-bulk-bookings.dto';
import { GetEligibleContainerBookingsDto } from '../dto/get-eligible-container-bookings.dto';
import {
ListTrainSchedulesQueryDto,
TrainScheduleFreightType,
} from './dto/list-train-schedules-query.dto';
import { PinWagonsDto } from './dto/pin-wagons.dto';
import { MoveWagonLoadDto } from './dto/move-wagon-load.dto';
import { UpdateContainerItemDto } from './dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from './dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
} from '../dto/list-train-schedules-query.dto';
import { PinWagonsDto } from '../dto/pin-wagons.dto';
import { MoveWagonLoadDto } from '../dto/move-wagon-load.dto';
import { UpdateContainerItemDto } from '../dto/update-container-item.dto';
import { UpdateImportLoadingStatusDto } from '../dto/update-import-loading-status.dto';
import { PreviewBulkTrainScheduleDto } from '../dto/preview-bulk-train-schedule.dto';
import { PreviewContainerTrainScheduleDto } from '../dto/preview-container-train-schedule.dto';
import { PreviewTrainScheduleDto } from '../dto/preview-train-schedule.dto';
import { TrainSchedulingGlobalRules } from '../entities/train-scheduling-global-rules.entity';
import { UpdateTrainSchedulingGlobalRulesDto } from '../dto/update-train-scheduling-global-rules.dto';
import {
ImportDjiboutiOperation,
type ImportDjiboutiDocumentType,
} from './entities/import-djibouti-operation.entity';
} from '../entities/import-djibouti-operation.entity';
import {
ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { MaintenanceRescheduleDto } from './dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from './booking-window.config';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingBatchService } from './booking-batch.service';
} from '../dto/import-djibouti-operation.dto';
import { UpdateScheduleWindowRuleDto } from '../dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from '../dto/update-schedule-date.dto';
import { MaintenanceRescheduleDto } from '../dto/maintenance-reschedule.dto';
import { type BookingWindowConfig } from '../booking-window.config';
import { BookingWindowGateway } from '../booking-window.gateway';
import { BookingNotifierService } from '../booking-notifier.service';
import { BookingBatchService } from '../booking-batch.service';
import {
computeFleetAvailability,
summarizeFleetWarnings,
@@ -109,13 +109,13 @@ import {
type BookingWagonShortage,
type DeferredBookingRow,
type FleetAvailabilityRow,
} from './fleet-plan.util';
} from '../utils/fleet-plan.util';
import {
applyWagonOrderReversal,
planWagonsWithStock,
type AllowedWagonTypeMap,
type WagonStock,
} from './wagon-plan-flex.util';
} from '../wagon-plan-flex.util';
import {
containerWagonsForLines,
expandBookingContainerUnits,
@@ -129,10 +129,10 @@ import {
validateMixedTrainLimitsPerEdge,
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
import { CorridorBudget } from './corridor-capacity.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
} from '../utils/wagon-plan.util';
import { CorridorBudget } from '../corridor-capacity.util';
import { deriveScheduleDirection } from '../utils/derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from '../train-number.util';
import {
bookingCargoTons,
bulkItemsFitFor,
@@ -144,7 +144,7 @@ import {
wagonTypeDimensionsFromEntity,
LocomotiveLimits,
WagonTypeDimensions,
} from './train-capacity.util';
} from '../train-capacity.util';
import {
DEFAULT_BULK_WAGON_CAPACITY_TONS,
DEFAULT_BULK_WAGON_LENGTH_METERS,
@@ -153,8 +153,8 @@ import {
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
paymentDrainEndsAtIso,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
} from '../booking-batch.constants';
import { orderConsistWagons } from '../consist-order.util';
import {
computeExportWindowTimes,
computeImportWindowTimes,
@@ -162,22 +162,22 @@ import {
eatDay,
eatDayToUtc,
shiftEatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { BookingJourneyService } from './booking-journey.service';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
} from '../batch-window.util';
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
import { BookingJourneyService } from '../booking-journey.service';
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
import { WarehouseReleaseDocumentService } from '../../warehouses/warehouse-release-document.service';
import {
autoFillPlacements,
findMissingContainerNumberIssues,
isPlaceholderContainerNumber,
placementsForBookings,
type ContainerUnitForPlacement,
} from './container-placement.util';
} from '../container-placement.util';
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
@@ -5709,7 +5709,7 @@ export class TrainSchedulingService {
}
private resolveScheduleFreightType(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
): 'CONTAINER' | 'BULK' | 'MIXED' | null {
const types = new Set(
(schedule.scheduleBookings ?? [])
@@ -5751,7 +5751,7 @@ export class TrainSchedulingService {
throw new ConflictException('Could not allocate a unique schedule reference');
}
private mapScheduleListItem(schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule) {
private mapScheduleListItem(schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule) {
return {
id: schedule.id,
reference: schedule.reference ?? null,
@@ -6907,7 +6907,7 @@ export class TrainSchedulingService {
originYardId?: string,
destinationYardId?: string,
): Promise<
import('../train-schedules/entities/train-schedule.entity').TrainSchedule[]
import('../../train-schedules/entities/train-schedule.entity').TrainSchedule[]
> {
const schedules = await this.trainSchedulesRepository.findAll({
where: {
@@ -7497,7 +7497,7 @@ export class TrainSchedulingService {
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
schedule: import('../../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
(w) => w.allocations ?? [],

View File

@@ -24,9 +24,9 @@ import { WarehousesModule } from '../warehouses/warehouses.module';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
import { TrainCheckpointEventsRepository } from './repositories/train-checkpoint-events.repository';
import { TrainSchedulingController } from './controllers/train-scheduling.controller';
import { TrainSchedulingService } from './services/train-scheduling.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingWindowGateway } from './booking-window.gateway';

View File

@@ -1,4 +1,4 @@
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
/** @deprecated Use deriveTradeDirection from common — kept as alias for train scheduling. */
export const deriveScheduleDirection = deriveTradeDirection;

View File

@@ -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,

View File

@@ -1,6 +1,6 @@
import { bookingCargoTons, bulkWagonsForAllowedTypes } from './train-capacity.util';
import type { Booking } from '../bookings/entities/booking.entity';
import type { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { bookingCargoTons, bulkWagonsForAllowedTypes } from '../train-capacity.util';
import type { Booking } from '../../bookings/entities/booking.entity';
import type { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
buildBulkWagonPlan,
buildContainerWagonPlan,

View File

@@ -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,

View File

@@ -1,8 +1,8 @@
import { AllocationLoadType } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../rule-engine/container-type.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { Booking } from '../../bookings/entities/booking.entity';
import { containersPerWagonForSize, wagonsPerUnitForSize } from '../../rule-engine/container-type.util';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import {
bookingCargoTons,
bulkItemsFitFor,
@@ -10,7 +10,7 @@ import {
bulkTonsPerWagon,
bulkTonWagonsRequired,
consistViolations,
} from './train-capacity.util';
} from '../train-capacity.util';
export const MAX_TRAIN_WEIGHT_TONS = 3500;
export const MAX_TRAIN_LENGTH_METERS = 760;

View File

@@ -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;
}

View File

@@ -4,7 +4,7 @@ import {
applyWagonOrderReversal,
planWagonsWithStock,
} from './wagon-plan-flex.util';
import type { WagonPlanSlot } from './wagon-plan.util';
import type { WagonPlanSlot } from './utils/wagon-plan.util';
const nw6: WagonType = {
id: 'wt-nw6',

View File

@@ -11,7 +11,7 @@ import {
sortBookingsForScheduling,
type BookingWagonShortage,
type DeferredBookingRow,
} from './fleet-plan.util';
} from './utils/fleet-plan.util';
import {
MAX_TEU_SLOTS_PER_WAGON,
containerWagonsForLines,
@@ -21,7 +21,7 @@ import {
teuSlotsForSizeFt,
type SlotLoadType,
type WagonPlanSlot,
} from './wagon-plan.util';
} from './utils/wagon-plan.util';
/**
* Wagon types allowed to carry each container type / bulk cargo type — the

View File

@@ -1425,6 +1425,9 @@ export class WarehouseInventoryService {
WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID'
AND inv.id IS NULL
-- Direct truck-to-train cargo never comes to the warehouse, so never
-- offer it for receipt.
AND COALESCE(b.export_handover_mode, 'WAREHOUSE') <> 'DIRECT_TO_TRAIN'
ORDER BY b.scheduled_date DESC NULLS LAST`,
);