approve-delivery exit-gate fix + Import Loading Confirmation frontend panel — done this session, not yet committed

This commit is contained in:
Hagernesh
2026-07-03 14:06:34 +00:00
147 changed files with 7024 additions and 2098 deletions

View File

@@ -1,21 +1,31 @@
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
import {
Controller,
Get,
Param,
ParseUUIDPipe,
Query,
Res,
} from "@nestjs/common";
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
import type { Response } from "express";
import { FreightAdmin } from "../../common/booking-guards";
import { BookingView } from "../../common/booking-guards";
import { BillingService } from "./billing.service";
import { FilterInvoiceDto } from "./dto/filter-invoice.dto";
@ApiTags("billing")
@Controller("billing")
@FreightAdmin()
@BookingView()
@ApiBearerAuth()
export class BillingController {
constructor(private readonly billingService: BillingService) { }
constructor(private readonly billingService: BillingService) {}
@Get("invoices")
@ApiOperation({ summary: "List all invoices" })
findAll() {
return this.billingService.findAll();
@ApiOperation({
summary: "List invoices (paginated, filterable by company/status/search)",
})
findAll(@Query() query: FilterInvoiceDto) {
return this.billingService.findAllPaginated(query);
}
@Get("invoices/:id")

View File

@@ -125,7 +125,7 @@ export class BillingService {
private readonly payment: PaymentService,
private readonly companies: CompaniesService,
private readonly invoiceDocuments: InvoiceDocumentService,
) { }
) {}
// ── Reads ──────────────────────────────────────────────────────────────────
@@ -134,9 +134,56 @@ export class BillingService {
return this.invoices.findAll({ order: { issuedAt: "DESC" } });
}
/**
* Paginated invoice list for the backoffice — optionally narrowed to a
* company (customer detail "Invoices" tab) and/or status/search (global
* invoices page).
*/
async findAllPaginated(
filter: {
companyId?: string;
status?: Freight.InvoiceStatus;
search?: string;
page?: number;
pageSize?: number;
} = {},
): Promise<{ items: Invoice[]; 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")
.orderBy("invoice.issuedAt", "DESC")
.skip((page - 1) * pageSize)
.take(pageSize);
if (filter.companyId) {
qb.andWhere("invoice.companyId = :companyId", {
companyId: filter.companyId,
});
}
if (filter.status) {
qb.andWhere("invoice.status = :status", { status: filter.status });
}
if (filter.search) {
qb.andWhere(
"(invoice.invoiceNumber ILIKE :search OR invoice.sourceId ILIKE :search)",
{ search: `%${filter.search}%` },
);
}
const [items, total] = await qb.getManyAndCount();
return { items, total };
}
/** Invoice header plus its line items. */
async findById(id: string): Promise<Invoice & { lines: InvoiceLine[] }> {
const invoice = await this.invoices.findById(id);
const invoice = await this.invoices.findById(id, {
relations: { company: true, companyProfile: true },
});
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
const lines = await this.invoiceLines.findAll({
where: { invoiceId: id },
@@ -375,7 +422,7 @@ export class BillingService {
input.dueAt ??
new Date(
Date.now() +
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
(input.dueInDays ?? DEFAULT_DUE_DAYS) * 24 * 60 * 60 * 1000,
);
const invoiceNumber = await this.nextInvoiceNumber(mg);

View File

@@ -0,0 +1,42 @@
import { Freight } from "@edr/types";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsIn,
IsInt,
IsOptional,
IsString,
IsUUID,
Min,
} from "class-validator";
export class FilterInvoiceDto {
@ApiPropertyOptional({ default: 1 })
@IsOptional()
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20 })
@IsOptional()
@Transform(({ value }: { value: unknown }) => parseInt(String(value), 10))
@IsInt()
@Min(1)
pageSize?: number = 20;
@ApiPropertyOptional()
@IsOptional()
@IsUUID()
companyId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
search?: string;
@ApiPropertyOptional({ enum: Freight.InvoiceStatus })
@IsOptional()
@IsIn(Object.values(Freight.InvoiceStatus))
status?: Freight.InvoiceStatus;
}

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingsService } from './bookings.service';
import { AllocateContainersDto } from './dto/allocate-containers.dto';
import { AllocationManage } from '../../common/booking-guards';
@ApiTags('bookings')
@Controller('bookings')
@@ -10,6 +11,7 @@ export class BookingAllocationController {
constructor(private readonly bookingsService: BookingsService) {}
@Post(':bookingId/allocate-containers')
@AllocationManage()
@ApiOperation({ summary: 'Allocate containers to vehicles' })
async allocateContainers(
@Param('bookingId', ParseUUIDPipe) bookingId: string,

View File

@@ -45,6 +45,7 @@ describe('BookingPricingService — domestic corridor', () => {
{} as never,
ratesService as never,
exchangeService as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
});

View File

@@ -17,6 +17,14 @@ import {
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { ContainerValidationService } from './container-validation.service';
export interface OverweightLine {
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}
export interface ComputedPriceResult {
lineItems: PriceLineItemDto[];
@@ -27,6 +35,7 @@ export interface ComputedPriceResult {
priorityScore: number;
warnings: string[];
hardBlocked: string[];
overweightLines: OverweightLine[];
}
type StoredPricingBreakdown = {
@@ -67,6 +76,7 @@ export class BookingPricingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly exchangeService: ExchangeService,
private readonly containerValidationService: ContainerValidationService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
@@ -94,12 +104,19 @@ export class BookingPricingService {
},
} as never);
// 20ft weight-pairing preview: surfaced now so the customer sees the problem
// (and the overweight warning + surcharge) at the confirm step, before submit.
// Submit re-runs this and HARD-BLOCKS on a non-empty result.
const pairing = await this.containerValidationService.validate20ftPairing(booking);
return {
bookingId,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
overweightLines: computed.overweightLines,
pairingErrors: pairing.map((p) => p.message),
};
}
@@ -169,6 +186,35 @@ export class BookingPricingService {
if (rate) usedRatesMap.set(rate.id, rate);
}
// Overweight detail for the customer: map the engine's per-line results back
// to the booking's container lines (same order) for code + weights. maxAllowed
// is derived from the line total minus the excess the engine computed.
const overweightLines: OverweightLine[] = [];
const containerLines = (booking.bookingContainers ?? []).filter(
(bc) => bc.containerTypeId != null,
);
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue;
const line = containerLines[i];
const totalVgmTons = Number(line?.totalVgmTons ?? 0);
const excessTons = Number(wr.overweightExcessTons ?? 0);
let code = line?.containerSize ?? '';
if (line?.containerTypeId) {
try {
code = (await this.containerTypesService.findById(line.containerTypeId)).code;
} catch {
// fall back to the container size label
}
}
overweightLines.push({
containerTypeCode: code,
totalVgmTons,
maxAllowedTons: Math.max(0, totalVgmTons - excessTons),
excessTons,
});
}
return {
lineItems,
totalAmount: total,
@@ -178,6 +224,7 @@ export class BookingPricingService {
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
hardBlocked: ruleResult.hardBlocked,
overweightLines,
};
}

View File

@@ -37,6 +37,7 @@ describe('BookingTransitionService — acceptIntake validity window', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, ruleEngineService };
}

View File

@@ -48,6 +48,7 @@ describe('BookingTransitionService — finalizeClearance gate', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository };
}
@@ -132,6 +133,7 @@ describe('BookingTransitionService — finalizeClearance customs output gate', (
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository };
}
@@ -202,6 +204,7 @@ describe('BookingTransitionService — submitClearanceDocuments required-fields
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, filesService };
}

View File

@@ -40,6 +40,7 @@ describe('BookingTransitionService — operation review', () => {
bookingsService as never,
{ isPhasedGeneralCustomsBooking: () => false } as never,
{} as never,
{ validate20ftPairing: jest.fn().mockResolvedValue([]) } as never,
);
return { service, bookingsRepository, bookingBatchService };
}

View File

@@ -16,6 +16,7 @@ import { FilesService } from '../files/files.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { clearanceCodesForBooking } from './clearance.util';
@@ -51,6 +52,7 @@ export class BookingTransitionService {
@Inject(forwardRef(() => ClearanceWorkflowService))
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly containerValidationService: ContainerValidationService,
) {}
@@ -58,6 +60,19 @@ export class BookingTransitionService {
return this.bookingClearanceService.isPhasedGeneralCustomsBooking(booking);
}
/** Reject submit when the booking's 20ft containers can't be balanced onto wagons. */
private async assert20ftPairable(booking: Booking): Promise<void> {
const violations =
await this.containerValidationService.validate20ftPairing(booking);
if (violations.length) {
throw new BadRequestException(
`Cannot submit — 20ft containers cannot be paired on wagons: ${violations
.map((v) => v.message)
.join(' ')}`,
);
}
}
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ["DRAFT", "CHANGES_REQUESTED"]);
@@ -78,6 +93,11 @@ export class BookingTransitionService {
requiresDirectorApproval: false,
});
// 20ft weight-pairing hard block: two 20ft on a wagon must differ ≤ the cap.
// If no balanced pairing exists the booking cannot proceed (overweight only
// warns; this rejects). An odd leftover 20ft is fine — it goes to consolidation.
await this.assert20ftPairable(booking);
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
@@ -158,6 +178,7 @@ export class BookingTransitionService {
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.assert20ftPairable(booking);
await this.pricingService.createPricingSnapshots(
bookingId,
@@ -993,12 +1014,14 @@ export class BookingTransitionService {
// Export is FCFS: fail the accept up-front (409) when no export train on the
// booking's day still has capacity — nothing below runs and the request stays
// pending for staff to move/decline.
// pending for staff to move/decline. (For a consolidated pair this is a rough
// solo pre-check; the real combined-capacity reservation happens after the
// booking is FULLY_EXECUTED, once both partners are ready.)
const isExportTrain =
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
const exportScheduleId = isExportTrain
? await this.bookingBatchService.pickExportSchedule(booking)
: null;
if (isExportTrain) {
await this.bookingBatchService.pickExportSchedule(booking);
}
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
@@ -1023,11 +1046,12 @@ export class BookingTransitionService {
lockedAt: booking.lockedAt ?? now,
} as never);
if (exportScheduleId) {
if (isExportTrain) {
// FCFS: reserve the slot and send the payment notification immediately;
// paid → auto-allocated by the settle/paid pipeline.
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
// only reserve once both partners are FULLY_EXECUTED (handled inside).
const fresh = await this.bookingsService.findById(booking.id);
await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId);
await this.bookingBatchService.acceptExportBooking(fresh);
} else if (booking.tradeDirection === "IMPORT") {
// Import bookings wait for their booking-day window cycle — the batch runs
// after staff document review, never at accept time.

View File

@@ -23,6 +23,7 @@ import { BookingsController } from './bookings.controller';
// import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { ContainerValidationService } from './container-validation.service';
import { BookingsService } from './bookings.service';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -79,6 +80,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingsService,
BookingsRepository,
ConsolidationService,
ContainerValidationService,
BookingReferenceDataService,
BookingPricingService,
BookingTransitionService,

View File

@@ -186,7 +186,13 @@ export class BookingsRepository extends BaseRepository<Booking> {
/**
* Find another booking whose container quantity complements this one to fill whole wagon(s)
* (same route, same container type, partial wagon on both sides).
* (same route, same container type, partial wagon on both sides). Only 20ft lines ever
* reach here — 40ft has perWagon=1 so `quantity % 1 == 0` is never partial.
*
* Partners must also ride the SAME booking day: consolidation shares one physical wagon,
* and the window/batch pool is keyed on the EAT departure day, so a pair that can't board
* the same train is useless. The day filter is applied only when THIS booking already has
* a scheduled_date (draft bookings without a date match on route/type alone until they pick one).
*/
async findComplementaryConsolidationPartner(
booking: Booking,
@@ -198,7 +204,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
): Promise<Booking | null> {
const { containerTypeId, quantity, containersPerWagon: perWagon } = slot;
return this.repository
const qb = this.repository
.createQueryBuilder('b')
.innerJoinAndSelect('b.bookingContainers', 'bc')
.innerJoin('bc.containerType', 'ct')
@@ -224,9 +230,18 @@ export class BookingsRepository extends BaseRepository<Booking> {
.andWhere('((:quantity + bc.quantity) % :perWagon) = 0', {
quantity,
perWagon,
})
.orderBy('b.createdAt', 'ASC')
.getOne();
});
// Same EAT booking day, so the pair can share a wagon on one train. Skip only
// when this booking has no date yet (matched again once it picks its day).
if (booking.scheduledDate) {
qb.andWhere(
`DATE(b.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = DATE(:bookingDate AT TIME ZONE 'Africa/Addis_Ababa')`,
{ bookingDate: booking.scheduledDate },
);
}
return qb.orderBy('b.createdAt', 'ASC').getOne();
}
/** Try each partial-wagon line until a complementary partner booking is found. */

View File

@@ -0,0 +1,55 @@
import { validate20ftWeightPairing } from './container-pairing.util';
describe('validate20ftWeightPairing', () => {
const MAX_DIFF = 10;
it('passes when a balanced pairing exists (adjacent diffs within cap)', () => {
// sorted: 8, 15, 18, 24 → pairs (8,15) diff 7, (18,24) diff 6 — both ≤ 10.
const units = [
{ label: 'A', grossWeightTons: 24 },
{ label: 'B', grossWeightTons: 8 },
{ label: 'C', grossWeightTons: 18 },
{ label: 'D', grossWeightTons: 15 },
];
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
});
it('flags a pair whose weight difference exceeds the cap', () => {
// sorted: 5, 25 → single pair diff 20 > 10.
const units = [
{ label: 'HEAVY', grossWeightTons: 25 },
{ label: 'LIGHT', grossWeightTons: 5 },
];
const result = validate20ftWeightPairing(units, MAX_DIFF);
expect(result).toHaveLength(1);
expect(result[0].labels).toEqual(['LIGHT', 'HEAVY']);
expect(result[0].diffTons).toBe(20);
});
it('allows an odd leftover unit (goes to consolidation, not a violation)', () => {
// sorted: 10, 12, 30 → pair (10,12) diff 2 ok; 30 is the odd leftover.
const units = [
{ label: 'A', grossWeightTons: 10 },
{ label: 'B', grossWeightTons: 12 },
{ label: 'C', grossWeightTons: 30 },
];
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
});
it('adjacent-by-weight pairing succeeds where a naive input order would fail', () => {
// Input order (20, 12, 22, 10) naively pairs (20,12)=8 and (22,10)=12 (fail),
// but sorted (10,12,20,22) pairs (10,12)=2 and (20,22)=2 — valid, so no violation.
const units = [
{ label: 'A', grossWeightTons: 20 },
{ label: 'B', grossWeightTons: 12 },
{ label: 'C', grossWeightTons: 22 },
{ label: 'D', grossWeightTons: 10 },
];
expect(validate20ftWeightPairing(units, MAX_DIFF)).toEqual([]);
});
it('returns nothing for fewer than two units', () => {
expect(validate20ftWeightPairing([{ label: 'A', grossWeightTons: 30 }], MAX_DIFF)).toEqual([]);
expect(validate20ftWeightPairing([], MAX_DIFF)).toEqual([]);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Booking-time 20ft weight-pairing rule.
*
* A container wagon holds two 20ft containers (2 TEU). When two 20ft ride the
* same wagon their gross-weight difference must not exceed `maxPairDiffTons`
* (global rule `max20ftPairWeightDiffTons`, default 10t) so the wagon load stays
* balanced. 40ft containers occupy a whole wagon alone and never pair.
*
* At booking time the customer enters every 20ft container's weight but not its
* wagon slot, so we auto-pair: sort the 20ft weights ascending and pair adjacent
* (0-1, 2-3, …). Adjacent pairing minimises the diff of every pair, so if ANY
* valid pairing exists this one finds it — a violation here means no balanced
* pairing is possible and the booking must be blocked. An odd leftover 20ft is
* fine: it has no partner in this booking and flows to consolidation.
*/
export interface Container20ftUnit {
/** Human label for messages, e.g. the container number. */
label: string;
grossWeightTons: number;
}
export interface PairingViolation {
message: string;
/** The two container labels whose pairing exceeds the diff cap. */
labels: [string, string];
diffTons: number;
}
const round2 = (n: number): number => Math.round(n * 100) / 100;
/**
* Validate that the given 20ft units can all be paired onto wagons within the
* weight-difference cap. Returns one violation per over-cap adjacent pair (empty
* when every wagon pair is balanced or there is nothing to pair). A single
* leftover unit (odd count) is not a violation.
*/
export function validate20ftWeightPairing(
units: Container20ftUnit[],
maxPairDiffTons: number,
): PairingViolation[] {
if (units.length < 2 || maxPairDiffTons == null) return [];
// Ascending by weight: adjacent pairs have the smallest possible diffs.
const sorted = [...units].sort((a, b) => a.grossWeightTons - b.grossWeightTons);
const violations: PairingViolation[] = [];
for (let i = 0; i + 1 < sorted.length; i += 2) {
const a = sorted[i];
const b = sorted[i + 1];
const diff = Math.abs(a.grossWeightTons - b.grossWeightTons);
if (diff > maxPairDiffTons) {
violations.push({
message:
`20ft containers ${a.label} (${round2(a.grossWeightTons)}T) and ` +
`${b.label} (${round2(b.grossWeightTons)}T) cannot share a wagon: ` +
`weight difference ${round2(diff)}T exceeds the ${maxPairDiffTons}T limit.`,
labels: [a.label, b.label],
diffTons: round2(diff),
});
}
}
return violations;
}

View File

@@ -0,0 +1,76 @@
import { Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, In } from 'typeorm';
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
import { Booking } from './entities/booking.entity';
import { BookingContainerUnit } from './entities/booking-container-unit.entity';
import {
Container20ftUnit,
PairingViolation,
validate20ftWeightPairing,
} from './container-pairing.util';
/** Default 20ft pair weight-difference cap when no global rules row exists (matches the entity default). */
const DEFAULT_MAX_20FT_PAIR_DIFF_TONS = 10;
/**
* Booking-time container validations that need the customer-entered per-unit
* weights (`BookingContainerUnit`): the 20ft weight-pairing rule. Kept out of the
* rule engine (which works on line totals) because pairing is per physical unit.
*/
@Injectable()
export class ContainerValidationService {
constructor(@InjectDataSource() private readonly dataSource: DataSource) {}
private async maxPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.find({ order: { createdAt: 'ASC' }, take: 1 })
.then((rows) => rows[0] ?? null)
.catch(() => null);
const v = row?.max20ftPairWeightDiffTons;
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : DEFAULT_MAX_20FT_PAIR_DIFF_TONS;
}
/** Load every 20ft container UNIT weight for a booking (customer-entered VGM). */
private async load20ftUnits(booking: Booking): Promise<Container20ftUnit[]> {
const lines = (booking.bookingContainers ?? []).filter(
(bc) => (bc.containerSize ?? '').includes('20'),
);
if (!lines.length) return [];
const units = await this.dataSource
.getRepository(BookingContainerUnit)
.find({
where: { bookingContainerId: In(lines.map((l) => l.id)) },
order: { sortOrder: 'ASC' },
});
return units.map((u) => ({
label: u.containerNumber || u.id.slice(0, 8),
grossWeightTons: Number(u.vgmTons ?? 0),
}));
}
/**
* Validate the 20ft weight-pairing rule for a booking. Returns one message per
* pair whose weight difference exceeds the cap; empty when all 20ft can be
* balanced onto wagons (or there is nothing to pair). A lone odd 20ft is fine —
* it flows to consolidation. Callers hard-block a non-empty result.
*/
async validate20ftPairing(booking: Booking): Promise<PairingViolation[]> {
// Only bookings whose 20ft lines actually carry per-unit weights can be
// checked; contract-drawdown bookings do (units are required there).
const containerLines = booking.bookingContainers ?? [];
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
if (!has20ft) return [];
const units = await this.load20ftUnits(booking);
if (units.length < 2) return [];
const maxDiff = await this.maxPairDiffTons();
return validate20ftWeightPairing(units, maxDiff);
}
}

View File

@@ -27,6 +27,20 @@ export class PriceLineItemDto {
currency!: string;
}
export class OverweightLineDto {
@ApiProperty()
containerTypeCode!: string;
@ApiProperty()
totalVgmTons!: number;
@ApiProperty()
maxAllowedTons!: number;
@ApiProperty()
excessTons!: number;
}
export class GeneratePriceResponseDto {
@ApiProperty()
bookingId!: string;
@@ -42,4 +56,16 @@ export class GeneratePriceResponseDto {
@ApiProperty({ type: [String] })
warnings!: string[];
/** Overweight container lines (VGM over the weight-limit rule) — surcharge already in lineItems. */
@ApiProperty({ type: [OverweightLineDto] })
overweightLines!: OverweightLineDto[];
/**
* 20ft weight-pairing violations. Non-empty means the booking cannot be
* balanced onto wagons and submit is HARD-BLOCKED — the customer must fix
* container weights/quantities. (Overweight, by contrast, only warns.)
*/
@ApiProperty({ type: [String] })
pairingErrors!: string[];
}

View File

@@ -1,11 +1,14 @@
import {
BadRequestException,
ForbiddenException,
Inject,
Injectable,
Logger,
NotFoundException,
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { ExchangeService } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -13,6 +16,9 @@ import { BookingContainerUnit } from '../bookings/entities/booking-container-uni
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingPricingService } from '../bookings/booking-pricing.service';
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 { ContainerTypesService } from '../rule-engine/services/container-types.service';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
@@ -60,6 +66,9 @@ export class ContractBookingService {
private readonly workflowService: ClearanceWorkflowService,
private readonly invoiceService: BookingInvoiceService,
private readonly dataSource: DataSource,
private readonly exchangeService: ExchangeService,
@Inject(forwardRef(() => TrainSchedulingService))
private readonly trainSchedulingService: TrainSchedulingService,
) {}
async createUnderContract(
@@ -111,6 +120,20 @@ export class ContractBookingService {
const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms) {
await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
scheduledDate: dto.scheduledDate ?? null,
direction: contract.tradeDirection ?? null,
});
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
reference,
@@ -563,6 +586,145 @@ export class ContractBookingService {
}
}
/**
* Pre-create validation for the shipment form: run the overweight rule + the
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
* booking. The portal calls this from the price-confirm modal so the customer
* sees the overweight warning (+ surcharge basis) and is blocked on an
* un-pairable 20ft set before the booking is created.
*/
async validateShipment(
contractId: string,
dto: CreateBookingUnderContractDto,
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}>;
overweightSurchargeAmount: number;
currency: string | null;
pairingErrors: string[];
}> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
const lines = dto.containers ?? [];
if (!lines.length) {
return {
overweightLines: [],
overweightSurchargeAmount: 0,
currency: null,
pairingErrors: [],
};
}
// Resolve each line's container type + total VGM (sum of unit weights) so the
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
const resolved = await Promise.all(
lines.map(async (line) => {
const ct = await this.resolveContainerTypeForSize(
line.containerSize,
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
);
const totalVgmTons = (line.units ?? []).reduce(
(s, u) => s + Number(u.vgmTons ?? 0),
0,
);
return { line, ct, totalVgmTons };
}),
);
const ruleResult = await this.ruleEngineService.evaluate({
freightType: 'CONTAINER',
cargoTypeId: null,
serviceTypeId: contract.serviceTypeId,
paymentCurrency: contract.paymentCurrency,
tradeDirection: contract.tradeDirection,
isHazardous: false,
isReefer: contract.isReefer ?? false,
isGovernment: false,
allowConsolidation: false,
shippingLineId: null,
totalWagons: 0,
bulkTons: 0,
containers: resolved.map((r) => ({
containerTypeId: r.ct.id,
quantity: r.line.quantity,
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
totalVgmTons: r.totalVgmTons,
isReefer: r.ct.isReefer,
})),
} as never);
const overweightLines: Array<{
containerTypeCode: string;
totalVgmTons: number;
maxAllowedTons: number;
excessTons: number;
}> = [];
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
const wr = ruleResult.containerWeightResults[i];
if (!wr?.isOverweight) continue;
const r = resolved[i];
const excessTons = Number(wr.overweightExcessTons ?? 0);
overweightLines.push({
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
totalVgmTons: r?.totalVgmTons ?? 0,
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
excessTons,
});
}
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
const twentyFtUnits = resolved
.filter((r) => (r.line.containerSize ?? '').includes('20'))
.flatMap((r) =>
(r.line.units ?? []).map((u, idx) => ({
label: u.containerNumber || `${r.line.containerSize}-${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
})),
);
const maxDiff = await this.max20ftPairDiffTons();
const pairingErrors = validate20ftWeightPairing(twentyFtUnits, maxDiff).map(
(v) => v.message,
);
// Real overweight surcharge (same rate the rule engine bills at booking-create
// time) so the confirm-modal total isn't missing the charge the warning refers to.
// Rates are stored in USD; convert to the contract's payment currency the same
// way BookingPricingService does so this preview matches the eventual booking total.
const overweightModifier = ruleResult.appliedModifiers.find(
(m) => m.surchargeCode === 'OVERWEIGHT_PER_TON',
);
let overweightSurchargeAmount = 0;
if (overweightModifier) {
const isEtb = contract.paymentCurrency === 'ETB';
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
overweightSurchargeAmount = isEtb
? Math.round(overweightModifier.calculatedAmount * usdToEtb)
: overweightModifier.calculatedAmount;
}
return {
overweightLines,
overweightSurchargeAmount,
currency: overweightLines.length ? contract.paymentCurrency : null,
pairingErrors,
};
}
private async max20ftPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
.find({ order: { createdAt: 'ASC' }, take: 1 })
.then((rows) => rows[0] ?? null)
.catch(() => null);
const n = row?.max20ftPairWeightDiffTons == null ? NaN : Number(row.max20ftPairWeightDiffTons);
return Number.isFinite(n) ? n : 10;
}
/** Pick the default container type for a size; prefer reefer when requested. */
private async resolveContainerTypeForSize(
size: string,

View File

@@ -19,6 +19,7 @@ import { CargoTypesService } from '../rule-engine/services/cargo-types.service';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FilesService } from '../files/files.service';
import { SignaturesService } from '../signatures/signatures.service';
import { OtpService } from '../otp/otp.service';
import { ContractPricingService } from './contract-pricing.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { ContractsRepository } from './contracts.repository';
@@ -63,6 +64,7 @@ export class ContractTransitionService {
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
private readonly minioService: MinioService,
private readonly otpService: OtpService,
) {}
/** Customer submits the contract for approval → SUBMITTED; freeze unit rates. */
@@ -520,6 +522,12 @@ export class ContractTransitionService {
if (existing) {
throw new BadRequestException('Customer has already signed this contract');
}
// Sudo-mode gate: a fresh, single-use OTP (SMS'd to the customer's phone)
// must be verified before the signature is applied.
if (!dto.otpPhone || !dto.otp) {
throw new BadRequestException('OTP verification is required to sign the contract');
}
await this.otpService.verifyOtpForAction(dto.otpPhone, dto.otp);
await this.applySignature(contract, dto, options);
await this.contractsRepository.update(contractId, {
status: 'SIGNED_CUSTOMER',

View File

@@ -788,6 +788,18 @@ export class ContractsController {
);
}
@Post(':id/validate-shipment')
@ApiOperation({
summary:
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
})
validateShipment(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
) {
return this.contractBookingService.validateShipment(id, dto);
}
@Get(':id/capacity')
@ApiOperation({
summary: 'Remaining bookable quantity per cargo line (GENERAL draw-down cap)',

View File

@@ -11,7 +11,9 @@ import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { FileUploadSettingsModule } from '../file-upload-settings/file-upload-settings.module';
import { DropdownSettingsModule } from '../dropdown-settings/dropdown-settings.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { OtpModule } from '../otp/otp.module';
import { BookingsModule } from '../bookings/bookings.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractsController } from './contracts.controller';
import { ContractsService } from './contracts.service';
@@ -72,10 +74,15 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
FilesModule,
MinioModule,
SignaturesModule,
OtpModule,
CompaniesModule,
// BookingsModule provides BookingsRepository/BookingPricingService used by the
// contract PDF builders (they read a Booking today — see docs/new-doc.md §3.3).
forwardRef(() => BookingsModule),
// TrainSchedulingModule provides the config-driven booking-window gate used
// by ContractBookingService.createUnderContract. forwardRef because
// TrainSchedulingModule already imports ContractsModule.
forwardRef(() => TrainSchedulingModule),
ExchangeModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService): ExchangeOptions =>

View File

@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
import { IsIn, IsOptional, IsString, Matches, MinLength } from 'class-validator';
export class SignContractDto {
@ApiProperty({ enum: ['CUSTOMER', 'STAFF', 'DIRECTOR', 'CEO'] })
@@ -26,4 +26,19 @@ export class SignContractDto {
@IsOptional()
@IsString()
consentText?: string;
// Sudo-mode OTP challenge. Required when role=CUSTOMER: a fresh 6-digit code
// SMS'd to the signer's phone, verified server-side before the signature is
// applied. `otpPhone` is the number the code was sent to (the signed-in
// customer's registered phone).
@ApiPropertyOptional({ description: '6-digit OTP; required when role=CUSTOMER' })
@IsOptional()
@IsString()
@Matches(/^\d{6}$/, { message: 'otp must be 6 digits' })
otp?: string;
@ApiPropertyOptional({ description: 'Phone the OTP was sent to; required when role=CUSTOMER' })
@IsOptional()
@IsString()
otpPhone?: string;
}

View File

@@ -0,0 +1,30 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEmail, IsNotEmpty, IsOptional, IsString } from "class-validator";
export class SendEmailDto {
@ApiProperty({
description: "Recipient email address",
example: "customer@example.com",
})
@IsEmail()
@IsNotEmpty()
to!: string;
@ApiProperty({
description: "Email subject",
example: "Your EDR Freight verification code",
})
@IsString()
@IsNotEmpty()
subject!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
text?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
html?: string;
}

View File

@@ -0,0 +1,51 @@
import {
Inject,
Injectable,
Logger,
OnApplicationBootstrap,
} from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { SendEmailDto } from "./dtos/email.dto";
@Injectable()
export class EmailClientService implements OnApplicationBootstrap {
private readonly logger = new Logger(EmailClientService.name);
constructor(
@Inject("EMAIL_SERVICE")
private readonly emailClient: ClientProxy,
) {}
private readonly enabled = process.env.RABBITMQ_ENABLED !== "false";
async onApplicationBootstrap() {
if (!this.enabled) return;
this.emailClient
.connect()
.then(() => this.logger.log("connected to Email service"))
.catch((err) => {
console.error("Error happened at Email service", err);
});
}
async sendEmail(dto: SendEmailDto): Promise<{ queued: boolean }> {
if (!this.enabled) {
this.logger.warn(`RABBITMQ disabled — skipped EMAIL to=${dto.to}`);
return { queued: false };
}
this.emailClient.emit("send-email", {
to: dto.to,
subject: dto.subject,
text: dto.text,
html: dto.html,
appKey: "IFHCRS-LICENSE-MANAGEMENT",
});
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
this.logger.log(
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
);
// Recipient + content are PII — debug only.
this.logger.debug(`EMAIL payload to=${dto.to} subject="${dto.subject}"`);
return { queued: true };
}
}

View File

@@ -4,6 +4,7 @@ import { ClientsModule, Transport } from "@nestjs/microservices";
import { NotificationsService } from "./notifications.service";
import { SmsClientService } from "./sms-client.service";
import { EmailClientService } from "./email-client.service";
import { EmailNotificationStrategy } from "./strategies/notification.email.strategy";
import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy";
@@ -20,10 +21,25 @@ import { SmsNotificationStrategy } from "./strategies/notification.sms.strategy"
queueOptions: { durable: true },
},
},
{
name: "EMAIL_SERVICE",
transport: Transport.RMQ,
options: {
urls: [process.env.RABBITMQ_URL as string],
queue: process.env.EMAIL_QUEUE ?? "email_queue",
queueOptions: { durable: true },
},
},
]),
],
controllers: [],
providers: [EmailNotificationStrategy, SmsNotificationStrategy, NotificationsService, SmsClientService],
exports: [NotificationsService, SmsClientService],
providers: [
EmailNotificationStrategy,
SmsNotificationStrategy,
NotificationsService,
SmsClientService,
EmailClientService,
],
exports: [NotificationsService, SmsClientService, EmailClientService],
})
export class NotificationsModule {}

View File

@@ -1,15 +1,24 @@
// otp.controller.ts
import {
BadRequestException,
Body,
Controller,
Post,
} from "@nestjs/common";
import { OtpService } from "./otp.service";
import { OtpService, OtpTarget } from "./otp.service";
import { Public } from "@edr/api-common";
// Exactly one of phone/email must be present per request — the channel the
// code is sent through / checked against.
function toTarget(phone?: string, email?: string): OtpTarget {
if (email) return { email };
if (phone) return { phone };
throw new BadRequestException("phone or email is required");
}
@Controller("otp")
@Public()
export class OtpController {
@@ -24,9 +33,12 @@ export class OtpController {
@Post("send")
async sendOtp(
@Body("phone")
phone: string
phone?: string,
@Body("email")
email?: string
) {
return this.otpService.sendOtp(phone);
return this.otpService.sendOtp(toTarget(phone, email));
}
// ---------------------------------------------------------------------------
@@ -36,13 +48,16 @@ export class OtpController {
@Post("verify")
async verifyOtp(
@Body("phone")
phone: string,
phone: string | undefined,
@Body("email")
email: string | undefined,
@Body("otp")
otp: string
) {
return this.otpService.verifyOtp(
phone,
toTarget(phone, email),
otp
);
}

View File

@@ -10,10 +10,19 @@ import { BaseEntity } from "@edr/api-common";
name: "otp_verifications",
})
export class OtpVerification extends BaseEntity{
// Exactly one of phone/email is set per row — the channel the code was sent
// through.
@Column({
unique: true,
nullable: true,
})
phone!: string;
phone?: string;
@Column({
unique: true,
nullable: true,
})
email?: string;
@Column()
otp!: string;

View File

@@ -31,6 +31,7 @@ import { NotificationsModule } from "../notifications/notifications.module";
exports: [
OtpRepository,
OtpService,
],
})
export class OtpModule {}

View File

@@ -31,17 +31,44 @@ export class OtpRepository {
});
}
// ---------------------------------------------------------------------------
// Find By Email
// ---------------------------------------------------------------------------
async findByEmail(
email: string
) {
return this.repository.findOne({
where: {
email,
},
});
}
// ---------------------------------------------------------------------------
// Find By Target (either channel)
// ---------------------------------------------------------------------------
async findByTarget(
target: { phone?: string; email?: string }
) {
return target.email
? this.findByEmail(target.email)
: this.findByPhone(target.phone!);
}
// ---------------------------------------------------------------------------
// Create OTP
// ---------------------------------------------------------------------------
async createOtp(
phone: string,
target: { phone?: string; email?: string },
otp: string
) {
const entity =
this.repository.create({
phone,
phone: target.phone,
email: target.email,
otp,
verified: false,
});
@@ -70,10 +97,10 @@ export class OtpRepository {
}
// ---------------------------------------------------------------------------
// Verify Phone
// Mark Verified
// ---------------------------------------------------------------------------
async verifyPhone(
async markVerified(
otpVerification: OtpVerification
) {
otpVerification.verified =
@@ -83,4 +110,18 @@ export class OtpRepository {
otpVerification
);
}
// ---------------------------------------------------------------------------
// Delete OTP (single-use consume)
// ---------------------------------------------------------------------------
// Hard delete so the unique `phone` row is freed and a fresh code can be
// requested for the same number on the next action.
async deleteOtp(
otpVerification: OtpVerification
) {
return this.repository.remove(
otpVerification
);
}
}

View File

@@ -1,80 +1,80 @@
// otp.service.ts
import {
BadRequestException,
Injectable,
} from "@nestjs/common";
import { BadRequestException, Injectable, Logger } from "@nestjs/common";
import { OtpRepository } from "./otp.repository";
import { SmsClientService } from "../notifications/sms-client.service";
import { EmailClientService } from "../notifications/email-client.service";
// Exactly one of phone/email is set — enforced by the controller before it
// reaches here.
export type OtpTarget = { phone?: string; email?: string };
@Injectable()
export class OtpService {
logger = new Logger(OtpService.name);
constructor(
private readonly otpRepository: OtpRepository,
private readonly smsClient: SmsClientService
) {}
private readonly smsClient: SmsClientService,
private readonly emailClient: EmailClientService,
) { }
// ---------------------------------------------------------------------------
// Generate OTP
// ---------------------------------------------------------------------------
generateOtp(): string {
return Math.floor(
100000 + Math.random() * 900000
).toString();
return Math.floor(100000 + Math.random() * 900000).toString();
}
// ---------------------------------------------------------------------------
// Send OTP
// ---------------------------------------------------------------------------
async sendOtp(phone: string) {
async sendOtp(target: OtpTarget) {
try {
// The verification code is generated server-side — never supplied by the
// caller — so the OTP stays a secret known only to the server and the
// recipient of the SMS.
// recipient of the SMS/email.
const otp = this.generateOtp();
// find existing phone
const existingPhone =
await this.otpRepository.findByPhone(
phone
);
// find existing row for this channel
const existing = await this.otpRepository.findByTarget(target);
// update existing otp
if (existingPhone) {
await this.otpRepository.updateOtp(
existingPhone,
otp
);
if (existing) {
await this.otpRepository.updateOtp(existing, otp);
} else {
// create new otp
await this.otpRepository.createOtp(
phone,
otp
);
await this.otpRepository.createOtp(target, otp);
}
// send sms (queued to RabbitMQ via the shared SMS service)
await this.smsClient.sendSms({
to: phone,
message: `Your verification code is ${otp}`,
});
if (target.email) {
// send email (queued to RabbitMQ via the shared Email service)
await this.emailClient.sendEmail({
to: target.email,
subject: "Your EDR Freight verification code",
text: `Your verification code is ${otp}`,
});
} else {
// send sms (queued to RabbitMQ via the shared SMS service)
await this.smsClient.sendSms({
to: target.phone as string,
message: `Your verification code is ${otp}`,
});
}
this.logger.log(`OTP send for ${target.email ?? target.phone}: ${otp}`);
return {
success: true,
message:
"OTP sent successfully",
message: "OTP sent successfully",
};
} catch (error) {
console.log(error);
throw new BadRequestException(
"Failed to send OTP"
);
throw new BadRequestException("Failed to send OTP");
}
}
@@ -82,40 +82,70 @@ export class OtpService {
// Verify OTP
// ---------------------------------------------------------------------------
async verifyOtp(
phone: string,
otp: string
) {
// find phone
const otpData =
await this.otpRepository.findByPhone(
phone
);
async verifyOtp(target: OtpTarget, otp: string) {
// find the channel's row
const otpData = await this.otpRepository.findByTarget(target);
// phone not found
// not found
if (!otpData) {
throw new BadRequestException(
"Phone number not found"
target.email ? "Email address not found" : "Phone number not found",
);
}
// invalid otp
if (otpData.otp !== otp) {
throw new BadRequestException(
"Invalid OTP"
);
throw new BadRequestException("Invalid OTP");
}
// verify phone
await this.otpRepository.verifyPhone(
otpData
);
// mark verified
await this.otpRepository.markVerified(otpData);
return {
success: true,
message:
"Phone verified successfully",
message: target.email
? "Email verified successfully"
: "Phone verified successfully",
};
}
}
// ---------------------------------------------------------------------------
// Verify OTP for a sensitive action (sudo mode)
// ---------------------------------------------------------------------------
// Fresh, single-use challenge gating a sensitive action (e.g. applying a
// contract signature). Unlike verifyOtp above — which marks a phone verified
// and leaves the code in place — this enforces a short TTL and consumes the
// code on success so it can never be replayed.
private readonly ACTION_OTP_TTL_MS = 5 * 60 * 1000;
async verifyOtpForAction(phone: string, otp: string) {
const otpData = await this.otpRepository.findByPhone(phone);
if (!otpData) {
throw new BadRequestException(
"No verification code was requested for this phone",
);
}
const ageMs = Date.now() - new Date(otpData.updatedAt).getTime();
if (ageMs > this.ACTION_OTP_TTL_MS) {
await this.otpRepository.deleteOtp(otpData);
throw new BadRequestException(
"Verification code has expired. Request a new one.",
);
}
if (otpData.otp !== otp) {
throw new BadRequestException("Invalid verification code");
}
// single-use: consume on success
await this.otpRepository.deleteOtp(otpData);
return { success: true };
}
}

View File

@@ -1,9 +1,10 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
Body,
Controller,
HttpCode,
HttpStatus,
Logger,
Post,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
@@ -22,14 +23,17 @@ import { PaymentService } from "./payment.service";
@Public()
@Controller("internal/payments")
export class InternalPaymentController {
constructor(private readonly paymentService: PaymentService) { }
private readonly logger = new Logger(InternalPaymentController.name);
constructor(private readonly paymentService: PaymentService) { }
@Post("mark-paid")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: "Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
})
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
return this.paymentService.handlePaymentEvent(event);
}
@Post("mark-paid")
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary:
"Apply a payment.succeeded / payment.failed event from the payment service (idempotent)",
})
async markPaid(@Body() event: PaymentEventDto): Promise<MarkPaidResponseDto> {
this.logger.log(`Marking payment ${event} as PAID`);
return this.paymentService.handlePaymentEvent(event);
}
}

View File

@@ -93,9 +93,8 @@ export class PaymentRepository {
p.paid_at,
p.created_at
FROM freight.payments p
JOIN freight.bookings b ON b.id = p.ref_id
JOIN freight.bookings b ON b.id = p.ref_id::uuid
WHERE b.company_id = $1
AND p.deleted_at IS NULL
AND b.deleted_at IS NULL
ORDER BY p.created_at DESC`,
[companyId],

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import { IsIn, IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
import {
RATE_APPLIES_TO,
RATE_TRIGGERS,
@@ -51,15 +51,6 @@ export class CreateRateDto {
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
@IsIn([...RATE_UNITS])
rateUnit!: string;
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
@IsDateString()
effectiveFrom!: string;
@ApiPropertyOptional({ description: 'Date when this rate expires. Null = currently active', example: '2025-12-31' })
@IsOptional()
@IsDateString()
effectiveTo?: string;
}
export class SubmitRateForApprovalDto {

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsDateString, IsIn, IsNumber, IsOptional, IsUUID, Min } from 'class-validator';
import { IsIn, IsNumber, IsUUID, Min } from 'class-validator';
const TRADE_DIRECTIONS = ['IMPORT', 'EXPORT', 'BOTH', 'DOMESTIC'] as const;
@@ -21,13 +21,4 @@ export class CreateWeightLimitRuleDto {
@Min(0)
@Transform(({ value }) => Number(value))
maxVgmTons!: number;
@ApiProperty({ description: 'Date from which this rule is active (ISO date)', example: '2024-01-01' })
@IsDateString()
effectiveFrom!: string;
@ApiPropertyOptional({ description: 'Date when this rule expires (ISO date). Null = currently active', example: '2025-12-31' })
@IsOptional()
@IsDateString()
effectiveTo?: string;
}

View File

@@ -0,0 +1,71 @@
import type { RateAppliesTo, RateTrigger, RateUnit } from './rate.entity';
/**
* Which rate units make sense for a given rate shape. The weighting basis is
* driven by the *type* of thing being billed — a container leg bills per
* container, bulk freight per ton, an intercity move can be per-km, a
* cancellation is a flat/per-invoice fee, and overweight is always per excess
* ton. This keeps the rate table dynamic yet non-conflicting: the admin can
* only pick a unit the pricing engine knows how to apply.
*
* Returned lists are ordered with the most natural/default unit first.
*/
export function allowedRateUnits(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
}): RateUnit[] {
const { appliesTo, trigger } = input;
// Surcharges (Applies to = Other) are governed by their trigger.
if (appliesTo === 'OTHER') {
switch (trigger) {
case 'OVERWEIGHT':
// Overweight always bills the excess tonnage — per ton, nothing else.
return ['PER_TON'];
case 'REEFER':
case 'HAZARDOUS':
// Scale with the freight shape: per container for boxes, per ton for bulk.
return ['PER_CONTAINER', 'PER_TON'];
case 'DEMURRAGE':
return ['PER_CONTAINER', 'PER_TON'];
case 'CANCELLATION':
return ['FLAT', 'PER_INVOICE'];
case 'CONSOLIDATION':
return ['PER_CONTAINER', 'FLAT'];
case 'SHIPPING_LINE':
case 'PIL_EXTRA_FEE':
return ['PER_CONTAINER', 'FLAT'];
default:
return ['FLAT', 'PER_TON', 'PER_CONTAINER'];
}
}
// Base freight + first/last mile scale with the cargo type.
switch (appliesTo) {
case 'CONTAINER':
return ['PER_CONTAINER', 'PER_WAGON'];
case 'BULK':
return ['PER_TON', 'PER_WAGON'];
case 'INTERCITY':
return ['PER_CONTAINER', 'PER_TON', 'PER_WAGON', 'PER_KM'];
case 'FIRST_MILE':
case 'LAST_MILE':
return ['PER_CONTAINER', 'PER_TON', 'PER_KM', 'FLAT'];
default:
return ['FLAT'];
}
}
/** The default (first / most natural) unit for a rate shape. */
export function defaultRateUnit(input: { appliesTo: RateAppliesTo; trigger: RateTrigger }): RateUnit {
return allowedRateUnits(input)[0];
}
/** True when `unit` is a valid weighting basis for the given rate shape. */
export function isRateUnitAllowed(input: {
appliesTo: RateAppliesTo;
trigger: RateTrigger;
unit: RateUnit;
}): boolean {
return allowedRateUnits(input).includes(input.unit);
}

View File

@@ -81,7 +81,6 @@ export type RateTrigger = typeof RATE_TRIGGERS[number];
@Entity({ schema: 'freight', name: 'rates' })
@Index(['rateType'])
@Index(['status'])
@Index(['effectiveFrom'])
@Index(['containerTypeId'])
@Index(['trigger'])
export class Rate extends BaseEntity {
@@ -131,10 +130,4 @@ export class Rate extends BaseEntity {
@Column({ name: 'approved_at', type: 'timestamptz', nullable: true })
approvedAt?: Date | null;
@Column({ name: 'effective_from', type: 'date' })
effectiveFrom!: Date;
@Column({ name: 'effective_to', type: 'date', nullable: true })
effectiveTo?: Date | null;
}

View File

@@ -5,7 +5,6 @@ import { ContainerType } from './container-type.entity';
@Entity({ schema: 'freight', name: 'weight_limit_rules' })
@Index(['containerTypeId'])
@Index(['tradeDirection'])
@Index(['effectiveFrom'])
export class WeightLimitRule extends BaseEntity {
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@@ -19,10 +18,4 @@ export class WeightLimitRule extends BaseEntity {
@Column({ name: 'max_vgm_tons', type: 'numeric', precision: 8, scale: 3, nullable: true })
maxVgmTons!: number;
@Column({ name: 'effective_from', type: 'date', nullable: true })
effectiveFrom!: Date;
@Column({ name: 'effective_to', type: 'date', nullable: true })
effectiveTo?: Date | null;
}

View File

@@ -4,6 +4,13 @@ import { Rate } from '../entities/rate.entity';
export interface IRatesRepository {
findById(id: string): Promise<Rate | null>;
findLiveRates(): Promise<Rate[]>;
findByPattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
}): Promise<Rate | null>;
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]>;
findAndCount(options?: FindManyOptions<Rate>): Promise<[Rate[], number]>;
create(data: Partial<Rate>): Promise<Rate>;

View File

@@ -7,6 +7,11 @@ export interface IWeightLimitRulesRepository {
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]>;
findByPattern(
containerTypeId: string,
tradeDirection: string,
excludeId?: string,
): Promise<WeightLimitRule | null>;
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]>;
findAndCount(options?: FindManyOptions<WeightLimitRule>): Promise<[WeightLimitRule[], number]>;
create(data: Partial<WeightLimitRule>): Promise<WeightLimitRule>;

View File

@@ -16,15 +16,50 @@ export class RatesRepository implements IRatesRepository {
}
findLiveRates(): Promise<Rate[]> {
const now = new Date();
return this.repo
.createQueryBuilder('rate')
.where('rate.status = :status', { status: 'LIVE' })
.andWhere('rate.effective_from <= :now', { now })
.andWhere('(rate.effective_to IS NULL OR rate.effective_to > :now)', { now })
.getMany();
}
/**
* Find a non-superseded rate matching an identity pattern — the same tuple the
* `UQ_rates_pattern` unique index enforces. Used to reject duplicates before
* insert so the admin gets a friendly error instead of a raw constraint fault.
* NULL scope columns are matched with IS NULL, mirroring the COALESCE index.
*/
findByPattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId?: string | null;
cargoTypeId?: string | null;
tradeDirection?: string | null;
}): Promise<Rate | null> {
const qb = this.repo
.createQueryBuilder('rate')
.where('rate.rate_type = :rateType', { rateType: pattern.rateType })
.andWhere('rate.rate_unit = :rateUnit', { rateUnit: pattern.rateUnit })
.andWhere('rate.status <> :superseded', { superseded: 'SUPERSEDED' });
if (pattern.containerTypeId) {
qb.andWhere('rate.container_type_id = :containerTypeId', { containerTypeId: pattern.containerTypeId });
} else {
qb.andWhere('rate.container_type_id IS NULL');
}
if (pattern.cargoTypeId) {
qb.andWhere('rate.cargo_type_id = :cargoTypeId', { cargoTypeId: pattern.cargoTypeId });
} else {
qb.andWhere('rate.cargo_type_id IS NULL');
}
if (pattern.tradeDirection) {
qb.andWhere('rate.trade_direction = :tradeDirection', { tradeDirection: pattern.tradeDirection });
} else {
qb.andWhere('rate.trade_direction IS NULL');
}
return qb.getOne();
}
findAll(options?: FindManyOptions<Rate>): Promise<Rate[]> {
return this.repo.find(options);
}

View File

@@ -22,7 +22,6 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
containerTypeId: string,
tradeDirection: string,
): Promise<WeightLimitRule[]> {
const now = new Date();
return this.repo
.createQueryBuilder('rule')
.innerJoinAndSelect('rule.containerType', 'ct')
@@ -31,11 +30,27 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
dir: tradeDirection,
both: 'BOTH',
})
.andWhere('rule.effective_from <= :now', { now })
.andWhere('(rule.effective_to IS NULL OR rule.effective_to > :now)', { now })
.getMany();
}
/**
* Find a rule matching the (containerType, tradeDirection) identity — the
* tuple enforced by `UQ_weight_limit_rules_pattern`. Used to reject duplicates
* before insert. Optionally excludes a row by id so updates don't self-collide.
*/
findByPattern(
containerTypeId: string,
tradeDirection: string,
excludeId?: string,
): Promise<WeightLimitRule | null> {
const qb = this.repo
.createQueryBuilder('rule')
.where('rule.container_type_id = :containerTypeId', { containerTypeId })
.andWhere('rule.trade_direction = :tradeDirection', { tradeDirection });
if (excludeId) qb.andWhere('rule.id <> :excludeId', { excludeId });
return qb.getOne();
}
findAll(options?: FindManyOptions<WeightLimitRule>): Promise<WeightLimitRule[]> {
return this.repo.find(options);
}

View File

@@ -1,8 +1,15 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CreateRateDto } from '../dto/create-rate.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { deriveRateType } from '../entities/rate-type.util';
import { allowedRateUnits, isRateUnitAllowed } from '../entities/rate-unit.util';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
@Injectable()
@@ -27,7 +34,7 @@ export class RatesService {
const [data, total] = await this.repository.findAndCount({
where,
order: { effectiveFrom: 'DESC' },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -46,6 +53,50 @@ export class RatesService {
return entity;
}
/**
* Normalise + validate the weighting unit for a rate shape. Overweight is
* always billed per excess ton, so its unit is forced to PER_TON regardless
* of what the client sent. Every other shape must pick a unit the pricing
* engine can actually apply (see `allowedRateUnits`).
*/
private resolveRateUnit(
appliesTo: Rate['appliesTo'],
trigger: Rate['trigger'],
requestedUnit: Rate['rateUnit'],
): Rate['rateUnit'] {
// Overweight is per-ton, full stop.
if (trigger === 'OVERWEIGHT') return 'PER_TON';
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
throw new BadRequestException(
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
);
}
return requestedUnit;
}
/**
* Reject a second rate with the same identity pattern (rateType + scope). With
* effective-date windows gone, two LIVE/DRAFT rates for the same pattern would
* make pricing ambiguous — so we allow exactly one per pattern.
*/
private async assertNoDuplicatePattern(pattern: {
rateType: string;
rateUnit: string;
containerTypeId: string | null;
cargoTypeId: string | null;
tradeDirection: string | null;
ignoreId?: string;
}): Promise<void> {
const existing = await this.repository.findByPattern(pattern);
if (existing && existing.id !== pattern.ignoreId) {
throw new ConflictException(
'A rate for this exact combination already exists. Edit or delete the existing rate instead of creating a duplicate.',
);
}
}
/** Create a rate in DRAFT status. */
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
const appliesTo = dto.appliesTo as Rate['appliesTo'];
@@ -57,25 +108,28 @@ export class RatesService {
const cargoTypeId = isSurcharge ? null : (dto.cargoTypeId ?? null);
const tradeDirection = isSurcharge ? null : (dto.tradeDirection ?? null);
const rateType = deriveRateType({
appliesTo,
trigger,
tradeDirection,
isBulk: Boolean(cargoTypeId),
});
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
await this.assertNoDuplicatePattern({ rateType, rateUnit, containerTypeId, cargoTypeId, tradeDirection });
return this.repository.create({
appliesTo,
trigger,
rateType: deriveRateType({
appliesTo,
trigger,
tradeDirection,
isBulk: Boolean(cargoTypeId),
}),
rateType,
containerTypeId,
cargoTypeId,
tradeDirection,
currency: dto.currency ?? 'USD',
rateValue: dto.rateValue,
rateUnit: dto.rateUnit as Rate['rateUnit'],
rateUnit,
status: 'DRAFT',
proposedByStaffId,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
});
}
@@ -110,22 +164,35 @@ export class RatesService {
? dto.tradeDirection
: existing.tradeDirection;
updates.containerTypeId = containerTypeId;
updates.cargoTypeId = cargoTypeId;
updates.tradeDirection = tradeDirection;
updates.containerTypeId = containerTypeId ?? null;
updates.cargoTypeId = cargoTypeId ?? null;
updates.tradeDirection = tradeDirection ?? null;
// Keep the derived rateType in sync with whatever changed.
updates.rateType = deriveRateType({
const rateType = deriveRateType({
appliesTo,
trigger,
tradeDirection,
isBulk: Boolean(cargoTypeId),
});
updates.rateType = rateType;
// Re-validate the unit against the (possibly changed) shape; overweight is
// forced to PER_TON.
const requestedUnit = (dto.rateUnit as Rate['rateUnit']) ?? existing.rateUnit;
updates.rateUnit = this.resolveRateUnit(appliesTo, trigger, requestedUnit);
// Guard the pattern uniqueness for the new identity, ignoring this row.
await this.assertNoDuplicatePattern({
rateType,
rateUnit: updates.rateUnit,
containerTypeId: updates.containerTypeId,
cargoTypeId: updates.cargoTypeId,
tradeDirection: updates.tradeDirection,
ignoreId: id,
});
updates.currency = dto.currency ?? existing.currency ?? 'USD';
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
if (dto.effectiveFrom) updates.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo) updates.effectiveTo = new Date(dto.effectiveTo);
const updated = await this.repository.update(id, updates);
if (!updated) throw new NotFoundException(`Rate ${id} not found`);
return updated;

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ConflictException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
import { WeightLimitRule } from '../entities/weight-limit-rule.entity';
@@ -30,7 +30,7 @@ export class WeightLimitRulesService {
const [data, total] = await this.repository.findAndCount({
where,
relations: { containerType: true },
order: { effectiveFrom: 'DESC' },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
@@ -44,26 +44,51 @@ export class WeightLimitRulesService {
return entity;
}
/**
* Reject a second rule for the same container + direction. One VGM limit per
* (container, direction) — otherwise the booking engine can't tell which
* applies.
*/
private async assertNoDuplicate(
containerTypeId: string,
tradeDirection: string,
ignoreId?: string,
): Promise<void> {
const existing = await this.repository.findByPattern(containerTypeId, tradeDirection, ignoreId);
if (existing) {
throw new ConflictException(
'A weight limit rule for this container type and trade direction already exists. Edit the existing rule instead.',
);
}
}
/** Create a new weight limit rule. */
async create(dto: CreateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.assertNoDuplicate(dto.containerTypeId, dto.tradeDirection);
return this.repository.create({
containerTypeId: dto.containerTypeId,
tradeDirection: dto.tradeDirection,
maxVgmTons: dto.maxVgmTons,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : null,
});
}
/** Update an existing weight limit rule. */
async update(id: string, dto: UpdateWeightLimitRuleDto): Promise<WeightLimitRule> {
await this.findById(id);
const existing = await this.findById(id);
const patch: Partial<WeightLimitRule> = {};
if (dto.containerTypeId !== undefined) patch.containerTypeId = dto.containerTypeId;
if (dto.tradeDirection !== undefined) patch.tradeDirection = dto.tradeDirection;
if (dto.maxVgmTons !== undefined) patch.maxVgmTons = dto.maxVgmTons;
if (dto.effectiveFrom !== undefined) patch.effectiveFrom = new Date(dto.effectiveFrom);
if (dto.effectiveTo !== undefined) patch.effectiveTo = new Date(dto.effectiveTo);
// Re-check uniqueness when the identity (container/direction) changes.
if (dto.containerTypeId !== undefined || dto.tradeDirection !== undefined) {
await this.assertNoDuplicate(
patch.containerTypeId ?? existing.containerTypeId,
patch.tradeDirection ?? existing.tradeDirection,
id,
);
}
const updated = await this.repository.update(id, patch);
if (!updated) throw new NotFoundException(`Weight limit rule ${id} not found`);
return updated;

View File

@@ -3,9 +3,9 @@ import {
listBatchWindowsForDate,
listBatchWindowsForBookings,
BATCH_WINDOW_START_HOURS,
boardWindowForTimestamp,
listBoardWindowsForRange,
listConfigBookingWindows,
groupBookingsIntoBoardWindows,
type BoardWindowConfig,
} from './batch-window.util';
describe('batch-window.util', () => {
@@ -54,83 +54,87 @@ describe('batch-window.util', () => {
});
});
describe('batch-window board windows (midnight-based 3h slots)', () => {
it('maps 04:00 EAT to the 03:0006:00 slot', () => {
// 01:00 UTC = 04:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T01:00:00.000Z'));
expect(w.label).toContain('03:00');
expect(w.label).toContain('06:00');
expect(w.date).toBe('2026-06-11');
expect(w.dateLabel).toContain('11 Jun');
});
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowDurationHours: 3,
reopenDelayMinutes: 90,
exportBookingLeadHours: 24,
};
it('maps 00:30 EAT to the 00:0003:00 slot of that EAT day', () => {
// 21:30 UTC on 10 Jun = 00:30 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-10T21:30:00.000Z'));
expect(w.label).toContain('00:00');
expect(w.label).toContain('03:00');
expect(w.date).toBe('2026-06-11');
});
it('maps 23:00 EAT to the final 21:0024:00 slot', () => {
// 20:00 UTC = 23:00 EAT on 11 Jun
const w = boardWindowForTimestamp(new Date('2026-06-11T20:00:00.000Z'));
expect(w.label).toContain('21:00');
expect(w.label).toContain('24:00');
expect(w.date).toBe('2026-06-11');
});
it('lists a continuous range open→departure clamped at both ends', () => {
// open 05 Jun 08:00 EAT (05:00 UTC) → departs 08 Jun 14:00 EAT (11:00 UTC)
const open = new Date('2026-06-05T05:00:00.000Z');
it('import: first window opens at windowOpenHour EAT, importWindowLeadDays before departure', () => {
// departs 08 Jun 14:00 EAT (11:00 UTC) → window day = 05 Jun, opens 08:00 EAT (05:00 UTC)
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listBoardWindowsForRange(open, departure);
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// Day 5: 06,09,12,15,18,21 = 6 ; Days 6,7: 8 each ; Day 8: 00,03,06,09,12 = 5
expect(windows).toHaveLength(6 + 8 + 8 + 5);
expect(windows[0].date).toBe('2026-06-05');
expect(windows[0].label).toContain('06:00');
expect(windows[0].label).toContain('09:00');
const last = windows[windows.length - 1];
expect(last.date).toBe('2026-06-08');
expect(last.label).toContain('12:00');
expect(last.label).toContain('15:00');
// chronological + unique keys
const keys = windows.map((w) => w.key);
expect(new Set(keys).size).toBe(keys.length);
expect(windows[0].label).toContain('08:00');
expect(windows[0].start.toISOString()).toBe('2026-06-05T05:00:00.000Z');
// end = open + windowDurationHours (3h) = 08:00 → 11:00 EAT (08:00 UTC)
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
it('handles a same-day open→departure range', () => {
const open = new Date('2026-06-05T05:00:00.000Z'); // 08:00 EAT (0609 slot)
const departure = new Date('2026-06-05T11:00:00.000Z'); // 14:00 EAT (1215 slot)
const windows = listBoardWindowsForRange(open, departure);
// 06,09,12 = 3 slots
expect(windows).toHaveLength(3);
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT
expect(windows.length).toBeGreaterThanOrEqual(2);
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
// all cycles stay on the same EAT booking day
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
});
it('buckets bookings by fullyExecutedAt and keeps empty + pending windows', () => {
const open = new Date('2026-06-05T05:00:00.000Z');
const departure = new Date('2026-06-06T11:00:00.000Z');
it('export: single FCFS window exportBookingLeadHours before departure', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('EXPORT', departure, cfg);
expect(windows).toHaveLength(1);
// 24h before 11:00 UTC on 08 Jun = 11:00 UTC on 07 Jun
expect(windows[0].start.toISOString()).toBe('2026-06-07T11:00:00.000Z');
expect(windows[0].end.toISOString()).toBe(departure.toISOString());
});
it('buckets bookings into config cycles and keeps empty + pending windows', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const items = [
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → 0609 on 5th
{ id: 'a', ts: new Date('2026-06-05T05:30:00.000Z') }, // 08:30 EAT → inside cycle 1
{ id: 'b', ts: null }, // pending
];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
open,
'IMPORT',
departure,
cfg,
'pending-contract',
);
const pending = map.get('pending-contract');
expect(pending?.items.map((i) => i.id)).toEqual(['b']);
const withA = [...map.values()].find((b) => b.items.some((i) => i.id === 'a'));
expect(withA?.window?.date).toBe('2026-06-05');
// empty slots are retained for the UI
// empty cycles are retained for the UI
const emptyCount = [...map.values()].filter(
(b) => b.window && b.items.length === 0,
).length;
expect(emptyCount).toBeGreaterThan(0);
});
it('attaches a booking made before the window opened to the first cycle', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const items = [{ id: 'early', ts: new Date('2026-06-01T00:00:00.000Z') }];
const map = groupBookingsIntoBoardWindows(
items,
(i) => i.ts,
'IMPORT',
departure,
cfg,
'pending-contract',
);
const withEarly = [...map.values()].find((b) =>
b.items.some((i) => i.id === 'early'),
);
expect(withEarly?.window?.date).toBe('2026-06-05');
expect(withEarly?.window?.label).toContain('08:00');
});
});

View File

@@ -230,14 +230,13 @@ export function listBatchWindowsForBookings(
}
// ---------------------------------------------------------------------------
// Board-display windows: full-day, midnight-based 3h slots over a date range.
// These are used ONLY for the batch-board UI grouping (not persisted, and
// independent of the cron intake hours above).
// Board-display windows: the REAL booking-window cycles derived from the
// train_scheduling_global_rules config (window open hour, lead days, duration,
// reopen delay) — NOT a fixed clock grid. Import shows each booking-window cycle
// (opens at windowOpenHour EAT, lasts windowDurationHours, reopens after
// reopenDelayMinutes until departure). Export shows the single FCFS lead window.
// ---------------------------------------------------------------------------
/** Midnight-based 3-hour slot starts (0003, 0306, … 2124). */
export const BOARD_WINDOW_HOURS = [0, 3, 6, 9, 12, 15, 18, 21] as const;
/** A board window carries an EAT calendar date in addition to the slot times. */
export interface BoardWindow extends BatchWindow {
/** EAT calendar day as ISO `YYYY-MM-DD`. */
@@ -246,6 +245,15 @@ export interface BoardWindow extends BatchWindow {
dateLabel: string;
}
/** Config fields the board needs to reconstruct booking-window cycles. */
export interface BoardWindowConfig {
importWindowLeadDays: number;
windowOpenHour: number;
windowDurationHours: number;
reopenDelayMinutes: number;
exportBookingLeadHours: number;
}
const dayLabelFmt = new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
@@ -257,119 +265,124 @@ function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/** Build a midnight-based 3h board window for an EAT calendar day + slot start hour. */
function boardWindowFromEatStart(
year: number,
month: number,
day: number,
startHour: number,
): BoardWindow {
const start = eatToUtc(year, month, day, startHour);
const endHour = startHour + 3; // 21 -> 24 (handled by Date.UTC roll-over)
const end = eatToUtc(year, month, day, endHour);
const endLabel = endHour >= 24 ? '24:00' : `${pad2(endHour)}:00`;
/** Wrap a [start, end] interval as a labelled BoardWindow keyed on its EAT day. */
function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
const { year, month, day } = eatParts(start);
return {
key: start.toISOString(),
start,
end,
label: formatWindowLabel(start, end, endLabel),
label: formatWindowLabel(start, end),
date: `${year}-${pad2(month)}-${pad2(day)}`,
dateLabel: dayLabelFmt.format(start),
};
}
/** Which midnight-based 3h EAT slot a timestamp falls in. */
export function boardWindowForTimestamp(date: Date): BoardWindow {
const { year, month, day, hour } = eatParts(date);
let startHour: (typeof BOARD_WINDOW_HOURS)[number] = 0;
for (const h of BOARD_WINDOW_HOURS) {
if (hour >= h) startHour = h;
}
return boardWindowFromEatStart(year, month, day, startHour);
}
/**
* Continuous list of board windows from `openDate` to `departureDate` (inclusive),
* clamped to the slot containing `openDate` on the first day and the slot
* containing `departureDate` on the last day. Returned in chronological order.
* The real booking-window cycles for a schedule, straight from config.
*
* IMPORT: first window opens at `windowOpenHour` EAT on `departure importWindowLeadDays`
* for `windowDurationHours`; if the train isn't full it reopens `reopenDelayMinutes`
* after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure.
*/
export function listBoardWindowsForRange(
openDate: Date,
departureDate: Date,
export function listConfigBookingWindows(
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
): BoardWindow[] {
const startWin = boardWindowForTimestamp(openDate);
const endWin = boardWindowForTimestamp(departureDate);
// Guard against an inverted range (departure before open).
if (endWin.start.getTime() < startWin.start.getTime()) {
return [startWin];
if (direction === 'EXPORT') {
const start = new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
return [boardWindowFromInterval(start, departure)];
}
const windows: BoardWindow[] = [];
const seen = new Set<string>();
// Walk day-by-day in EAT, emitting each day's slots, stepping via UTC noon to
// avoid any boundary ambiguity, then filter to [startWin.start, endWin.start].
let cursor = new Date(eatToUtc(
Number(startWin.date.slice(0, 4)),
Number(startWin.date.slice(5, 7)),
Number(startWin.date.slice(8, 10)),
12,
));
const lastDayMs = eatToUtc(
Number(endWin.date.slice(0, 4)),
Number(endWin.date.slice(5, 7)),
Number(endWin.date.slice(8, 10)),
12,
).getTime();
const durationMs = cfg.windowDurationHours * 3_600_000;
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
while (cursor.getTime() <= lastDayMs) {
const { year, month, day } = eatParts(cursor);
for (const h of BOARD_WINDOW_HOURS) {
const w = boardWindowFromEatStart(year, month, day, h);
if (
w.start.getTime() >= startWin.start.getTime() &&
w.start.getTime() <= endWin.start.getTime() &&
!seen.has(w.key)
) {
seen.add(w.key);
windows.push(w);
}
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
for (let cycle = 0; cycle < 12; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
if (
nextOpensAt.getTime() >= departure.getTime() ||
eatDay(nextOpensAt) !== eatDay(opensAt)
) {
break;
}
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
opensAt = nextOpensAt;
}
windows.sort(compareBatchWindows);
// Degenerate config (no window before departure) — surface a single window
// clamped to departure so the board still renders something meaningful.
if (windows.length === 0) {
windows.push(boardWindowFromInterval(new Date(departure.getTime() - durationMs), departure));
}
return windows;
}
/** Which config booking-window a timestamp falls in; null if before/after all of them. */
function configWindowForTimestamp(
windows: BoardWindow[],
date: Date,
): BoardWindow | null {
const ms = date.getTime();
for (const w of windows) {
if (ms >= w.start.getTime() && ms < w.end.getTime()) return w;
}
return null;
}
/**
* Group items into board windows spanning [openDate, departureDate]. Empty
* windows are kept so the UI shows every slot. Items whose timestamp falls
* outside the range still get their own window (nothing hidden). Items without
* a timestamp go to `pendingKey`.
* Group items into the real config booking-window cycles for a schedule. Empty
* windows are kept so the UI shows every cycle. Items whose timestamp falls
* outside every window (e.g. a booking created before the window opened) are
* attached to the nearest window by start time so nothing is hidden. Items
* without a timestamp go to `pendingKey`.
*/
export function groupBookingsIntoBoardWindows<T>(
items: T[],
getTimestamp: (item: T) => Date | null | undefined,
openDate: Date,
departureDate: Date,
direction: string | null | undefined,
departure: Date,
cfg: BoardWindowConfig,
pendingKey = 'pending-contract',
): Map<string, { window: BoardWindow | null; items: T[] }> {
const windows = listConfigBookingWindows(direction, departure, cfg);
const map = new Map<string, { window: BoardWindow | null; items: T[] }>();
for (const w of listBoardWindowsForRange(openDate, departureDate)) {
for (const w of windows) {
map.set(w.key, { window: w, items: [] });
}
map.set(pendingKey, { window: null, items: [] });
const firstWindow = windows[0] ?? null;
const lastWindow = windows[windows.length - 1] ?? null;
for (const item of items) {
const ts = getTimestamp(item);
if (!ts) {
map.get(pendingKey)!.items.push(item);
continue;
}
const w = boardWindowForTimestamp(ts);
if (!map.has(w.key)) {
map.set(w.key, { window: w, items: [] });
let w = configWindowForTimestamp(windows, ts);
if (!w) {
// Booked before the window opened → first cycle; after it closed → last cycle.
w =
firstWindow && ts.getTime() < firstWindow.start.getTime()
? firstWindow
: lastWindow;
}
if (!w) {
map.get(pendingKey)!.items.push(item);
continue;
}
map.get(w.key)!.items.push(item);
}

View File

@@ -269,5 +269,56 @@ describe('BookingBatchService — PAID reconcile', () => {
}),
);
});
it('reserves both partners of a consolidated pair together on one train', async () => {
// Two 20ft bookings, 1 container each — a shared wagon. Both in the pool.
const consol = (id: string, partnerId: string, priority: number): Booking =>
({
id,
reference: id,
isGovernment: false,
priorityScore: priority,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
consolidationPartnerId: partnerId,
bookingContainers: [{ quantity: 1 }],
}) as unknown as Booking;
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
consol('a', 'b', 30),
consol('b', 'a', 20),
]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// Both reserved on the same (first) train; neither reported unplaced.
const reservedIds = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
expect(reservedIds.sort()).toEqual(['a', 'b']);
expect(notifier.unplaced).not.toHaveBeenCalled();
});
it('skips a consolidated booking whose partner is not in the pool (both-or-neither)', async () => {
const lonely = {
id: 'a',
reference: 'a',
isGovernment: false,
priorityScore: 30,
status: 'FULLY_EXECUTED',
wagonsRequired: 1,
cargoTotalWeightVgm: 10,
freightType: 'CONTAINER',
consolidationPartnerId: 'missing-partner',
bookingContainers: [{ quantity: 1 }],
} as unknown as Booking;
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([lonely]);
await service.fillRouteDay(originYardId, destinationYardId, day);
// Never reserved — waits for its partner in a later cycle.
expect(notifier.payNow).not.toHaveBeenCalled();
});
});
});

View File

@@ -9,7 +9,7 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { SchedulerRegistry } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { DataSource, In } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingsRepository } from '../bookings/bookings.repository';
@@ -40,6 +40,7 @@ import {
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
@@ -89,6 +90,9 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
selectedForBatchAt: string | null;
allocationStatus: BookingAllocationStatus;
allocationIssue: string | null;
/** Set when this booking shares a wagon with a consolidation partner. */
consolidationPartnerId: string | null;
consolidationPartnerRef: string | null;
}
export interface BatchWindowGroup {
@@ -433,7 +437,7 @@ export class BookingBatchService implements OnModuleInit {
* fits the booking. Throws ConflictException when every train is full — the
* staff accept fails and no more export bookings are taken.
*/
async pickExportSchedule(booking: Booking): Promise<string> {
async pickExportSchedule(booking: Booking, need?: Capacity): Promise<string> {
if (!booking.scheduledDate) {
throw new BadRequestException('Booking has no scheduled date');
}
@@ -471,7 +475,7 @@ export class BookingBatchService implements OnModuleInit {
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const need = this.needFor(booking, wagonLengths);
const required = need ?? this.needFor(booking, wagonLengths);
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
@@ -480,18 +484,47 @@ export class BookingBatchService implements OnModuleInit {
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (this.fits(need, budget)) return schedule.id;
if (this.fits(required, budget)) return schedule.id;
}
throw new ConflictException('Train is full — no export capacity left for this day');
}
/**
* Reserve an accepted export booking on its picked train and open the pay
* window immediately (payment notification goes out on reserve). Marks the
* train FULL when this reservation exhausts the wagon budget.
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
* A consolidated booking reserves as a pair only once BOTH partners are ready
* (FULLY_EXECUTED): the second partner's accept triggers the pair reservation
* against the combined shared-wagon need; the first partner's accept just waits.
* Throws ConflictException (before this booking is persisted-ready) when there is
* no export capacity for the day, so staff accept fails.
*/
async reserveExportBooking(booking: Booking, scheduleId: string): Promise<void> {
await this.reserve(booking, scheduleId);
async acceptExportBooking(booking: Booking): Promise<void> {
const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) {
const scheduleId = await this.pickExportSchedule(booking);
await this.reserveOnExport([booking], scheduleId);
return;
}
const partner = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: partnerId }, relations: { company: true, bookingContainers: true } });
// Partner not yet accepted → this booking is now FULLY_EXECUTED and simply
// waits; the partner's later accept will reserve the pair.
if (!partner || partner.status !== 'FULLY_EXECUTED') {
return;
}
const wagonLengths = await this.loadWagonLengths();
const need = this.combinedNeed(booking, partner, wagonLengths);
const scheduleId = await this.pickExportSchedule(booking, need);
await this.reserveOnExport([booking, partner], scheduleId);
}
/** Reserve one or two (consolidated) export bookings on a train and open pay windows. */
private async reserveOnExport(
bookings: Booking[],
scheduleId: string,
): Promise<void> {
for (const b of bookings) await this.reserve(b, scheduleId);
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
@@ -557,6 +590,9 @@ export class BookingBatchService implements OnModuleInit {
const board: BatchBoardSchedule[] = [];
for (const s of schedules) {
if (s.status === "ARRIVED" || s.status === "CANCELLED") continue;
// Batch board is IMPORT-only: export is FCFS with no batch/priority calc,
// and domestic/legacy schedules run the legacy fill, not the window batch.
if (s.direction !== "IMPORT") continue;
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
const linkedIds = new Set(links.map((l) => l.bookingId));
@@ -597,6 +633,12 @@ export class BookingBatchService implements OnModuleInit {
if (s.status === "ARRIVED" || s.status === "CANCELLED") {
throw new BadRequestException("Schedule is no longer active");
}
// Batch board is IMPORT-only (export is FCFS, no batch/priority calc).
if (s.direction !== "IMPORT") {
throw new BadRequestException(
"The batch board only covers import schedules",
);
}
const wagonLengths = await this.loadWagonLengths();
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
@@ -622,6 +664,27 @@ export class BookingBatchService implements OnModuleInit {
allocationPreview.issues.map((i) => [i.bookingId, i]),
);
// Resolve consolidation-partner references for the shared-wagon badge. Most
// partners are on this same schedule; look up any that aren't in one query.
const refById = new Map(
bookings.map((b) => [b.id, b.reference ?? b.id.slice(0, 8)]),
);
const missingPartnerIds = [
...new Set(
bookings
.map((b) => b.consolidationPartnerId)
.filter((id): id is string => Boolean(id) && !refById.has(id!)),
),
];
if (missingPartnerIds.length) {
const partners = await this.dataSource
.getRepository(Booking)
.find({ where: { id: In(missingPartnerIds) } });
for (const p of partners) {
refById.set(p.id, p.reference ?? p.id.slice(0, 8));
}
}
const items: BatchBoardBookingDetail[] = bookings.map((b) => {
const need = this.needFor(b, wagonLengths);
const alloc = allocationByBooking.get(b.id);
@@ -647,20 +710,27 @@ export class BookingBatchService implements OnModuleInit {
: null,
allocationStatus: alloc?.status ?? "NOT_ATTEMPTED",
allocationIssue: alloc?.issue ?? null,
consolidationPartnerId: b.consolidationPartnerId ?? null,
consolidationPartnerRef: b.consolidationPartnerId
? (refById.get(b.consolidationPartnerId) ?? null)
: null,
};
});
const loco = s.trainSet?.locomotive ?? null;
// Display windows span the whole booking window: from when it opened
// (schedule creation) through the scheduled departure, in 3-hour EAT slots.
const openDate = s.createdAt ?? s.scheduledDepartureDate ?? new Date();
// Display windows are the REAL booking-window cycles from the global-rules
// config (import: opens at windowOpenHour EAT importWindowLeadDays before
// departure, lasts windowDurationHours, reopens per reopenDelayMinutes;
// export: single FCFS lead window) — not a fixed clock grid.
const windowCfg = await this.trainSchedulingService.getWindowConfig();
const departureDate = s.scheduledDepartureDate ?? new Date();
const windowBuckets = groupBookingsIntoBoardWindows(
items,
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
openDate,
s.direction ?? null,
departureDate,
windowCfg,
);
const emptyCounts = () => ({
@@ -903,13 +973,19 @@ export class BookingBatchService implements OnModuleInit {
}
const pool = await this.bookingsRepository.findBatchPool(scheduleId);
const units = this.groupConsolidatedPool(pool);
let armed = false;
for (const booking of pool) {
const need = this.needFor(booking, wagonLengths);
for (const unit of units) {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
if (!this.fits(need, budget)) {
if (booking.isGovernment) {
if (isGov) {
budget = await this.preemptForGovernment(
scheduleId,
need,
@@ -918,14 +994,16 @@ export class BookingBatchService implements OnModuleInit {
);
if (!this.fits(need, budget)) continue; // still doesn't fit even after preempt
} else {
continue; // skip a booking that exceeds weight/length/wagons, try the next
continue; // skip a unit that exceeds weight/length/wagons, try the next
}
}
if (booking.isGovernment) {
if (isGov) {
await this.allocate(scheduleId, booking, "gov");
if (partner) await this.allocate(scheduleId, partner, "gov");
} else {
await this.reserve(booking, scheduleId);
if (partner) await this.reserve(partner, scheduleId);
armed = true;
}
budget = this.subtract(budget, need);
@@ -1013,15 +1091,23 @@ export class BookingBatchService implements OnModuleInit {
destinationYardId,
day,
);
// Consolidated partners collapse into one atomic unit (both-or-neither); a
// consolidated booking whose partner isn't ready this cycle is skipped.
const units = this.groupConsolidatedPool(pool);
for (const booking of pool) {
const need = this.needFor(booking, wagonLengths);
for (const unit of units) {
const { primary: booking, partner } = unit;
const isPair = partner != null;
const need = isPair
? this.combinedNeed(booking, partner, wagonLengths)
: this.needFor(booking, wagonLengths);
const isGov = booking.isGovernment || (partner?.isGovernment ?? false);
// First train (earliest departure) that fits this booking as-is.
// First train (earliest departure) that fits this unit as-is.
let target = trains.find((t) => this.fits(need, t.budget));
if (!target && booking.isGovernment) {
// Government booking fits nowhere on its own — try to preempt commercial
if (!target && isGov) {
// Government fits nowhere on its own — try to preempt commercial
// on each train (earliest first) until one frees enough room.
for (const t of trains) {
t.budget = await this.preemptForGovernment(
@@ -1038,41 +1124,45 @@ export class BookingBatchService implements OnModuleInit {
}
if (!target) {
// Fits no train whole. Import GENERAL-contract commercial bookings get a
// partial-capacity offer on the train with the most free wagons: pay =
// accept the split (remainder returns to the contract cap), no pay =
// booking stays whole and expires for this train.
const partialTarget = [...trains]
.filter((t) => t.budget.wagons >= 1)
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
if (
partialTarget &&
!booking.isGovernment &&
booking.tradeDirection === "IMPORT" &&
booking.contractKind === "GENERAL" &&
this.splitService
) {
const offered = await this.tryPartialOffer(
booking,
partialTarget.id,
partialTarget.budget,
need,
);
if (offered) {
partialTarget.budget = this.subtract(partialTarget.budget, offered);
partialTarget.armed = true;
continue;
// A consolidated pair is placed whole or not at all — never split.
if (!isPair) {
// Fits no train whole. Import GENERAL-contract commercial bookings get a
// partial-capacity offer on the train with the most free wagons.
const partialTarget = [...trains]
.filter((t) => t.budget.wagons >= 1)
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
if (
partialTarget &&
!booking.isGovernment &&
booking.tradeDirection === "IMPORT" &&
booking.contractKind === "GENERAL" &&
this.splitService
) {
const offered = await this.tryPartialOffer(
booking,
partialTarget.id,
partialTarget.budget,
need,
);
if (offered) {
partialTarget.budget = this.subtract(partialTarget.budget, offered);
partialTarget.armed = true;
continue;
}
}
}
// Stays in the pool, retried next batch/window cycle.
this.notifier.unplaced(booking, day);
if (partner) this.notifier.unplaced(partner, day);
continue;
}
if (booking.isGovernment) {
if (isGov) {
await this.allocate(target.id, booking, "gov");
if (partner) await this.allocate(target.id, partner, "gov");
} else {
await this.reserve(booking, target.id);
if (partner) await this.reserve(partner, target.id);
target.armed = true;
}
target.budget = this.subtract(target.budget, need);
@@ -1099,6 +1189,8 @@ export class BookingBatchService implements OnModuleInit {
need: Capacity,
): Promise<Capacity | null> {
if (!this.splitService) return null;
// A consolidated booking is already half of a shared wagon — never split it.
if (booking.consolidationPartnerId) return null;
if (await this.splitService.findOpenOffer(booking.id)) return null;
const wagonLengths = await this.loadWagonLengths();
@@ -1143,29 +1235,69 @@ export class BookingBatchService implements OnModuleInit {
return capacity > 0 ? capacity : 60;
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
/**
* Settle a schedule's reserved bookings. `expireUnpaidUnknownDeadline` decides
* how to treat a reservation with no deadline (durable path: leave it; timeout
* path: expire it). Consolidated pairs settle atomically: both allocate only
* when both paid; if either partner expires, both expire (a half-paid shared
* wagon must not ship). Returns whether anything changed.
*/
private async settleReserved(
scheduleId: string,
expireUnpaidUnknownDeadline: boolean,
): Promise<boolean> {
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
const byId = new Map(reserved.map((b) => [b.id, b]));
const done = new Set<string>();
let anySettled = false;
for (const booking of reserved) {
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: false;
const isPaid = (b: Booking) =>
b.paymentStatus === "PAID" || b.status === "PAID";
const isExpired = (b: Booking) =>
b.paymentDeadline
? b.paymentDeadline.getTime() <= now
: expireUnpaidUnknownDeadline;
if (paid) {
for (const booking of reserved) {
if (done.has(booking.id)) continue;
const partner = booking.consolidationPartnerId
? (byId.get(booking.consolidationPartnerId) ?? null)
: null;
if (partner) {
done.add(booking.id);
done.add(partner.id);
// Both-or-neither: allocate the shared wagon only when both partners paid;
// if either lapsed, expire both so no half-paid wagon rides.
if (isPaid(booking) && isPaid(partner)) {
await this.allocate(scheduleId, booking, "paid");
await this.allocate(scheduleId, partner, "paid");
anySettled = true;
} else if (isExpired(booking) || isExpired(partner)) {
await this.expire(booking);
await this.expire(partner);
anySettled = true;
}
continue;
}
done.add(booking.id);
if (isPaid(booking)) {
await this.allocate(scheduleId, booking, "paid");
anySettled = true;
} else if (expired) {
} else if (isExpired(booking)) {
await this.expire(booking);
anySettled = true;
}
}
return anySettled;
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const anySettled = await this.settleReserved(scheduleId, false);
if (anySettled) await this.fillSchedule(scheduleId);
}
@@ -1174,25 +1306,7 @@ export class BookingBatchService implements OnModuleInit {
/** Allocate paid reservations, expire the rest, then top up. */
async settleBatch(scheduleId: string): Promise<void> {
this.removeTimeout(scheduleId);
const reserved =
await this.bookingsRepository.findReservedForSchedule(scheduleId);
const now = Date.now();
for (const booking of reserved) {
const paid =
booking.paymentStatus === "PAID" || booking.status === "PAID";
const expired = booking.paymentDeadline
? booking.paymentDeadline.getTime() <= now
: true;
if (paid) {
await this.allocate(scheduleId, booking, "paid");
} else if (expired) {
await this.expire(booking);
}
// else: still within window (rare at settle) → leave for the re-armed timeout
}
await this.settleReserved(scheduleId, true);
await this.fillSchedule(scheduleId);
void this.triggerWagonAllocation(scheduleId);
}
@@ -1452,6 +1566,74 @@ export class BookingBatchService implements OnModuleInit {
// ---- capacity helpers -----------------------------------------------------
/**
* Collapse consolidated partners into single pool entries so the fill treats a
* shared-wagon pair as one atomic unit (both-or-neither). For each pool entry:
* - no `consolidationPartnerId` → passes through as a lone booking.
* - consolidated + partner also in this pool → emitted ONCE (at the position of
* whichever partner ranks first) as a pair; the partner is not emitted again.
* - consolidated + partner NOT in this pool → dropped (can't ship half a wagon;
* it waits for the partner to become ready in a later cycle).
* The pool is already priority-ordered, so emitting the pair at the first-seen
* partner's slot ranks it by the stronger (max-priority) partner automatically.
*/
private groupConsolidatedPool(
pool: Booking[],
): Array<{ primary: Booking; partner: Booking | null }> {
const byId = new Map(pool.map((b) => [b.id, b]));
const emitted = new Set<string>();
const units: Array<{ primary: Booking; partner: Booking | null }> = [];
for (const booking of pool) {
if (emitted.has(booking.id)) continue;
const partnerId = booking.consolidationPartnerId ?? null;
if (!partnerId) {
emitted.add(booking.id);
units.push({ primary: booking, partner: null });
continue;
}
const partner = byId.get(partnerId) ?? null;
if (!partner) {
// Both-or-neither: partner not ready in this pool → skip the pair entirely.
emitted.add(booking.id);
continue;
}
emitted.add(booking.id);
emitted.add(partner.id);
units.push({ primary: booking, partner });
}
return units;
}
/**
* Combined capacity need of a consolidated pair sharing wagons. The whole point of
* consolidation is that the two partial 20ft counts pack onto the SAME wagons, so
* the shared wagon count is ceil((c1+c2)/2) — strictly fewer than summing the two
* independently-rounded-up needs (that is the capacity consolidation saves).
*/
private combinedNeed(
primary: Booking,
partner: Booking,
wagonLengths: WagonLengths,
): Capacity {
const containers = (b: Booking): number =>
(b.bookingContainers ?? []).reduce((sum, c) => sum + Number(c.quantity ?? 0), 0);
const totalContainers = containers(primary) + containers(partner);
const sharedWagons =
totalContainers > 0
? Math.ceil(totalContainers / MAX_TEU_SLOTS_PER_WAGON)
: this.wagonsFor(primary) + this.wagonsFor(partner);
const weightTons =
Number(primary.cargoTotalWeightVgm ?? 0) + Number(partner.cargoTotalWeightVgm ?? 0);
return {
wagons: sharedWagons,
weightTons,
lengthMeters: bookingTrainLengthMeters(primary.freightType, sharedWagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
};
}
private wagonsFor(booking: Booking): number {
if (booking.wagonsRequired && booking.wagonsRequired > 0) {
return Math.ceil(booking.wagonsRequired);

View File

@@ -3257,6 +3257,53 @@ export class TrainSchedulingService {
return days.includes(day);
}
/**
* Enforce the config-driven booking window at booking-create time.
*
* A booking is only allowed when the route has an OPEN departure the customer
* can join for the requested day — which, because the window engine keeps
* `bookingWindowStatus === 'OPEN'` in lockstep with the live window, means:
* - IMPORT: the day's window is currently open (opens at `windowOpenHour` EAT,
* `importWindowLeadDays` before departure, for `windowDurationHours`).
* - EXPORT: now is within `exportBookingLeadHours` before that departure (FCFS).
*
* `getBookableScheduleEntities` filters on `bookingWindowStatus === 'OPEN'`, so
* both gates are satisfied by checking that route for open departures. When a
* specific day is requested, require an open departure on that EAT day; when no
* day is given, require at least one open departure on the route at all.
* Throws `BadRequestException` when the window is closed. No-ops when the route
* yards are unknown (nothing to gate against).
*/
async assertBookingWindowOpen(input: {
originYardId?: string | null;
destinationYardId?: string | null;
scheduledDate?: Date | string | null;
direction?: string | null;
}): Promise<void> {
const { originYardId, destinationYardId } = input;
if (!originYardId || !destinationYardId) return;
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
if (days.length === 0) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'The export booking window for this route is not open yet'
: 'The import booking window for this route is closed right now',
);
}
if (input.scheduledDate) {
const day = eatDay(new Date(input.scheduledDate));
if (!days.includes(day)) {
throw new BadRequestException(
input.direction === 'EXPORT'
? 'No departure is within the export booking window on the selected day'
: 'The import booking window is not open for the selected day',
);
}
}
}
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {