automation of loading and unloading

This commit is contained in:
Hagernesh
2026-06-17 22:33:06 +00:00
1637 changed files with 375027 additions and 20437 deletions

View File

@@ -1,6 +1,9 @@
import {
BadRequestException,
forwardRef,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'stream';
@@ -19,9 +22,13 @@ import { assertBookingStatus } from './booking-status.util';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { ContractSignerRole } from './entities/booking-contract-signature.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { SignaturesService } from '../signatures/signatures.service';
@Injectable()
export class BookingContractService {
private readonly logger = new Logger(BookingContractService.name);
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
@@ -30,6 +37,9 @@ export class BookingContractService {
private readonly viewModelBuilder: ContractViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
@Inject(forwardRef(() => BookingBatchService))
private readonly bookingBatchService: BookingBatchService,
private readonly signaturesService: SignaturesService,
) {}
buildContractSummary(booking: Booking): string {
@@ -67,10 +77,16 @@ export class BookingContractService {
return { summary };
}
async getContractView(bookingId: string): Promise<ContractViewDto> {
async getContractView(
bookingId: string,
viewerUserId?: string,
): Promise<ContractViewDto> {
const { view } = await this.viewModelBuilder.build(bookingId);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const savedSignature = viewerUserId
? ((await this.signaturesService.getForUser(viewerUserId)) ?? undefined)
: undefined;
return {
bookingId: view.bookingId,
reference: view.reference,
@@ -82,6 +98,7 @@ export class BookingContractService {
canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument,
signatures: view.signatures,
savedSignature,
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
};
}
@@ -92,7 +109,16 @@ export class BookingContractService {
const templateKey = this.templateResolver.resolve(booking);
const summary = this.buildContractSummary(booking);
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
// PDF rendering (Puppeteer/Chromium) is best-effort and must NOT block the contract
// from becoming ready — the document is (re)rendered lazily on view/download.
try {
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
} catch (err) {
this.logger.warn(
`Contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download once Chromium is available.`,
);
}
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
@@ -177,6 +203,23 @@ export class BookingContractService {
ipAddress: options.ipAddress ?? null,
});
// Persist the just-used signature to the signer's reusable profile so they
// don't have to redraw it on the next contract. Best-effort: a failure here
// must never block contract execution.
if (options.signerUserId) {
try {
await this.signaturesService.upsertForUser({
userId: options.signerUserId,
signerDisplayName: dto.signerDisplayName,
signatureImageBase64: dto.signatureImageBase64,
});
} catch (err) {
this.logger.warn(
`Could not save reusable signature for user ${options.signerUserId}: ${err}`,
);
}
}
const updates: Record<string, unknown> = {};
if (role === 'CUSTOMER') {
@@ -191,11 +234,20 @@ export class BookingContractService {
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
if (role === 'STAFF' && updated?.trainScheduleId) {
this.bookingBatchService.enqueueScheduleProcessing(updated.trainScheduleId);
}
try {
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
} catch (err) {
this.logger.warn(
`Signed-contract PDF deferred for ${booking.reference}: ${err}. It will render on view/download.`,
);
}
return updated!;
}

View File

@@ -16,7 +16,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
statuses: readonly string[] | null;
}> = [
{ key: 'all', statuses: null },
{ key: 'intake', statuses: ['SUBMITTED'] },
{ key: 'intake', statuses: ['SUBMITTED', 'PENDING_CONSOLIDATION'] },
{
key: 'in_approval',
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
@@ -28,7 +28,7 @@ export const BOOKING_LIST_TABS: ReadonlyArray<{
{ key: 'payment', statuses: ['FULLY_EXECUTED'] },
{
key: 'operations',
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED','PAID'],
statuses: ['IN_TRANSIT', 'PAID'],
},
{ key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },

View File

@@ -14,6 +14,11 @@ export function computeNextStep(
const { status } = booking;
switch (status) {
case 'PRICE_CHANGED_PENDING_CONFIRM':
return {
action: 'CONFIRM_SUBMIT',
description: 'Price has changed since preview; confirm to submit booking',
};
case 'SUBMITTED':
return {
action: 'ACCEPT_INTAKE',

View File

@@ -4,56 +4,49 @@ import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { PaymentService } from '../payment/payment.service';
import { PaymentStatus } from '../payment/entities/payment.entity';
import { PaymentMethodTypeEnum } from '../payment/payments.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto { }
const NON_TERMINAL_STATUSES: PaymentStatus[] = [
"action-required",
"processing",
"success",
];
@Injectable()
export class BookingPaymentService {
constructor(private readonly bookingsRepository: BookingsRepository, private readonly paymentService: PaymentService) { }
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly paymentService: PaymentService,
) { }
async pay(
bookingId: string,
): Promise<{ redirectUrl: string }> {
async pay(bookingId: string): Promise<{ redirectUrl: string }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
assertBookingStatus(booking, ['FULLY_EXECUTED', 'SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', '']);
// const receipt = this.buildMockReceipt(booking);
// const updated = await this.bookingsRepository.update(bookingId, {
// status: 'PAID',
// paymentStatus: 'PAID',
// } as never);
const resp = await this.paymentService.pay(booking.totalAmount, "ETB", "telebirr", "payment for booking", 'booking', (_) => {
return new Promise((resp, _) => {
resp({
id: booking.id,
type: "booking"
})
});
})
return {
redirectUrl: resp.clientAction.type == "REDIRECT" ? `http://localhost:3001/api/payments/telebirr/${booking.id}` : ""
const existing = await this.paymentService.findBookingById(bookingId);
if (existing && NON_TERMINAL_STATUSES.includes(existing.status)) {
if (existing.clientAction) {
const action = existing.clientAction as { type?: string; url?: string };
if (action.type === "REDIRECT" && action.url) {
return { redirectUrl: action.url };
}
}
}
const resp = await this.paymentService.initiatePayment({
bookingId,
method: PaymentMethodTypeEnum.TELEBIRR,
platform: "web",
});
const action = resp.clientAction as { type?: string; url?: string } | undefined;
return {
redirectUrl: action?.type === "REDIRECT" ? (action.url ?? "") : "",
};
}
// private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
// const timestamp = Date.now();
// const isEtb = booking.paymentCurrency === 'ETB';
// const prefix = isEtb ? 'TB' : 'CARD';
// const provider = isEtb ? 'TELEBIRR' : 'CARD';
// return {
// success: true,
// provider,
// providerRef: `${prefix}-${booking.reference}-${timestamp}`,
// amount: booking.totalAmount,
// currency: booking.paymentCurrency,
// paidAt: new Date().toISOString(),
// };
// }
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -0,0 +1,129 @@
import { BookingPricingService } from './booking-pricing.service';
import type { Booking } from './entities/booking.entity';
import type { Rate } from '../rule-engine/entities/rate.entity';
const MOCK_CBE_RATE = 130;
describe('BookingPricingService — domestic corridor', () => {
const intercityBulkUsd: Rate = {
id: 'rate-intercity-bulk-usd',
rateType: 'INTERCITY_BULK',
currency: 'USD',
rateValue: 35,
rateUnit: 'PER_TON',
status: 'LIVE',
containerTypeId: null,
} as Rate;
const intercityContainerUsd: Rate = {
id: 'rate-intercity-container-usd',
rateType: 'INTERCITY_CONTAINER',
currency: 'USD',
rateValue: 400,
rateUnit: 'PER_CONTAINER',
status: 'LIVE',
containerTypeId: null,
} as Rate;
let service: BookingPricingService;
let bookingsRepository: { calculateWagonCount: jest.Mock };
let ratesService: { findLiveRates: jest.Mock };
let cbeExchangeService: { getUsdToEtbRate: jest.Mock };
beforeEach(() => {
bookingsRepository = { calculateWagonCount: jest.fn().mockResolvedValue(2) };
ratesService = {
findLiveRates: jest.fn().mockResolvedValue([intercityBulkUsd, intercityContainerUsd]),
};
cbeExchangeService = {
getUsdToEtbRate: jest.fn().mockResolvedValue(MOCK_CBE_RATE),
};
service = new BookingPricingService(
bookingsRepository as never,
{} as never,
{} as never,
ratesService as never,
{} as never,
cbeExchangeService as never,
);
});
it('prices domestic bulk in ETB using INTERCITY_BULK USD rate × CBE exchange rate', async () => {
const booking = {
id: 'b-1',
freightType: 'BULK',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 120,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
expect(result.lineItems[0].currency).toBe('ETB');
expect(result.lineItems[0].amount).toBe(Math.round(35 * 120 * MOCK_CBE_RATE));
});
it('prices domestic bulk in USD using INTERCITY_BULK USD rate directly', async () => {
const booking = {
id: 'b-1-usd',
freightType: 'BULK',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'USD',
cargoTotalWeightVgm: 120,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: { containers: [] },
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
}
).computeBaseRailLinesWithRates(booking, { containers: [] });
expect(result.lineItems).toHaveLength(1);
expect(result.lineItems[0].code).toBe('INTERCITY_BULK');
expect(result.lineItems[0].currency).toBe('USD');
expect(result.lineItems[0].amount).toBe(35 * 120);
});
it('prices domestic container in ETB using INTERCITY_CONTAINER USD fallback × CBE rate', async () => {
const booking = {
id: 'b-2',
freightType: 'CONTAINER',
tradeDirection: 'DOMESTIC',
paymentCurrency: 'ETB',
cargoTotalWeightVgm: 50,
bookingContainers: [],
} as unknown as Booking;
const result = await (
service as unknown as {
computeBaseRailLinesWithRates: (
b: Booking,
input: {
containers: Array<{ containerTypeId: string; quantity: number }>;
},
) => Promise<{ lineItems: Array<{ amount: number; code: string; currency: string }> }>;
}
).computeBaseRailLinesWithRates(booking, {
containers: [{ containerTypeId: 'ct-20', quantity: 3 }],
});
expect(result.lineItems.some((l) => l.code === 'INTERCITY_CONTAINER')).toBe(true);
const line = result.lineItems.find((l) => l.code === 'INTERCITY_CONTAINER')!;
expect(line.currency).toBe('ETB');
});
});

View File

@@ -4,6 +4,7 @@ import { ContainerTypesService } from '../rule-engine/services/container-types.s
import { RatesService } from '../rule-engine/services/rates.service';
import { ServiceTypesService } from '../rule-engine/services/service-types.service';
import { Rate } from '../rule-engine/entities/rate.entity';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
import {
AppliedCargoModifier,
BookingEvaluationInput,
@@ -14,6 +15,24 @@ import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
export interface ComputedPriceResult {
lineItems: PriceLineItemDto[];
totalAmount: number;
currency: string;
usedRates: Rate[];
appliedModifiers: AppliedCargoModifier[];
priorityScore: number;
warnings: string[];
hardBlocked: string[];
}
type StoredPricingBreakdown = {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
generatedAt?: string;
} | null;
@Injectable()
export class BookingPricingService {
constructor(
@@ -22,74 +41,158 @@ export class BookingPricingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService,
private readonly cbeExchangeService: CbeExchangeService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['DRAFT']);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
const evalInput = await this.buildEvalInputForBooking(booking);
console.log('evalInput----', evalInput);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const baseLines = await this.computeBaseRailLines(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
for (const mod of ruleResult.appliedModifiers) {
const item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount,
currency: mod.currency,
};
lineItems.push(item);
total += mod.calculatedAmount;
}
await this.persistPriceRun(bookingId, ruleResult.appliedModifiers, total);
const computed = await this.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.bookingsRepository.update(bookingId, {
totalAmount: total,
priorityScore: ruleResult.priorityScore,
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
return {
bookingId,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
warnings: computed.warnings,
};
}
async computePriceForBooking(booking: Booking): Promise<ComputedPriceResult> {
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const { lineItems: baseLines, usedRates: baseRates } =
await this.computeBaseRailLinesWithRates(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
const liveRates = await this.ratesService.findLiveRates();
const rateById = new Map(liveRates.map((r) => [r.id, r]));
const usedRatesMap = new Map(baseRates.map((r) => [r.id, r]));
for (const mod of ruleResult.appliedModifiers) {
const usdAmount = mod.calculatedAmount;
const convertedAmount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
const item: PriceLineItemDto = {
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: convertedAmount,
currency: paymentCurrency,
};
lineItems.push(item);
total += convertedAmount;
const rate = rateById.get(mod.rateId);
if (rate) usedRatesMap.set(rate.id, rate);
}
return {
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
lineItems,
usedRates: [...usedRatesMap.values()],
appliedModifiers: ruleResult.appliedModifiers,
priorityScore: ruleResult.priorityScore,
warnings: ruleResult.warnings,
hardBlocked: ruleResult.hardBlocked,
};
}
pricesMatch(stored: StoredPricingBreakdown, computed: ComputedPriceResult): boolean {
if (!stored?.lineItems?.length) return false;
if (Number(stored.totalAmount) !== computed.totalAmount) return false;
return (
this.lineItemsSignature(stored.lineItems) ===
this.lineItemsSignature(computed.lineItems)
);
}
async createPricingSnapshots(
bookingId: string,
usedRates: Rate[],
appliedModifiers: AppliedCargoModifier[],
): Promise<void> {
await this.bookingsRepository.clearPricingArtifacts(bookingId);
const snapshots = await this.ruleEngineService.snapshotRates(bookingId, usedRates);
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
const rows = appliedModifiers
.map((m) => {
const snapshotId = snapshotByRateId.get(m.rateId);
if (!snapshotId) return null;
return {
bookingId,
surchargeTypeId: m.surchargeTypeId,
triggerValue: m.triggerValue,
calculatedAmount: m.calculatedAmount,
rateSnapshotId: snapshotId,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (rows.length > 0) {
await this.bookingsRepository.createCargoModifiers(rows);
}
}
async buildEvalInputForBooking(booking: Booking): Promise<BookingEvaluationInput> {
const containers = await Promise.all(
(booking.bookingContainers ?? []).map(async (bc) => {
const ct = await this.containerTypesService.findById(bc.containerTypeId);
const vgm = Number(bc.vgmPerUnitTons);
const qty = bc.quantity;
return {
containerTypeId: bc.containerTypeId,
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
};
}),
(booking.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map(async (bc) => {
const ct = await this.containerTypesService.findById(bc.containerTypeId);
const vgm = Number(bc.vgmPerUnitTons);
const qty = bc.quantity;
return {
containerTypeId: bc.containerTypeId,
quantity: qty,
vgmPerUnitTons: vgm,
totalVgmTons: qty * vgm,
isReefer: ct.isReefer,
};
}),
);
// Wagon count is persisted per container line at booking creation; sum it.
const totalWagons =
booking.freightType === 'CONTAINER'
? Math.ceil(
(booking.bookingContainers ?? []).reduce(
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
0,
),
)
: 0;
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId ?? null,
@@ -97,8 +200,10 @@ export class BookingPricingService {
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous,
isGovernment: booking.isGovernment,
allowConsolidation: booking.allowConsolidation,
shippingLineId: booking.shippingLineId,
totalWagons,
containers,
};
}
@@ -115,11 +220,7 @@ export class BookingPricingService {
totalAmount: number;
currency: string;
}> {
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
} | null;
const stored = booking.pricingBreakdown as StoredPricingBreakdown;
if (stored?.lineItems?.length) {
return {
@@ -129,41 +230,28 @@ export class BookingPricingService {
};
}
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
const lineItems: PriceLineItemDto[] = [];
let total = 0;
const computed = await this.computePriceForBooking(booking);
const baseLines = await this.computeBaseRailLines(booking, evalInput);
for (const line of baseLines) {
lineItems.push(line);
total += line.amount;
}
for (const mod of ruleResult.appliedModifiers) {
lineItems.push({
code: mod.surchargeTypeCode,
description: `Surcharge: ${mod.surchargeTypeCode}`,
amount: mod.calculatedAmount,
currency: mod.currency,
});
total += mod.calculatedAmount;
}
if (lineItems.length === 0) {
total = Number(booking.totalAmount);
lineItems.push({
code: 'TOTAL',
description: 'Contract total',
amount: total,
if (computed.lineItems.length === 0) {
const total = Number(booking.totalAmount);
return {
lineItems: [
{
code: 'TOTAL',
description: 'Contract total',
amount: total,
currency: booking.paymentCurrency,
},
],
totalAmount: total,
currency: booking.paymentCurrency,
});
};
}
return {
lineItems,
totalAmount: total || Number(booking.totalAmount),
currency: booking.paymentCurrency,
lineItems: computed.lineItems,
totalAmount: computed.totalAmount || Number(booking.totalAmount),
currency: computed.currency,
};
}
@@ -190,14 +278,16 @@ export class BookingPricingService {
return score;
}
private async computeBaseRailLines(
private async computeBaseRailLinesWithRates(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<PriceLineItemDto[]> {
): Promise<{ lineItems: PriceLineItemDto[]; usedRates: Rate[] }> {
const liveRates = await this.ratesService.findLiveRates();
const currency = booking.paymentCurrency;
const paymentCurrency = booking.paymentCurrency;
const isEtbBooking = paymentCurrency === 'ETB';
const usdToEtb = isEtbBooking ? await this.cbeExchangeService.getUsdToEtbRate() : 1;
const isBulk = booking.freightType === 'BULK';
console.log('liveRates----', liveRates);
const rateType =
booking.tradeDirection === 'IMPORT'
? isBulk
@@ -207,45 +297,50 @@ console.log('liveRates----', liveRates);
? isBulk
? 'BULK_EXPORT'
: 'CONTAINER_EXPORT'
: 'INTERCITY_CONTAINER';
console.log('rateType----', rateType);
: isBulk
? 'INTERCITY_BULK'
: 'INTERCITY_CONTAINER';
const lines: PriceLineItemDto[] = [];
const usedRatesMap = new Map<string, Rate>();
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
for (const container of evalInput.containers) {
console.log('container----', container);
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, currency);
console.log('rate----', rate);
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
if (!rate) continue;
const amount = this.amountForRate(rate, container.quantity, wagonCount);
usedRatesMap.set(rate.id, rate);
const usdAmount = this.amountForRate(rate, container.quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: rate.currency,
currency: paymentCurrency,
});
}
if (lines.length === 0) {
const fallback = liveRates.find(
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
(r) => r.rateType === rateType && r.currency === 'USD' && r.status === 'LIVE',
);
if (fallback) {
const amount = this.amountForRate(fallback, 1, wagonCount);
usedRatesMap.set(fallback.id, fallback);
const bulkTons = Number(booking.cargoTotalWeightVgm ?? 0);
const quantity =
isBulk && fallback.rateUnit === 'PER_TON' ? Math.max(bulkTons, 0) : 1;
const usdAmount = this.amountForRate(fallback, quantity, wagonCount);
const amount = isEtbBooking ? Math.round(usdAmount * usdToEtb) : usdAmount;
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: fallback.currency,
currency: paymentCurrency,
});
}
}
return lines;
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
}
private pickRate(
@@ -281,31 +376,15 @@ console.log('liveRates----', liveRates);
}
}
private async persistPriceRun(
bookingId: string,
modifiers: AppliedCargoModifier[],
_total: number,
): Promise<void> {
await this.bookingsRepository.clearPricingArtifacts(bookingId);
const snapshots = await this.ruleEngineService.snapshotLiveRates(bookingId);
const snapshotByRateId = new Map(snapshots.map((s) => [s.rateId, s.id]));
const rows = modifiers
.map((m) => {
const snapshotId = snapshotByRateId.get(m.rateId);
if (!snapshotId) return null;
return {
bookingId,
surchargeTypeId: m.surchargeTypeId,
triggerValue: m.triggerValue,
calculatedAmount: m.calculatedAmount,
rateSnapshotId: snapshotId,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
if (rows.length > 0) {
await this.bookingsRepository.createCargoModifiers(rows);
}
private lineItemsSignature(items: PriceLineItemDto[]): string {
return JSON.stringify(
[...items]
.map((item) => ({
code: item.code,
amount: item.amount,
currency: item.currency,
}))
.sort((a, b) => a.code.localeCompare(b.code)),
);
}
}

View File

@@ -1,28 +1,28 @@
import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm';
import { Inject, Injectable } from "@nestjs/common";
import { In, Not } from "typeorm";
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
import { ContainerType } from "../rule-engine/entities/container-type.entity";
import {
CARGO_TYPES_REPOSITORY,
ICargoTypesRepository,
} from '../rule-engine/interfaces/cargo-types.repository.interface';
} from "../rule-engine/interfaces/cargo-types.repository.interface";
import {
CONTAINER_TYPES_REPOSITORY,
IContainerTypesRepository,
} from '../rule-engine/interfaces/container-types.repository.interface';
} from "../rule-engine/interfaces/container-types.repository.interface";
import {
IServiceTypesRepository,
SERVICE_TYPES_REPOSITORY,
} from '../rule-engine/interfaces/service-types.repository.interface';
} from "../rule-engine/interfaces/service-types.repository.interface";
import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from '../rule-engine/interfaces/shipping-lines.repository.interface';
} from "../rule-engine/interfaces/shipping-lines.repository.interface";
import {
IYardsRepository,
YARDS_REPOSITORY,
} from '../rule-engine/interfaces/yards.repository.interface';
} from "../rule-engine/interfaces/yards.repository.interface";
import {
BookingReferenceCargoTypeChildDto,
BookingReferenceCargoTypeGroupDto,
@@ -32,9 +32,9 @@ import {
BookingReferenceServiceDto,
BookingReferenceShippingLineDto,
BookingReferenceYardDto,
} from './dto/booking-reference-data.dto';
} from "./dto/booking-reference-data.dto";
const LEGACY_YARD_CODES = ['LEGACY_ORIGIN', 'LEGACY_DEST'] as const;
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
export function buildCargoTypeTree(
rows: CargoType[],
@@ -42,13 +42,16 @@ export function buildCargoTypeTree(
const active = rows.filter((r) => r.isActive);
const parents = active
.filter((r) => !r.parentGroupId)
.sort((a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code));
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
);
return parents.map((parent) => {
const children = active
.filter((r) => r.parentGroupId === parent.id)
.sort(
(a, b) => a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
(a, b) =>
a.displayOrder - b.displayOrder || a.code.localeCompare(b.code),
)
.map(
(child): BookingReferenceCargoTypeChildDto => ({
@@ -79,14 +82,14 @@ export function groupContainersBySize(
for (const ct of active) {
const sizeKey =
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : 'other';
ct.sizeFt != null && ct.sizeFt > 0 ? `${ct.sizeFt}ft` : "other";
const list = bySize.get(sizeKey) ?? [];
list.push(ct);
bySize.set(sizeKey, list);
}
const sortSizeKey = (key: string): number => {
if (key === 'other') return Number.MAX_SAFE_INTEGER;
if (key === "other") return Number.MAX_SAFE_INTEGER;
const n = parseInt(key, 10);
return Number.isNaN(n) ? Number.MAX_SAFE_INTEGER - 1 : n;
};
@@ -126,7 +129,7 @@ export class BookingReferenceDataService {
private readonly shippingLinesRepository: IShippingLinesRepository,
@Inject(CARGO_TYPES_REPOSITORY)
private readonly cargoTypesRepository: ICargoTypesRepository,
) {}
) { }
async getReferenceData(): Promise<BookingReferenceDataDto> {
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
@@ -136,23 +139,23 @@ export class BookingReferenceDataService {
isActive: true,
code: Not(In([...LEGACY_YARD_CODES])),
},
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.containerTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.serviceTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
this.shippingLinesRepository.findAll({
where: { isActive: true },
order: { label: 'ASC', code: 'ASC' },
order: { label: "ASC", code: "ASC" },
}),
this.cargoTypesRepository.findAll({
where: { isActive: true },
order: { displayOrder: 'ASC', code: 'ASC' },
order: { displayOrder: "ASC", code: "ASC" },
}),
]);
@@ -168,9 +171,8 @@ export class BookingReferenceDataService {
containers: groupContainersBySize(containerTypes),
service: serviceTypes.map(
(s): BookingReferenceServiceDto => ({
id: s.id,
name: s.serviceName,
code: s.code,
...s,
}),
),
shipping_line: shippingLines.map(

View File

@@ -1,4 +1,10 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import {
BadRequestException,
ConflictException,
forwardRef,
Inject,
Injectable,
} from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
@@ -8,6 +14,8 @@ import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import { PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
@@ -22,7 +30,7 @@ export class BookingTransitionService {
private readonly bookingsService: BookingsService,
) {}
async submit(bookingId: string): Promise<Booking> {
async submit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
@@ -32,14 +40,119 @@ export class BookingTransitionService {
);
}
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
await this.ruleEngineService.snapshotLiveRates(bookingId);
const computed = await this.pricingService.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
} | null;
const unchanged = this.pricingService.pricesMatch(stored, computed);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
if (unchanged) {
await this.pricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
priorityScore,
} as never);
const finalBooking = await this.bookingsService.findById(updated!.id);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
priceChanged: false,
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
};
}
const previousTotalAmount = Number(booking.totalAmount);
await this.bookingsRepository.update(bookingId, {
totalAmount: computed.totalAmount,
priorityScore: computed.priorityScore,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
status: 'PRICE_CHANGED_PENDING_CONFIRM',
} as never);
const updatedBooking = await this.bookingsService.findById(bookingId);
return {
bookingId: updatedBooking.id,
status: updatedBooking.status,
priceChanged: true,
previousTotalAmount,
totalAmount: computed.totalAmount,
currency: computed.currency,
lineItems: computed.lineItems,
message: 'Price has changed since preview. Confirm to submit with the updated price.',
};
}
async confirmSubmit(bookingId: string): Promise<SubmitBookingResponseDto> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PRICE_CHANGED_PENDING_CONFIRM']);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException('No price to confirm');
}
const computed = await this.pricingService.computePriceForBooking(booking);
this.ruleEngineService.assertNoHardBlocks({
priorityScore: computed.priorityScore,
appliedModifiers: computed.appliedModifiers,
containerWeightResults: [],
warnings: computed.warnings,
hardBlocked: computed.hardBlocked,
requiresDirectorApproval: false,
});
await this.pricingService.createPricingSnapshots(
bookingId,
computed.usedRates,
computed.appliedModifiers,
);
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
priorityScore,
totalAmount: computed.totalAmount,
pricingBreakdown: {
lineItems: computed.lineItems,
totalAmount: computed.totalAmount,
currency: computed.currency,
generatedAt: new Date().toISOString(),
},
} as never);
return this.bookingsService.findById(updated!.id);
const finalBooking = await this.bookingsService.findById(updated!.id);
return {
bookingId: finalBooking.id,
status: finalBooking.status,
priceChanged: false,
totalAmount: Number(finalBooking.totalAmount),
currency: finalBooking.paymentCurrency,
lineItems: computed.lineItems,
message: 'Booking submitted with confirmed price.',
};
}
async requestChanges(
@@ -77,6 +190,16 @@ export class BookingTransitionService {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
// Consolidation gate: a booking whose containers don't fill whole wagons
// cannot be accepted until it is paired with a complementary booking.
const gate = await this.bookingsService.resolveConsolidationGate(bookingId);
if (gate.blocked) {
throw new ConflictException(
gate.message ??
'Booking requires consolidation and cannot be accepted until a partner is found.',
);
}
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
@@ -279,6 +402,7 @@ export class BookingTransitionService {
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
@@ -321,4 +445,4 @@ export class BookingTransitionService {
nextStep,
};
}
}
}

View File

@@ -39,6 +39,7 @@ import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import { SubmitBookingResponseDto } from './dto/submit-booking-response.dto';
import {
ApproveStepDto,
CancelBookingDto,
@@ -53,6 +54,7 @@ import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
import { assertFreightPermission, hasFreightPermission } from '../../common/freight-permission.util';
@ApiTags('bookings')
@Controller('bookings')
@@ -71,13 +73,30 @@ export class BookingsController {
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
@ApiBody({ type: CreateBookingDto })
create(
async create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@Request() req: { user?: { id?: string; sub?: string } },
@CurrentUser() user: TCurrentUser,
) {
const userId = req.user?.id ?? req.user?.sub;
return this.bookingsService.create(dto, files ?? [], userId);
if (dto.isGovernment) {
assertFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
}
const result = await this.bookingsService.create(dto, files ?? [], user?.id);
// Staff-created commercial bookings skip the draft stage: auto generate-price + submit.
const isStaff = hasFreightPermission(user, FREIGHT_PERMS.bookings.staffAccept);
if (isStaff && !dto.isGovernment) {
try {
await this.pricingService.generatePrice(result.booking.id);
await this.transitionService.submit(result.booking.id);
const submitted = await this.bookingsService.findById(result.booking.id);
return { booking: submitted, warnings: result.warnings };
} catch {
// If auto-pricing/submit fails, fall back to the DRAFT so staff can finish manually.
return result;
}
}
return result;
}
@Patch(':id')
@@ -109,6 +128,20 @@ export class BookingsController {
return this.bookingsService.getListSummary(filter);
}
@Get('my')
@ApiOperation({
summary: "List the current customer's bookings ready for payment",
description:
'Bookings owned by the authenticated user\'s company that are payable ' +
'(FULLY_EXECUTED, SELECTED_FOR_BATCH, AWAITING_PAYMENT) and not yet PAID.',
})
findMyPayable(
@CurrentUser() user: AuthUserPayload,
@Query() filter: FilterBookingDto,
) {
return this.bookingsService.findMyPayable(resolveAuthUserId(user), filter);
}
@Get('queues/:queue')
@ApiOperation({
summary: 'List bookings for a dashboard queue',
@@ -165,17 +198,36 @@ export class BookingsController {
}
@Post(':id/generate-price')
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
@ApiOperation({
summary: 'Generate price preview (DRAFT or CHANGES_REQUESTED)',
description:
'Computes and stores a price preview on the booking. Does not create rate snapshots.',
})
@ApiOkResponse({ type: GeneratePriceResponseDto })
generatePrice(@Param('id', ParseUUIDPipe) id: string) {
return this.pricingService.generatePrice(id);
}
@Post(':id/submit')
@ApiOperation({ summary: 'Customer submit booking' })
async submit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.submit(id);
return this.transitionService.enrichBookingResponse(booking);
@ApiOperation({
summary: 'Customer submit booking',
description:
'Recomputes price against live rates. If unchanged, creates rate snapshots and submits. If changed, updates the booking price and returns priceChanged=true for confirmation.',
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.submit(id);
}
@Post(':id/confirm-submit')
@ApiOperation({
summary: 'Confirm submit after price change',
description:
'Creates rate snapshots for the updated booking price and moves the booking to SUBMITTED.',
})
@ApiOkResponse({ type: SubmitBookingResponseDto })
confirmSubmit(@Param('id', ParseUUIDPipe) id: string) {
return this.transitionService.confirmSubmit(id);
}
@Post(':id/staff/request-changes')
@@ -224,6 +276,20 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/government-expedite')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Expedite government booking to PAID / ELIGIBLE for scheduling' })
async governmentExpedite(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.bookingsService.governmentExpedite(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
@@ -276,8 +342,12 @@ export class BookingsController {
@Get(':id/contract/view')
@ApiOkResponse({ type: ContractViewDto })
@ApiOperation({ summary: 'Contract HTML view for portal and backoffice' })
getContractView(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getContractView(id);
getContractView(
@Param('id', ParseUUIDPipe) id: string,
@Request() req: { user?: { id?: string; sub?: string } },
) {
const userId = req.user?.id ?? req.user?.sub;
return this.contractService.getContractView(id, userId);
}
@Get(':id/contract/document')

View File

@@ -1,4 +1,4 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
// import { CustomersModule } from '../customers/customers.module';
@@ -6,6 +6,7 @@ import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
import { RuleEngineModule } from '../rule-engine/rule-engine.module';
import { SignaturesModule } from '../signatures/signatures.module';
import { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
@@ -29,6 +30,8 @@ import { ContractRendererService } from '../../contracts/contract-renderer.servi
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { PaymentModule } from '../payment/payment.module';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { CbeExchangeService } from '../cbe-exchange/cbe-exchange.service';
@Module({
imports: [
@@ -42,11 +45,13 @@ import { PaymentModule } from '../payment/payment.module';
BookingContractSignature,
]),
PaymentModule,
forwardRef(() => TrainSchedulingModule),
FilesModule,
MinioModule,
CompaniesModule,
// CustomersModule,
RuleEngineModule,
SignaturesModule,
],
controllers: [BookingsController, PayController],
providers: [
@@ -63,6 +68,7 @@ import { PaymentModule } from '../payment/payment.module';
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
CbeExchangeService,
],
exports: [BookingsService, BookingsRepository],
})

View File

@@ -0,0 +1,70 @@
import { DataSource, Repository } from 'typeorm';
import { Booking } from './entities/booking.entity';
import { BookingsRepository } from './bookings.repository';
function mockQueryBuilder() {
const qb = {
leftJoinAndSelect: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
addOrderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getMany: jest.fn(),
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
};
return qb;
}
describe('BookingsRepository', () => {
let repository: jest.Mocked<Repository<Booking>>;
let dataSource: { getRepository: jest.Mock };
let bookingsRepository: BookingsRepository;
beforeEach(() => {
repository = {
createQueryBuilder: jest.fn(),
} as unknown as jest.Mocked<Repository<Booking>>;
dataSource = { getRepository: jest.fn() };
bookingsRepository = new BookingsRepository(repository, dataSource as unknown as DataSource);
});
it('findEligibleForScheduling does not filter by schedule date', async () => {
const qb = mockQueryBuilder();
const bookings = [
{ id: 'b1', scheduledDate: new Date('2026-06-20T08:00:00.000Z') },
{ id: 'b2', scheduledDate: new Date('2026-06-21T14:00:00.000Z') },
];
qb.getMany.mockResolvedValue(bookings);
repository.createQueryBuilder.mockReturnValue(qb as never);
const result = await bookingsRepository.findEligibleForScheduling({
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
freightType: 'CONTAINER',
});
expect(result).toHaveLength(2);
const dateFilters = qb.andWhere.mock.calls.filter(([clause]) =>
String(clause).includes('scheduled_date'),
);
expect(dateFilters).toHaveLength(0);
});
it('applyListFilters excludes assigned bookings when assignedToSchedule is false', async () => {
const qb = mockQueryBuilder();
repository.createQueryBuilder.mockReturnValue(qb as never);
dataSource.getRepository.mockReturnValue({ find: jest.fn().mockResolvedValue([]) });
await bookingsRepository.findAllPaginated({
page: 1,
pageSize: 10,
assignedToSchedule: 'false',
});
expect(qb.andWhere).toHaveBeenCalledWith(expect.stringContaining('NOT EXISTS'));
});
});

View File

@@ -1,7 +1,8 @@
import { BaseRepository } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
@@ -9,6 +10,7 @@ import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingReviewNote, ReviewNoteType } from './entities/booking-review-note.entity';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { Booking } from './entities/booking.entity';
import {
BookingContractSignature,
@@ -20,6 +22,8 @@ import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
schedulingStatuses?: string[];
assignedToSchedule?: 'true' | 'false';
companyId?: string;
contractType?: string;
serviceTypeId?: string;
@@ -27,6 +31,8 @@ export interface BookingListFilterOptions {
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
paymentStatus?: string;
excludePaymentStatus?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@@ -87,6 +93,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
.where('booking.id = :id', { id })
.leftJoinAndMapMany(
'booking.files',
@@ -171,7 +178,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.andWhere('b.allowConsolidation = true')
.andWhere('b.consolidationPartnerId IS NULL')
.andWhere('b.status IN (:...statuses)', {
statuses: ['DRAFT', 'PENDING_CONSOLIDATION'],
statuses: ['DRAFT', 'SUBMITTED', 'PENDING_CONSOLIDATION'],
})
.andWhere('b.originYardId = :originYardId', {
originYardId: booking.originYardId,
@@ -208,15 +215,27 @@ export class BookingsRepository extends BaseRepository<Booking> {
return null;
}
/** Pair two bookings for consolidation. */
/**
* Pair two bookings for consolidation. Both return to SUBMITTED so staff can
* accept them into the approval chain; the link itself (consolidationPartnerId)
* marks them as consolidated in the UI.
*/
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
status: 'CONSOLIDATED',
status: 'SUBMITTED',
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
status: 'CONSOLIDATED',
status: 'SUBMITTED',
} as never);
}
/** Park a booking that needs consolidation but has no partner yet. */
async parkForConsolidation(bookingId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: 'PENDING_CONSOLIDATION',
} as never);
}
@@ -345,6 +364,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
}
async hasPricingArtifacts(bookingId: string): Promise<boolean> {
const snapshotCount = await this.dataSource
.getRepository(BookingRateSnapshot)
.count({ where: { bookingId } });
const modifierCount = await this.dataSource
.getRepository(BookingCargoModifier)
.count({ where: { bookingId } });
return snapshotCount > 0 || modifierCount > 0;
}
async invalidatePricingPreview(bookingId: string): Promise<void> {
if (await this.hasPricingArtifacts(bookingId)) {
await this.clearPricingArtifacts(bookingId);
}
await this.update(bookingId, {
totalAmount: 0,
pricingBreakdown: null,
} as never);
}
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: {
status: string | string[];
@@ -403,21 +442,42 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
if (options.sortBy === 'isGovernment') {
qb.orderBy('booking.isGovernment', 'DESC')
.addOrderBy('booking.priorityScore', 'DESC')
.addOrderBy('booking.scheduledDate', 'ASC');
} else {
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: options.sortBy === 'scheduledDate'
? 'booking.scheduledDate'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
}
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
if (items.length) {
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
where: { bookingId: In(items.map((item) => item.id)) },
select: { bookingId: true, trainScheduleId: true },
});
const scheduleByBooking = new Map(links.map((link) => [link.bookingId, link.trainScheduleId]));
for (const item of items) {
(item as Booking & { trainScheduleId?: string | null }).trainScheduleId =
scheduleByBooking.get(item.id) ?? null;
}
}
return { items, total };
}
@@ -526,6 +586,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
paymentCurrency: options.paymentCurrency,
});
}
if (options.paymentStatus) {
qb.andWhere('booking.payment_status = :paymentStatus', {
paymentStatus: options.paymentStatus,
});
}
if (options.excludePaymentStatus) {
qb.andWhere('booking.payment_status != :excludePaymentStatus', {
excludePaymentStatus: options.excludePaymentStatus,
});
}
if (options.allowConsolidation !== undefined) {
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
allowConsolidation: options.allowConsolidation,
@@ -536,6 +606,26 @@ export class BookingsRepository extends BaseRepository<Booking> {
} else if (options.consolidationPaired === 'false') {
qb.andWhere('booking.consolidation_partner_id IS NULL');
}
if (options.schedulingStatuses?.length) {
qb.andWhere('booking.scheduling_status IN (:...schedulingStatuses)', {
schedulingStatuses: options.schedulingStatuses,
});
}
if (options.assignedToSchedule === 'true') {
qb.andWhere(
`EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
)`,
);
} else if (options.assignedToSchedule === 'false') {
qb.andWhere(
`NOT EXISTS (
SELECT 1 FROM freight.train_schedule_bookings tsb
WHERE tsb.booking_id = booking.id AND tsb.deleted_at IS NULL
)`,
);
}
}
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
@@ -585,4 +675,192 @@ export class BookingsRepository extends BaseRepository<Booking> {
}
return repo.save(repo.create(data));
}
private bookingRepo(manager?: EntityManager) {
return manager ? manager.getRepository(Booking) : this.repository;
}
findEligibleForScheduling(options: {
freightType?: string;
originStationId?: string;
destinationStationId?: string;
schedulingStatus?: string;
trainScheduleId?: string;
}): Promise<Booking[]> {
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.leftJoin(
TrainScheduleBooking,
'scheduleBooking',
'scheduleBooking.booking_id = booking.id',
)
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
.andWhere('scheduleBooking.id IS NULL');
// Mirror the automatic batch pool: a schedule only ever considers bookings that
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
if (options.trainScheduleId) {
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
trainScheduleId: options.trainScheduleId,
});
}
if (options.freightType) {
qb.andWhere('booking.freightType = :freightType', { freightType: options.freightType });
}
if (options.originStationId) {
qb.andWhere('booking.originYardId = :originStationId', {
originStationId: options.originStationId,
});
}
if (options.destinationStationId) {
qb.andWhere('booking.destinationYardId = :destinationStationId', {
destinationStationId: options.destinationStationId,
});
}
if (options.schedulingStatus) {
qb.andWhere('booking.scheduling_status = :schedulingStatus', {
schedulingStatus: options.schedulingStatus,
});
}
return qb
.orderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.scheduled_date', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/**
* Ready, not-yet-allocated bookings targeting a schedule (the batch pool).
* Commercial = FULLY_EXECUTED; government = APPROVED or PAID (skips contract).
* Ordered government → priority → contract-sign time.
*/
findBatchPool(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere('sb.id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.fully_executed_at', 'ASC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.getMany();
}
/** PAID bookings targeting a schedule that have no train_schedule_bookings link yet. */
findPaidUnlinkedForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoin(
TrainScheduleBooking,
'scheduleBooking',
'scheduleBooking.booking_id = booking.id',
)
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status = 'PAID'`)
.andWhere('scheduleBooking.id IS NULL')
.orderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
}
/** Commercial bookings already allocated to a schedule, lowest-priority first (for government preempt). */
findAllocatedCommercialForSchedule(scheduleId: string): Promise<Booking[]> {
return this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.innerJoin(
TrainScheduleBooking,
'sb',
'sb.booking_id = booking.id AND sb.train_schedule_id = :scheduleId',
{ scheduleId },
)
.where('booking.is_government = false')
.orderBy('booking.priority_score', 'ASC')
.addOrderBy('booking.created_at', 'DESC')
.getMany();
}
findByIdsForScheduling(bookingIds: string[], manager?: EntityManager): Promise<Booking[]> {
if (!bookingIds.length) return Promise.resolve([]);
return this.bookingRepo(manager).find({
where: { id: In(bookingIds) },
relations: {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
cargoType: true,
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },
});
}
async updateSchedulingFields(
bookingId: string,
fields: Partial<
Pick<
Booking,
'schedulingStatus' | 'wagonsRequired' | 'scheduledAt' | 'holdStartedAt' | 'holdExpiresAt'
>
>,
manager?: EntityManager,
): Promise<void> {
await this.bookingRepo(manager).update(bookingId, fields as never);
}
async setHoldWindowOnPaid(bookingId: string, manager?: EntityManager): Promise<void> {
const now = new Date();
const expires = new Date(now.getTime() + 3 * 60 * 60 * 1000);
await this.updateSchedulingFields(
bookingId,
{
schedulingStatus: SchedulingStatus.Holding,
holdStartedAt: now,
holdExpiresAt: expires,
},
manager,
);
}
}

View File

@@ -4,6 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { SchedulingStatus } from '@edr/types';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service';
@@ -13,6 +14,12 @@ import {
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, In } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { assertFreightShape } from './booking-freight.util';
@@ -39,6 +46,7 @@ const NEEDS_ACTION_STATUSES = [
@Injectable()
export class BookingsService {
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
@@ -49,6 +57,36 @@ export class BookingsService {
private readonly consolidationService: ConsolidationService,
) {}
/** Resolve trade direction from yard countries; reject client mismatch. */
private async resolveTradeDirectionForBooking(
originYardId: string,
destinationYardId: string,
provided?: string,
): Promise<string> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
const origin = yards.find((y) => y.id === originYardId);
const destination = yards.find((y) => y.id === destinationYardId);
if (!origin) {
throw new BadRequestException(`Origin yard ${originYardId} not found`);
}
if (!destination) {
throw new BadRequestException(`Destination yard ${destinationYardId} not found`);
}
if (originYardId === destinationYardId) {
throw new BadRequestException('Origin and destination yards must differ');
}
const expected = deriveTradeDirection(origin, destination);
if (provided && provided !== expected) {
throw new BadRequestException(
`tradeDirection must be ${expected} for the selected yard pair (got ${provided})`,
);
}
return expected;
}
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
@@ -64,6 +102,7 @@ export class BookingsService {
paymentCurrency: string;
tradeDirection: string;
isHazardous?: boolean;
isGovernment?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: CreateBookingContainerDto[];
@@ -81,9 +120,13 @@ export class BookingsService {
vgmPerUnitTons: c.vgmPerUnitTons,
totalVgmTons,
isReefer: ct.isReefer,
wagonsRequired: c.quantity * (Number(ct.wagonsPerUnit) || 1),
};
}),
);
const totalWagons = Math.ceil(
containers.reduce((sum, c) => sum + c.wagonsRequired, 0),
);
return {
freightType: dto.freightType,
@@ -92,9 +135,11 @@ export class BookingsService {
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
isGovernment: dto.isGovernment ?? false,
allowConsolidation:
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId,
totalWagons,
containers,
};
}
@@ -159,6 +204,48 @@ export class BookingsService {
return { booking: pending, messages };
}
/**
* Consolidation gate used at staff-accept time. Returns the (possibly newly
* paired) booking plus whether it still needs a consolidation partner.
* When a booking needs consolidation and none is found, it is parked in
* PENDING_CONSOLIDATION and `blocked` is true so the caller refuses the accept.
*/
async resolveConsolidationGate(bookingId: string): Promise<{
booking: Booking;
blocked: boolean;
message?: string;
}> {
let booking = await this.findById(bookingId);
// Already paired — passes the gate.
if (booking.consolidationPartnerId) {
return { booking, blocked: false };
}
const needs =
await this.consolidationService.needsConsolidationFromBooking(booking);
if (!needs) {
return { booking, blocked: false };
}
// A partner may have appeared since submission — try to pair now.
const result = await this.tryAutoConsolidate(booking);
booking = result.booking;
if (booking.consolidationPartnerId) {
return { booking, blocked: false, message: result.messages.join(' ') };
}
// Still no partner — park it and block the accept.
await this.bookingsRepository.parkForConsolidation(booking.id);
booking = await this.findById(booking.id);
const slots = await this.consolidationService.slotsFromBooking(booking);
return {
booking,
blocked: true,
message: this.consolidationService.describePending(booking, slots),
};
}
/** Create a new freight booking. */
async create(
dto: CreateBookingDto,
@@ -178,8 +265,15 @@ export class BookingsService {
// customerId = customer.id;
// }
let companyId = dto.companyId;
if (!companyId) {
const isGovernment = dto.isGovernment === true;
let companyId: string | null | undefined = dto.companyId;
if (isGovernment) {
if (!dto.governmentInstitution?.trim()) {
throw new BadRequestException('governmentInstitution is required for government bookings');
}
companyId = dto.companyId ?? null;
} else if (!companyId) {
if (!userId) {
throw new BadRequestException(
'companyId is required or must be resolvable from auth token',
@@ -189,6 +283,25 @@ export class BookingsService {
companyId = company.id;
}
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
if (dto.trainScheduleId) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: dto.trainScheduleId } });
if (!schedule) {
throw new BadRequestException(`Train schedule ${dto.trainScheduleId} not found`);
}
if (schedule.bookingWindowStatus !== 'OPEN') {
throw new BadRequestException('Selected schedule is no longer accepting bookings');
}
if (
schedule.originStationId !== dto.originYardId ||
schedule.destinationStationId !== dto.destinationYardId
) {
throw new BadRequestException('Selected schedule is not on the booking route');
}
}
const reference = dto.reference || (await this.generateReference());
const containers = dto.containers ?? [];
assertFreightShape({
@@ -197,6 +310,12 @@ export class BookingsService {
containers,
});
const tradeDirection = await this.resolveTradeDirectionForBooking(
dto.originYardId,
dto.destinationYardId,
dto.tradeDirection,
);
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
@@ -207,8 +326,9 @@ export class BookingsService {
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
tradeDirection,
isHazardous: dto.isHazardous,
isGovernment,
allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
@@ -220,8 +340,11 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
companyId,
companyId: companyId ?? null,
isGovernment,
governmentInstitution: isGovernment ? dto.governmentInstitution!.trim() : null,
trainId: dto.trainId,
trainScheduleId: dto.trainScheduleId ?? null,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
serviceTypeId: dto.serviceTypeId,
@@ -230,7 +353,7 @@ export class BookingsService {
equipmentReturn: dto.equipmentReturn,
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
tradeDirection,
freightType: dto.freightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
@@ -300,12 +423,13 @@ export class BookingsService {
const freightType = (dto.freightType ?? existing.freightType) as FreightType;
let containers =
dto.containers ??
existing.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
})) ??
[];
(existing.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
}));
let cargoTypeId =
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
@@ -324,6 +448,14 @@ export class BookingsService {
assertFreightShape({ freightType, cargoTypeId, containers });
const originYardId = dto.originYardId ?? existing.originYardId;
const destinationYardId = dto.destinationYardId ?? existing.destinationYardId;
const tradeDirection = await this.resolveTradeDirectionForBooking(
originYardId,
destinationYardId,
dto.tradeDirection,
);
const allowConsolidation =
freightType === 'CONTAINER'
? await this.resolveConsolidation(
@@ -337,7 +469,7 @@ export class BookingsService {
cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
tradeDirection,
isHazardous: dto.isHazardous ?? existing.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId ?? existing.shippingLineId ?? undefined,
@@ -348,12 +480,22 @@ export class BookingsService {
this.ruleEngineService.assertNoHardBlocks(ruleResult);
warnings.push(...ruleResult.warnings);
const pricingFieldsChanged = this.pricingRelevantFieldsChanged(
existing,
dto,
freightType,
cargoTypeId,
allowConsolidation,
containers,
);
const updates: Record<string, unknown> = {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
tradeDirection,
};
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
@@ -375,6 +517,10 @@ export class BookingsService {
);
}
if (pricingFieldsChanged) {
await this.bookingsRepository.invalidatePricingPreview(id);
}
if (files.length > 0) {
await this.filesService.uploadMany(id, 'bookings', files);
}
@@ -390,6 +536,19 @@ export class BookingsService {
return { booking, warnings };
}
/** Parse comma-separated scheduling status query values. */
private parseSchedulingStatusFilter(filter: FilterBookingDto): {
schedulingStatuses?: string[];
} {
const raw = filter.schedulingStatuses;
if (!raw) return {};
const schedulingStatuses = raw
.split(',')
.map((s) => s.trim())
.filter(Boolean);
return schedulingStatuses.length ? { schedulingStatuses } : {};
}
/** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterBookingDto): {
statuses?: string[];
@@ -420,11 +579,14 @@ export class BookingsService {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const schedulingStatusFilter = this.parseSchedulingStatusFilter(filter);
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
...schedulingStatusFilter,
assignedToSchedule: filter.assignedToSchedule,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
@@ -432,6 +594,7 @@ export class BookingsService {
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
@@ -439,6 +602,35 @@ export class BookingsService {
});
}
/** Booking statuses at which a customer can pay (mirrors booking-payment.service). */
private static readonly PAYABLE_STATUSES = [
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'AWAITING_PAYMENT',
];
/**
* List the current customer's bookings that are ready for payment:
* payable status AND not yet PAID. Company scope is derived from the
* authenticated user and cannot be widened by the caller.
*/
async findMyPayable(
userId: string,
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
return this.bookingsRepository.findAllPaginated({
page: filter.page ?? 1,
pageSize: filter.pageSize ?? 20,
statuses: BookingsService.PAYABLE_STATUSES,
excludePaymentStatus: 'PAID',
companyId: company.id,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;
@@ -453,6 +645,7 @@ export class BookingsService {
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
paymentStatus: filter.paymentStatus,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
@@ -648,4 +841,86 @@ export class BookingsService {
),
};
}
private pricingRelevantFieldsChanged(
existing: Booking,
dto: UpdateBookingDto,
freightType: FreightType,
cargoTypeId: string | null | undefined,
allowConsolidation: boolean,
containers: CreateBookingContainerDto[],
): boolean {
if (dto.freightType !== undefined && dto.freightType !== existing.freightType) {
return true;
}
if (dto.tradeDirection !== undefined && dto.tradeDirection !== existing.tradeDirection) {
return true;
}
if (dto.paymentCurrency !== undefined && dto.paymentCurrency !== existing.paymentCurrency) {
return true;
}
if (dto.isHazardous !== undefined && dto.isHazardous !== existing.isHazardous) {
return true;
}
if (
dto.allowConsolidation !== undefined &&
dto.allowConsolidation !== existing.allowConsolidation
) {
return true;
}
if (dto.shippingLineId !== undefined && dto.shippingLineId !== existing.shippingLineId) {
return true;
}
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== existing.cargoTypeId) {
return true;
}
if (dto.containers !== undefined) {
const existingContainers = (existing.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
}));
if (JSON.stringify(existingContainers) !== JSON.stringify(containers)) {
return true;
}
}
if (
freightType !== existing.freightType ||
(cargoTypeId ?? null) !== (existing.cargoTypeId ?? null) ||
allowConsolidation !== existing.allowConsolidation
) {
return true;
}
return false;
}
/** Staff expedite: mark a government booking PAID and ready for scheduling (no commercial hold). */
async governmentExpedite(id: string, staffUserId: string): Promise<Booking> {
const booking = await this.findById(id);
if (!booking.isGovernment) {
throw new BadRequestException('Only government bookings can be expedited');
}
const blocked = ['PAID', 'IN_TRANSIT', 'COMPLETED', 'CANCELLED', 'REJECTED'];
if (blocked.includes(booking.status)) {
throw new BadRequestException(`Cannot expedite booking in status ${booking.status}`);
}
await this.bookingsRepository.update(id, {
status: 'PAID',
paymentStatus: 'PAID',
schedulingStatus: SchedulingStatus.Eligible,
holdStartedAt: null,
holdExpiresAt: null,
});
await this.bookingsRepository.createReviewNote(
id,
`Government booking expedited to PAID by staff (${staffUserId})`,
'STAFF_NOTE',
staffUserId,
);
return this.findById(id);
}
}

View File

@@ -76,11 +76,12 @@ export class ConsolidationService {
}
async slotsFromBooking(booking: Booking): Promise<ConsolidationSlot[]> {
const lines =
booking.bookingContainers?.map((bc) => ({
const lines = (booking.bookingContainers ?? [])
.filter((bc): bc is typeof bc & { containerTypeId: string } => bc.containerTypeId != null)
.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
})) ?? [];
}));
return this.slotsFromContainerLines(lines);
}

View File

@@ -14,6 +14,14 @@ export class ContractSignatureDto {
signatureImageUrl?: string | null;
}
export class SavedSignatureViewDto {
@ApiProperty()
signerDisplayName!: string;
@ApiPropertyOptional()
signatureImageUrl?: string | null;
}
export class ContractViewDto {
@ApiProperty()
bookingId!: string;
@@ -45,6 +53,9 @@ export class ContractViewDto {
@ApiProperty({ type: [ContractSignatureDto] })
signatures!: ContractSignatureDto[];
@ApiPropertyOptional({ type: SavedSignatureViewDto })
savedSignature?: SavedSignatureViewDto;
@ApiPropertyOptional()
pricingSchedule?: Record<string, unknown>;
}

View File

@@ -12,6 +12,7 @@ import {
IsString,
IsUUID,
Min,
MinLength,
Validate,
ValidateIf,
ValidateNested,
@@ -66,7 +67,21 @@ export class CreateBookingDto {
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ description: 'Staff only: government booking flag' })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === 'true' || value === true)
isGovernment?: boolean;
@ApiPropertyOptional({ description: 'Required when isGovernment is true' })
@ValidateIf((o) => o.isGovernment === true)
@IsString()
@MinLength(2)
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
governmentInstitution?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@ValidateIf((o) => o.isGovernment !== true)
@IsOptional()
@IsUUID()
companyId?: string;
@@ -76,6 +91,12 @@ export class CreateBookingDto {
@IsUUID()
trainId?: string;
/** Target schedule this booking is created against (required by the backoffice create form). */
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
@IsOptional()
@IsUUID()
trainScheduleId?: string;
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
@IsDateString()
scheduledDate!: string;

View File

@@ -7,6 +7,7 @@ import {
PAYMENT_CURRENCIES,
TRADE_DIRECTIONS,
} from './create-booking.dto';
import { PAYMENT_STATUSES } from '../entities/booking.entity';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -65,6 +66,11 @@ export class FilterBookingDto {
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({ enum: PAYMENT_STATUSES })
@IsOptional()
@IsIn([...PAYMENT_STATUSES])
paymentStatus?: string;
@ApiPropertyOptional()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
@@ -84,8 +90,25 @@ export class FilterBookingDto {
@Transform(({ value }) => (value ? parseInt(value, 10) : 20))
pageSize?: number;
@ApiPropertyOptional({
description: 'Comma-separated scheduling statuses (NOT_SCHEDULED,HOLDING,ELIGIBLE,SCHEDULED)',
})
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (Array.isArray(value)) return value.map(String).join(',');
return String(value);
})
schedulingStatuses?: string;
@ApiPropertyOptional({ enum: ['true', 'false'], description: 'Filter by train schedule assignment' })
@IsOptional()
@IsIn(['true', 'false'])
assignedToSchedule?: 'true' | 'false';
@ApiPropertyOptional({ default: 'createdAt' })
@IsOptional()
@IsIn(['createdAt', 'priorityScore', 'scheduledDate', 'isGovernment'])
sortBy?: string;
@ApiPropertyOptional({ enum: ['ASC', 'DESC'], default: 'DESC' })

View File

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { PriceLineItemDto } from './generate-price-response.dto';
export class SubmitBookingResponseDto {
@ApiProperty()
bookingId!: string;
@ApiProperty()
status!: string;
@ApiProperty()
priceChanged!: boolean;
@ApiPropertyOptional()
previousTotalAmount?: number;
@ApiProperty()
totalAmount!: number;
@ApiProperty()
currency!: string;
@ApiPropertyOptional({ type: [PriceLineItemDto] })
lineItems?: PriceLineItemDto[];
@ApiPropertyOptional()
message?: string;
}

View File

@@ -15,12 +15,15 @@ export class BookingContainer extends BaseEntity {
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@Column({ name: 'container_type_id', type: 'uuid', nullable: true })
containerTypeId?: string | null;
@ManyToOne(() => ContainerType)
@ManyToOne(() => ContainerType, { nullable: true })
@JoinColumn({ name: 'container_type_id' })
containerType?: ContainerType;
containerType?: ContainerType | null;
@Column({ name: 'container_number', type: 'varchar', length: 64, nullable: true })
containerNumber?: string | null;
@Column({ name: 'quantity', type: 'smallint' })
quantity!: number;

View File

@@ -2,7 +2,7 @@ import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from './booking.entity';
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION'] as const;
export const REVIEW_NOTE_TYPES = ['CHANGES_REQUESTED', 'REJECTION', 'STAFF_NOTE'] as const;
export type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'booking_review_note' })

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common';
import { SchedulingStatus } from '@edr/types';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
// import { Customer } from '../../customers/entities/customer.entity';
import { Company } from '../../companies/entities/company.entity';
@@ -17,6 +18,7 @@ import { BookingReviewNote } from './booking-review-note.entity';
export const BOOKING_STATUSES = [
'DRAFT',
'SUBMITTED',
'PRICE_CHANGED_PENDING_CONFIRM',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
@@ -27,6 +29,8 @@ export const BOOKING_STATUSES = [
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'SELECTED_FOR_BATCH',
'EXPIRED',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
@@ -53,6 +57,16 @@ export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
export type FreightType = (typeof FREIGHT_TYPES)[number];
export const SCHEDULING_STATUSES = [
SchedulingStatus.NotScheduled,
SchedulingStatus.Holding,
SchedulingStatus.Eligible,
SchedulingStatus.Scheduled,
SchedulingStatus.Dispatched,
] as const;
export type BookingSchedulingStatus = (typeof SCHEDULING_STATUSES)[number];
/** Statuses where the customer may edit booking fields. */
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
'DRAFT',
@@ -71,16 +85,24 @@ export class Booking extends BaseEntity {
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@Column({ name: 'company_id', type: 'uuid', nullable: true })
companyId?: string | null;
@ManyToOne(() => Company)
@ManyToOne(() => Company, { nullable: true })
@JoinColumn({ name: 'company_id' })
company?: Company;
company?: Company | null;
@Column({ name: 'is_government', type: 'boolean', default: false })
isGovernment!: boolean;
@Column({ name: 'government_institution', type: 'varchar', length: 255, nullable: true })
governmentInstitution?: string | null;
/** @deprecated Fleet master data link — scheduling uses train_schedule_bookings instead. */
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null;
/** @deprecated Use train_schedule_bookings for operational scheduling. */
@ManyToOne(() => Train, { nullable: true })
@JoinColumn({ name: 'train_id' })
train?: Train | null;
@@ -242,6 +264,34 @@ export class Booking extends BaseEntity {
@JoinColumn({ name: 'consolidation_partner_id' })
consolidationPartner?: Booking | null;
@Column({ name: 'wagons_required', type: 'numeric', precision: 6, scale: 2, nullable: true })
wagonsRequired?: number | null;
@Column({ name: 'scheduling_status', type: 'varchar', length: 30, default: 'NOT_SCHEDULED' })
schedulingStatus!: string;
@Column({ name: 'hold_started_at', type: 'timestamptz', nullable: true })
holdStartedAt?: Date | null;
@Column({ name: 'hold_expires_at', type: 'timestamptz', nullable: true })
holdExpiresAt?: Date | null;
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
scheduledAt?: Date | null;
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
/** End of the pay window once the booking is SELECTED_FOR_BATCH. */
@Column({ name: 'payment_deadline', type: 'timestamptz', nullable: true })
paymentDeadline?: Date | null;
/** When the batch engine picked this booking and opened the pay window. */
@Column({ name: 'selected_for_batch_at', type: 'timestamptz', nullable: true })
selectedForBatchAt?: Date | null;
@OneToMany(() => BookingContainer, (bc) => bc.booking)
bookingContainers?: BookingContainer[];