add fan number

This commit is contained in:
Tria
2026-06-05 15:59:12 +03:00
288 changed files with 21965 additions and 3600 deletions

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
controllers: [FreightMeController],
providers: [FreightMeService],
})
export class FreightAuthModule {}

View File

@@ -0,0 +1,23 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@tria-plc/api-common/modules/auth/decorators/current-user.decorator';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FreightMeService } from './freight-me.service';
@ApiTags('auth')
@Controller('me')
@ApiBearerAuth()
export class FreightMeController {
constructor(private readonly freightMeService: FreightMeService) {}
@Get()
@UseGuards(JwtGuard)
@ApiOperation({
summary: 'Current user with flat permissionKeys for backoffice gating',
})
getMe(@CurrentUser() user: TCurrentUser) {
return this.freightMeService.getEnrichedProfile(user);
}
}

View File

@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import {
collectPermissionKeys,
isSuperAdmin,
} from '../../common/freight-permission.util';
import { PERMISSIONS_CATALOG } from '../../seed/freight-permissions.registry';
@Injectable()
export class FreightMeService {
getEnrichedProfile(user: TCurrentUser) {
const employee = user.employee
? [
{
id: user.employee.id,
organizationId: user.employee.organizationId,
unitId: user.employee.unitId,
name: user.employee.name,
positions: user.employee.position
? [
{
id: user.employee.position.id,
key: user.employee.position.key,
employeePositionId: user.employee.position.employeePositionId,
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
},
]
: [],
},
]
: [];
const permissionKeys = collectPermissionKeys(user);
return {
id: user.id,
email: user.email,
name: user.name,
username: user.username,
phoneNumber: user.phoneNumber,
userType: user.userType,
status: user.status,
hasFinishedRegistration: user.hasFinishedRegistration,
hasFinishedDMSOnboarding: user.hasFinishedDMSOnboarding,
roles: user.roles,
permissions: user.permissions,
employee,
permissionKeys,
isSuperAdmin: isSuperAdmin(user),
permissionsCatalog: PERMISSIONS_CATALOG,
};
}
}

View File

@@ -0,0 +1,284 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { Readable } from 'stream';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { getTemplateMeta } from '../../contracts/contract-template.registry';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
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';
@Injectable()
export class BookingContractService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly templateResolver: ContractTemplateResolver,
private readonly viewModelBuilder: ContractViewModelBuilder,
private readonly renderer: ContractRendererService,
private readonly pdfService: ContractPdfService,
) {}
buildContractSummary(booking: Booking): string {
const direction =
booking.tradeDirection === 'IMPORT'
? 'Import'
: booking.tradeDirection === 'EXPORT'
? 'Export'
: booking.tradeDirection;
const cargo = booking.cargoType;
const isBulk = booking.freightType === 'BULK';
let cargoLabel: string;
if (isBulk) {
cargoLabel = `Bulk (${booking.cargoFreeText || cargo?.cargoTypeName || 'Commodity'})`;
} else {
const lines =
booking.bookingContainers?.map((bc) => {
const label = bc.containerType?.label ?? bc.containerType?.code ?? 'Container';
return `${bc.quantity}× ${label}`;
}) ?? [];
cargoLabel =
lines.length > 0
? `Container (${lines.join(', ')})`
: 'Container (Standard)';
}
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
}
async getSummary(bookingId: string): Promise<{ summary: string }> {
const booking = await this.requireBooking(bookingId);
const summary = booking.contractSummary ?? this.buildContractSummary(booking);
return { summary };
}
async getContractView(bookingId: string): Promise<ContractViewDto> {
const { view } = await this.viewModelBuilder.build(bookingId);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
return {
bookingId: view.bookingId,
reference: view.reference,
status: view.status,
templateKey: view.templateKey,
title: view.template.title,
html,
canSignCustomer: view.canSignCustomer,
canSignStaff: view.canSignStaff,
hasContractDocument: view.hasContractDocument,
signatures: view.signatures,
pricingSchedule: view.pricing as unknown as Record<string, unknown>,
};
}
async generateContract(bookingId: string): Promise<Booking> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['APPROVED']);
const templateKey = this.templateResolver.resolve(booking);
const summary = this.buildContractSummary(booking);
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CONTRACT_READY',
contractSummary: summary,
contractTemplateKey: templateKey,
contractGeneratedAt: now,
} as never);
return updated!;
}
async streamContract(bookingId: string) {
const booking = await this.requireBooking(bookingId);
const templateKey =
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const record = await this.upsertContractPdf(
bookingId,
booking.reference,
templateKey,
);
return this.filesService.streamById(record.id);
}
async signContract(
bookingId: string,
dto: SignContractDto,
options: { signerUserId?: string; ipAddress?: string },
): Promise<Booking> {
const booking = await this.requireBooking(bookingId);
const role = dto.role as ContractSignerRole;
if (role === 'CUSTOMER') {
assertBookingStatus(booking, ['CONTRACT_READY']);
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'CUSTOMER',
);
if (existing) {
throw new BadRequestException('Customer has already signed this contract');
}
} else {
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const existing = await this.bookingsRepository.findContractSignature(
bookingId,
'STAFF',
);
if (existing) {
throw new BadRequestException('Staff has already signed this contract');
}
}
const buffer = this.decodeSignatureImage(dto.signatureImageBase64);
const sigFile: Express.Multer.File = {
fieldname: `signature_${role.toLowerCase()}`,
originalname: `signature-${role.toLowerCase()}-${booking.reference}.png`,
encoding: '7bit',
mimetype: 'image/png',
size: buffer.length,
buffer,
stream: Readable.from(buffer),
destination: '',
filename: '',
path: '',
};
const fileRecord = await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
file: sigFile,
});
const now = new Date();
await this.bookingsRepository.saveContractSignature({
bookingId,
signerRole: role,
signerUserId: options.signerUserId ?? null,
signerDisplayName: dto.signerDisplayName,
signedAt: now,
signatureFileId: fileRecord.id,
consentText: dto.consentText ?? null,
ipAddress: options.ipAddress ?? null,
});
const updates: Record<string, unknown> = {};
if (role === 'CUSTOMER') {
updates.status = 'SIGNED_CUSTOMER';
updates.customerSignedAt = now;
} else {
updates.status = 'FULLY_EXECUTED';
updates.fullyExecutedAt = now;
updates.marketingApprovedAt = now;
updates.marketingApprovedById = options.signerUserId ?? null;
updates.lockedAt = now;
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
return updated!;
}
async getSignatures(bookingId: string) {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
await this.inlineSignatureImages(views);
return { signatures: views };
}
private async upsertContractPdf(
bookingId: string,
reference: string,
templateKey: string,
): Promise<FileRecord> {
const { view } = await this.viewModelBuilder.build(bookingId);
view.templateKey = templateKey;
view.template = getTemplateMeta(templateKey);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${reference}.pdf`,
encoding: '7bit',
mimetype: 'application/pdf',
size: pdfBuffer.length,
buffer: pdfBuffer,
stream: Readable.from(pdfBuffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'contract',
file,
});
}
private async inlineSignatureImages(
signatures: Array<{ signatureImageUrl?: string | null }>,
): Promise<void> {
for (const sig of signatures) {
if (!sig.signatureImageUrl) continue;
try {
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString(
'base64',
)}`;
} catch {
/* keep original url */
}
}
}
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
private decodeSignatureImage(base64: string): Buffer {
const raw = base64.includes(',') ? base64.split(',')[1]! : base64;
return Buffer.from(raw, 'base64');
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
}

View File

@@ -0,0 +1,44 @@
import { BadRequestException } from '@nestjs/common';
import { FREIGHT_TYPES, FreightType } from './entities/booking.entity';
import { BookingFreightShapeInput } from './dto/validators/booking-freight.validator';
/** Normalize and validate booking freight shape (used on create and after update merge). */
export function assertFreightShape(input: BookingFreightShapeInput): void {
if (!input.freightType || !FREIGHT_TYPES.includes(input.freightType as FreightType)) {
throw new BadRequestException(
`freightType is required and must be one of: ${FREIGHT_TYPES.join(', ')}`,
);
}
//
const containers = input.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType = Boolean(input.cargoTypeId);
if (input.freightType === 'BULK') {
if (hasContainers) {
throw new BadRequestException(
'BULK freight cannot include container lines; use cargoTypeId only',
);
}
if (!hasCargoType) {
throw new BadRequestException('cargoTypeId is required for BULK freight');
}
return;
}
if (hasCargoType) {
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
}
if (!hasContainers) {
throw new BadRequestException(
'CONTAINER freight requires at least one container line with containerTypeId',
);
}
for (const line of containers) {
if (!line.containerTypeId) {
throw new BadRequestException('Each container line must include containerTypeId');
}
}
}

View File

@@ -0,0 +1,54 @@
export const BOOKING_LIST_TAB_KEYS = [
'all',
'intake',
'in_approval',
'approved_contract',
'payment',
'operations',
'completed',
'closed',
] as const;
export type BookingListTabKey = (typeof BOOKING_LIST_TAB_KEYS)[number];
export const BOOKING_LIST_TABS: ReadonlyArray<{
key: BookingListTabKey;
statuses: readonly string[] | null;
}> = [
{ key: 'all', statuses: null },
{ key: 'intake', statuses: ['SUBMITTED'] },
{
key: 'in_approval',
statuses: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
},
{
key: 'approved_contract',
statuses: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
},
{ key: 'payment', statuses: ['FULLY_EXECUTED', 'PAID'] },
{
key: 'operations',
statuses: ['IN_TRANSIT', 'PENDING_CONSOLIDATION', 'CONSOLIDATED'],
},
{ key: 'completed', statuses: ['COMPLETED'] },
{ key: 'closed', statuses: ['REJECTED', 'CANCELLED'] },
];
export function mapStatusCountsToTabs(
statusCounts: Record<string, number>,
): Record<BookingListTabKey, number> {
const result = {} as Record<BookingListTabKey, number>;
for (const tab of BOOKING_LIST_TABS) {
if (!tab.statuses?.length) {
result[tab.key] = Object.values(statusCounts).reduce((sum, n) => sum + n, 0);
continue;
}
result[tab.key] = tab.statuses.reduce(
(sum, status) => sum + (statusCounts[status] ?? 0),
0,
);
}
return result;
}

View File

@@ -0,0 +1,68 @@
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { Booking } from './entities/booking.entity';
export interface BookingNextStep {
action: string;
description: string;
requiredRole?: string;
}
export function computeNextStep(
booking: Pick<Booking, 'status' | 'paymentCurrency'>,
nextPendingStep?: Pick<BookingApprovalStep, 'requiredRole' | 'stepOrder'> | null,
): BookingNextStep | null {
const { status } = booking;
switch (status) {
case 'SUBMITTED':
return {
action: 'ACCEPT_INTAKE',
description: 'Line Staff must accept the submission to begin approval',
};
case 'PENDING_APPROVAL':
case 'APPROVED_PENDING_SIGNATURE':
if (nextPendingStep) {
return {
action: 'APPROVE_STEP',
requiredRole: nextPendingStep.requiredRole,
description: `${nextPendingStep.requiredRole} must approve step ${nextPendingStep.stepOrder}`,
};
}
return {
action: 'APPROVE_STEP',
description: 'Complete the pending approval step in sequence',
};
case 'APPROVED':
return {
action: 'GENERATE_CONTRACT',
description: 'Generate the contract document',
};
case 'CONTRACT_READY':
return {
action: 'CUSTOMER_SIGN',
description: 'Customer must sign the contract',
};
case 'SIGNED_CUSTOMER':
return {
action: 'STAFF_SIGN',
description: 'Internal staff must counter-sign the contract',
};
case 'FULLY_EXECUTED':
return {
action: 'PAY',
description: 'Complete in-app payment',
};
case 'PAID':
return {
action: 'START_TRANSIT',
description: 'Mark shipment as in transit',
};
case 'IN_TRANSIT':
return {
action: 'COMPLETE',
description: 'Mark shipment complete',
};
default:
return null;
}
}

View File

@@ -0,0 +1,50 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {}
@Injectable()
export class BookingPaymentService {
constructor(private readonly bookingsRepository: BookingsRepository) {}
async pay(
bookingId: string,
): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
const receipt = this.buildMockReceipt(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PAID',
paymentStatus: 'PAID',
} as never);
return { booking: updated!, receipt };
}
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`);
return booking;
}
}

View File

@@ -0,0 +1,311 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
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 {
AppliedCargoModifier,
BookingEvaluationInput,
RuleEngineService,
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import { GeneratePriceResponseDto, PriceLineItemDto } from './dto/generate-price-response.dto';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
@Injectable()
export class BookingPricingService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly ratesService: RatesService,
private readonly serviceTypesService: ServiceTypesService,
) {}
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['DRAFT']);
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);
await this.bookingsRepository.update(bookingId, {
totalAmount: total,
priorityScore: ruleResult.priorityScore,
pricingBreakdown: {
lineItems,
totalAmount: total,
currency: booking.paymentCurrency,
generatedAt: new Date().toISOString(),
},
} as never);
return {
bookingId,
totalAmount: total,
currency: booking.paymentCurrency,
lineItems,
warnings: ruleResult.warnings,
};
}
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,
};
}),
);
return {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId ?? null,
serviceTypeId: booking.serviceTypeId,
paymentCurrency: booking.paymentCurrency,
tradeDirection: booking.tradeDirection,
isHazardous: booking.isHazardous,
allowConsolidation: booking.allowConsolidation,
shippingLineId: booking.shippingLineId,
containers,
};
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByIdWithFiles(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return booking;
}
/** Line items for contract schedule (uses stored breakdown or recomputes). */
async computeContractLineItems(booking: Booking): Promise<{
lineItems: PriceLineItemDto[];
totalAmount: number;
currency: string;
}> {
const stored = booking.pricingBreakdown as {
lineItems?: PriceLineItemDto[];
totalAmount?: number;
currency?: string;
} | null;
if (stored?.lineItems?.length) {
return {
lineItems: stored.lineItems,
totalAmount: Number(stored.totalAmount ?? booking.totalAmount),
currency: stored.currency ?? booking.paymentCurrency,
};
}
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
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) {
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,
currency: booking.paymentCurrency,
});
}
return {
lineItems,
totalAmount: total || Number(booking.totalAmount),
currency: booking.paymentCurrency,
};
}
/** Recompute priority on submit (USD + service tier). */
async computeSubmitPriorityScore(booking: Booking): Promise<number> {
const evalInput = await this.buildEvalInputForBooking(booking);
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
let score = ruleResult.priorityScore;
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
if (booking.paymentCurrency === 'USD' && serviceType) {
const code = (serviceType.code ?? '').toUpperCase();
const hasForwarding =
serviceType.includesFirstMile ||
serviceType.includesLastMile ||
code.includes('FORWARD') ||
code.includes('Y');
const railOnly = code.includes('RAIL') && !hasForwarding;
if (hasForwarding) score += 1000;
else if (railOnly || code.includes('X')) score += 500;
}
return score;
}
private async computeBaseRailLines(
booking: Booking,
evalInput: BookingEvaluationInput,
): Promise<PriceLineItemDto[]> {
const liveRates = await this.ratesService.findLiveRates();
const currency = booking.paymentCurrency;
const isBulk = booking.freightType === 'BULK';
console.log('liveRates----', liveRates);
const rateType =
booking.tradeDirection === 'IMPORT'
? isBulk
? 'BULK_IMPORT'
: 'CONTAINER_IMPORT'
: booking.tradeDirection === 'EXPORT'
? isBulk
? 'BULK_EXPORT'
: 'CONTAINER_EXPORT'
: 'INTERCITY_CONTAINER';
console.log('rateType----', rateType);
const lines: PriceLineItemDto[] = [];
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);
if (!rate) continue;
const amount = this.amountForRate(rate, container.quantity, wagonCount);
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: rate.currency,
});
}
if (lines.length === 0) {
const fallback = liveRates.find(
(r) => r.rateType === rateType && r.currency === currency && r.status === 'LIVE',
);
if (fallback) {
const amount = this.amountForRate(fallback, 1, wagonCount);
lines.push({
code: rateType,
description: `Base rail (${rateType})`,
amount,
currency: fallback.currency,
});
}
}
return lines;
}
private pickRate(
rates: Rate[],
rateType: string,
containerTypeId: string,
currency: string,
): Rate | undefined {
return (
rates.find(
(r) =>
r.rateType === rateType &&
r.currency === currency &&
r.containerTypeId === containerTypeId,
) ??
rates.find((r) => r.rateType === rateType && r.currency === currency && !r.containerTypeId)
);
}
private amountForRate(rate: Rate, quantity: number, wagonCount: number): number {
const value = Number(rate.rateValue);
switch (rate.rateUnit) {
case 'PER_CONTAINER':
return value * quantity;
case 'PER_WAGON':
return value * wagonCount;
case 'PER_TON':
return value * quantity;
case 'FLAT':
return value;
default:
return value * quantity;
}
}
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);
}
}
}

View File

@@ -0,0 +1,10 @@
import { ConflictException } from '@nestjs/common';
import { Booking } from './entities/booking.entity';
export function assertBookingStatus(booking: Booking, allowed: string[]): void {
if (!allowed.includes(booking.status)) {
throw new ConflictException(
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
);
}
}

View File

@@ -0,0 +1,319 @@
import { BadRequestException, 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';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { BookingContractService } from './booking-contract.service';
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 { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
@Injectable()
export class BookingTransitionService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly ruleEngineService: RuleEngineService,
private readonly pricingService: BookingPricingService,
private readonly contractService: BookingContractService,
@Inject(forwardRef(() => BookingsService))
private readonly bookingsService: BookingsService,
) {}
async submit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['DRAFT', 'CHANGES_REQUESTED']);
if (Number(booking.totalAmount) <= 0) {
throw new BadRequestException(
'Generate a price before submitting (POST /bookings/:id/generate-price)',
);
}
const priorityScore = await this.pricingService.computeSubmitPriorityScore(booking);
await this.ruleEngineService.snapshotLiveRates(bookingId);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SUBMITTED',
priorityScore,
} as never);
return this.bookingsService.findById(updated!.id);
}
async requestChanges(
bookingId: string,
note: string,
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
await this.bookingsRepository.createReviewNote(
bookingId,
note,
'CHANGES_REQUESTED',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CHANGES_REQUESTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
/** Auto-create booking approval steps from system rules when none exist yet. */
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
});
}
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
await this.ruleEngineService.instantiateApprovalSteps(bookingId, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PENDING_APPROVAL',
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async staffReject(
bookingId: string,
reason: string,
actorId: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async approveStep(
bookingId: string,
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> {
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
await this.ensureBookingApprovalSteps(booking);
booking = await this.bookingsService.findById(bookingId);
}
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step || step.status !== 'PENDING') {
throw new BadRequestException('Approval step not found or already actioned');
}
const next = await this.bookingsRepository.findNextPendingApprovalStep(bookingId);
if (!next || next.id !== step.id) {
throw new BadRequestException(
'Approval steps must be completed in order',
);
}
if (step.requiredRole !== requiredRole) {
throw new BadRequestException(
`Step requires role ${step.requiredRole}, not ${requiredRole}`,
);
}
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
}
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
const updates: Record<string, unknown> = {};
const now = new Date();
if (requiredRole === 'LINE_STAFF') {
updates.status = 'APPROVED_PENDING_SIGNATURE';
updates.approvedByStaffId = actorId;
updates.approvedByStaffAt = now;
} else if (requiredRole === 'DIRECTOR') {
updates.signedByDirectorId = actorId;
updates.signedByDirectorAt = now;
} else if (requiredRole === 'CEO') {
updates.signedByCeoId = actorId;
updates.signedByCeoAt = now;
}
const allDone = await this.bookingsRepository.allApprovalStepsComplete(bookingId);
if (allDone) {
updates.status = 'APPROVED';
}
if (Object.keys(updates).length > 0) {
await this.bookingsRepository.update(bookingId, updates as never);
}
return this.bookingsService.findById(bookingId);
}
async rejectStep(
bookingId: string,
stepId: string,
actorId: string,
reason: string,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE']);
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
);
if (!step) throw new BadRequestException('Approval step not found');
await this.bookingsRepository.completeApprovalStep(
step.id,
actorId,
'REJECTED',
reason,
);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
actorId,
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'REJECTED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async customerSign(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['CONTRACT_READY']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async marketingApprove(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
marketingApprovedById: actorId,
marketingApprovedAt: new Date(),
lockedAt: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async startTransit(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['PAID']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'IN_TRANSIT',
} as never);
return this.bookingsService.findById(updated!.id);
}
async complete(bookingId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['IN_TRANSIT']);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'COMPLETED',
endDate: new Date(),
} as never);
return this.bookingsService.findById(updated!.id);
}
async cancel(bookingId: string, reason: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'DRAFT',
'SUBMITTED',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'CONTRACT_READY',
]);
await this.bookingsRepository.createReviewNote(
bookingId,
reason,
'REJECTION',
);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'CANCELLED',
} as never);
return this.bookingsService.findById(updated!.id);
}
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
'CHANGES_REQUESTED',
);
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === 'PENDING_APPROVAL' ||
booking.status === 'APPROVED_PENDING_SIGNATURE'
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
};
}
}

View File

@@ -10,10 +10,15 @@ import {
Post,
Query,
Request,
Res,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
} from '@nestjs/common';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
ApiBody,
@@ -21,181 +26,392 @@ import {
ApiOkResponse,
ApiOperation,
ApiTags,
} from "@nestjs/swagger";
} from '@nestjs/swagger';
import type { Response } from 'express';
import { BookingReferenceDataService } from "./booking-reference-data.service";
import { BookingsService } from "./bookings.service";
import { BookingReferenceDataDto } from "./dto/booking-reference-data.dto";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import { UpdateStatusDto } from "./dto/update-status.dto";
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
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 {
ApproveStepDto,
CancelBookingDto,
RejectStepDto,
RequestChangesDto,
StaffRejectDto,
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../common/resolve-auth-user-id';
@ApiTags("bookings")
@Controller("bookings")
@ApiTags('bookings')
@Controller('bookings')
@ApiBearerAuth()
export class BookingsController {
constructor(
private readonly bookingsService: BookingsService,
private readonly bookingReferenceDataService: BookingReferenceDataService,
private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
) {}
// ── 1. Create booking (multipart/form-data) ──────────────────────────
@Post()
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Create a new freight booking",
description:
"Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
"Auto-enables consolidation when container quantity does not fill a whole wagon; attempts partner match or PENDING_CONSOLIDATION.",
})
@ApiBody({
description:
"Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
"Each uploaded file is saved as a row in the files table (resource=bookings).",
type: CreateBookingDto,
})
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Create a new freight booking (DRAFT)' })
@ApiBody({ type: CreateBookingDto })
create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
@Request() req: any,
@Request() req: { user?: { id?: string; sub?: string } },
) {
console.log(
"[BookingsController] Files received:",
files?.length,
files?.map((f) => ({
fieldname: f.fieldname,
originalname: f.originalname,
size: f.size,
mimetype: f.mimetype,
})),
);
const userId: string | undefined = req.user?.id ?? req.user?.sub;
const userId = req.user?.id ?? req.user?.sub;
return this.bookingsService.create(dto, files ?? [], userId);
}
// ── 2. Update draft booking (multipart/form-data) ─────────────────────
@Patch(":id")
@Patch(':id')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: "Update a draft booking",
description:
"Only DRAFT bookings can be updated. New files are merged into existing documents.",
summary: 'Update booking',
description: 'Allowed when status is DRAFT or CHANGES_REQUESTED.',
})
@ApiBody({ type: UpdateBookingDto })
update(
@Param("id", ParseUUIDPipe) id: string,
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.bookingsService.update(id, dto, files ?? []);
}
// ── 3. List bookings (paginated + filtered) ───────────────────────────
@Get()
@ApiOperation({
summary: "List freight bookings (paginated)",
description:
"Filter by status, customerId, contractType, serviceTypeId, cargoTypeId, tradeDirection, " +
"paymentCurrency, allowConsolidation, consolidationPaired. " +
"Sort by createdAt or priorityScore.",
})
@ApiOperation({ summary: 'List freight bookings (paginated)' })
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
}
// ── Booking form catalog (must be before :id) ─────────────────────────
@Get("reference-data")
@Get('list-summary')
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Get('queues/:queue')
@ApiOperation({
summary: "Booking form catalog",
description:
"Returns yards, container types (grouped by size), service types, shipping lines, " +
"and hierarchical cargo types for the booking UI in a single payload.",
summary: 'List bookings for a dashboard queue',
description: 'Queues: intake, approval, signatures, marketing, finance',
})
findQueue(
@Param('queue') queue: string,
@Query() filter: FilterBookingDto,
@Query('excludeBulk') excludeBulk?: string,
) {
return this.bookingsService.findQueue(queue, filter, {
excludeBulk: excludeBulk === 'true',
});
}
@Get('reference-data')
@ApiOperation({ summary: 'Booking form catalog' })
@ApiOkResponse({ type: BookingReferenceDataDto })
getReferenceData(): Promise<BookingReferenceDataDto> {
return this.bookingReferenceDataService.getReferenceData();
}
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
@Get("by-reference/:reference")
@ApiOperation({
summary: "Get a freight booking by reference number",
description: "Lookup booking by its human-readable reference string.",
})
findByReference(@Param("reference") reference: string) {
return this.bookingsService.findByReference(reference);
@Get('by-reference/:reference')
@ApiOperation({ summary: 'Get booking by reference' })
async findByReference(@Param('reference') reference: string) {
const booking = await this.bookingsService.findByReference(reference);
return this.transitionService.enrichBookingResponse(booking);
}
// ── 4. Get single booking by ID ───────────────────────────────────────
@Get(":id")
@ApiOperation({ summary: "Get a freight booking by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.findById(id);
@Get(':id')
@ApiOperation({ summary: 'Get booking by ID' })
async findOne(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.bookingsService.findById(id);
return this.transitionService.enrichBookingResponse(booking);
}
// ── 6. Soft-delete (DRAFT only) ───────────────────────────────────────
@Delete(":id")
@Delete(':id')
@HttpCode(204)
@ApiOperation({
summary: "Soft-delete a freight booking",
description: "Only DRAFT bookings can be deleted.",
})
remove(@Param("id", ParseUUIDPipe) id: string) {
@ApiOperation({ summary: 'Soft-delete DRAFT booking' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
// ── 7. Unified status transition ──────────────────────────────────────
@Patch(":id/status")
@ApiOperation({
summary: "Transition booking status",
description:
"Unified endpoint for all status transitions. Actions: " +
"SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " +
"Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " +
"Bulk/high-volume → DIRECTOR → CEO → SIGNED.",
})
updateStatus(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateStatusDto,
@Post(':id/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a booking (DRAFT only)' })
async uploadDocuments(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.bookingsService.updateStatus(id, dto);
const booking = await this.bookingsService.uploadDocuments(id, files ?? []);
return this.transitionService.enrichBookingResponse(booking);
}
// ── 8. Request or auto-pair consolidation ─────────────────────────────
@Post(":id/consolidation")
@Post(':id/generate-price')
@ApiOperation({ summary: 'Generate price preview (DRAFT only)' })
@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);
}
@Post(':id/staff/request-changes')
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RequestChangesDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.requestChanges(
id,
dto.note,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/accept')
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.acceptIntake(
id,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/staff/reject')
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: StaffRejectDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.staffReject(
id,
dto.reason,
resolveAuthUserId(user),
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/approve')
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: 'Approve one approval step in sequence' })
async approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: RejectStepDto,
@CurrentUser() user: AuthUserPayload,
) {
const booking = await this.transitionService.rejectStep(
id,
stepId,
resolveAuthUserId(user),
dto.reason,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/contract/generate')
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
return this.transitionService.enrichBookingResponse(booking);
}
@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);
}
@Get(':id/contract/document')
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' })
async downloadContract(
@Param('id', ParseUUIDPipe) id: string,
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@Post(':id/contract/sign')
@ApiOperation({ summary: 'Apply digital signature (customer or staff)' })
async signContract(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const userId = req.user?.id ?? req.user?.sub;
const booking = await this.contractService.signContract(id, dto, {
signerUserId: userId,
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/contract/signatures')
@ApiOperation({ summary: 'List contract signatures' })
getContractSignatures(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getSignatures(id);
}
@Get(':id/summary')
@ApiOperation({ summary: 'Contract summary string for dashboard' })
getSummary(@Param('id', ParseUUIDPipe) id: string) {
return this.contractService.getSummary(id);
}
@Post(':id/customer/sign')
@ApiOperation({
summary: "Request freight consolidation",
description:
"Searches for a partner whose container quantity complements yours to fill whole wagon(s) " +
"(same route, same container type). Pairs on match or sets PENDING_CONSOLIDATION with a status message.",
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
})
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
async customerSign(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@Request() req: { user?: { id?: string; sub?: string }; ip?: string },
) {
const payload: SignContractDto = { ...dto, role: 'CUSTOMER' };
const booking = await this.contractService.signContract(id, payload, {
signerUserId: req.user?.id ?? req.user?.sub,
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/marketing/approve')
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
})
async marketingApprove(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SignContractDto,
@CurrentUser() user: AuthUserPayload,
@Request() req: { ip?: string },
) {
const payload: SignContractDto = {
...dto,
role: 'STAFF',
};
const booking = await this.contractService.signContract(id, payload, {
signerUserId: resolveAuthUserId(user),
ipAddress: req.ip,
});
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/start-transit')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/complete')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/cancel')
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' })
async cancel(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CancelBookingDto,
) {
const booking = await this.transitionService.cancel(id, dto.reason);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/consolidation')
@ApiOperation({ summary: 'Request freight consolidation' })
requestConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
// ── 9. Remove consolidation pairing ───────────────────────────────────
@Delete(":id/consolidation")
@ApiOperation({
summary: "Remove consolidation pairing",
description:
"Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
})
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
@Delete(':id/consolidation')
@ApiOperation({ summary: 'Remove consolidation pairing' })
removeConsolidation(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
// ── 10. Get consolidation details ─────────────────────────────────────
@Get(":id/consolidation")
@ApiOperation({
summary: "Get consolidation details",
description:
"Returns partner booking details and split billing information.",
})
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
@Get(':id/consolidation')
@ApiOperation({ summary: 'Get consolidation details' })
getConsolidationDetails(@Param('id', ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}

View File

@@ -1,12 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CustomersModule } from '../customers/customers.module';
// import { CustomersModule } from '../customers/customers.module';
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 { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
@@ -14,7 +20,14 @@ import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
@Module({
imports: [
@@ -24,19 +37,31 @@ import { Booking } from './entities/booking.entity';
BookingCargoModifier,
BookingApprovalStep,
BookingRateSnapshot,
BookingReviewNote,
BookingContractSignature,
]),
FilesModule,
MinioModule,
CustomersModule,
CompaniesModule,
// CustomersModule,
RuleEngineModule,
],
controllers: [BookingsController],
controllers: [BookingsController, PayController],
providers: [
BookingsService,
BookingsRepository,
ConsolidationService,
BookingReferenceDataService,
BookingPricingService,
BookingTransitionService,
BookingContractService,
BookingPaymentService,
ContractTemplateResolver,
ContractViewModelBuilder,
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
],
exports: [BookingsService],
exports: [BookingsService, BookingsRepository],
})
export class BookingsModule {}

View File

@@ -1,17 +1,36 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
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 { Booking } from './entities/booking.entity';
import {
BookingContractSignature,
ContractSignerRole,
} from './entities/booking-contract-signature.entity';
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
companyId?: string;
contractType?: string;
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
constructor(
@@ -56,7 +75,8 @@ export class BookingsRepository extends BaseRepository<Booking> {
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.bookingContainers', 'bc')
.leftJoinAndSelect('bc.containerType', 'ct')
.leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.train', 'train')
.leftJoinAndSelect('booking.serviceType', 'st')
.leftJoinAndSelect('booking.cargoType', 'cargo')
@@ -66,6 +86,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.approvalSteps', 'steps')
.leftJoinAndSelect('booking.rateSnapshots', 'snapshots')
.leftJoinAndSelect('booking.cargoModifiers', 'modifiers')
.leftJoinAndSelect('booking.reviewNotes', 'reviewNotes')
.where('booking.id = :id', { id })
.leftJoinAndMapMany(
'booking.files',
@@ -216,15 +237,33 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingContainer).delete({ bookingId });
}
/** Get pending approval step for a role. */
/** Lowest-order pending approval step (sequential enforcement). */
async findNextPendingApprovalStep(
bookingId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
}
async findApprovalStepById(
bookingId: string,
stepId: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, id: stepId },
});
}
/** Get pending approval step for a role (must match next in sequence). */
async findPendingApprovalStep(
bookingId: string,
requiredRole: string,
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, requiredRole, status: 'PENDING' },
order: { stepOrder: 'ASC' },
});
const next = await this.findNextPendingApprovalStep(bookingId);
if (!next || next.requiredRole !== requiredRole) return null;
return next;
}
/** Mark an approval step complete. */
@@ -277,4 +316,273 @@ export class BookingsRepository extends BaseRepository<Booking> {
where: { bookingId, rateId },
});
}
async createReviewNote(
bookingId: string,
note: string,
type: ReviewNoteType,
authorId?: string,
): Promise<BookingReviewNote> {
const repo = this.dataSource.getRepository(BookingReviewNote);
return repo.save(
repo.create({ bookingId, note, type, authorId: authorId ?? null }),
);
}
async findLatestReviewNote(
bookingId: string,
type?: ReviewNoteType,
): Promise<BookingReviewNote | null> {
const repo = this.dataSource.getRepository(BookingReviewNote);
return repo.findOne({
where: type ? { bookingId, type } : { bookingId },
order: { createdAt: 'DESC' },
});
}
async clearPricingArtifacts(bookingId: string): Promise<void> {
await this.dataSource.getRepository(BookingCargoModifier).delete({ bookingId });
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
}
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: {
status: string | string[];
page?: number;
pageSize?: number;
excludeBulk?: boolean;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ items: Booking[]; total: number }> {
const page = options.page ?? 1;
const pageSize = options.pageSize ?? 20;
const statuses = Array.isArray(options.status) ? options.status : [options.status];
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
qb.andWhere("booking.freight_type = 'CONTAINER'");
}
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
/** Paginated list with optional multi-status filter (API tab queues). */
async findAllPaginated(options: BookingListFilterOptions & {
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ items: Booking[]; total: number }> {
const page = options.page;
const pageSize = options.pageSize;
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.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');
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.groupBy('booking.status')
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(
rows.map((row) => [row.status, Number(row.count)]),
);
}
async getListSummaryMetrics(
options: BookingListFilterOptions & {
page: number;
pageSize: number;
needsActionStatuses: readonly string[];
urgentPriorityThreshold: number;
},
): Promise<{
inQueue: number;
onThisPage: number;
needsAction: number;
urgent: number;
}> {
const baseQb = () => {
const qb = this.repository
.createQueryBuilder('booking')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
return qb;
};
const inQueue = await baseQb().getCount();
const needsAction = await baseQb()
.andWhere('booking.status IN (:...needsActionStatuses)', {
needsActionStatuses: [...options.needsActionStatuses],
})
.getCount();
const urgent = await baseQb()
.andWhere('booking.priority_score >= :urgentPriorityThreshold', {
urgentPriorityThreshold: options.urgentPriorityThreshold,
})
.getCount();
const offset = (options.page - 1) * options.pageSize;
const onThisPage = Math.min(
options.pageSize,
Math.max(0, inQueue - offset),
);
return { inQueue, onThisPage, needsAction, urgent };
}
private applyListFilters(
qb: SelectQueryBuilder<Booking>,
options: BookingListFilterOptions,
): void {
if (options.statuses?.length) {
qb.andWhere('booking.status IN (:...statuses)', {
statuses: options.statuses,
});
} else if (options.status) {
qb.andWhere('booking.status = :status', { status: options.status });
}
if (options.companyId) {
qb.andWhere('booking.company_id = :companyId', {
companyId: options.companyId,
});
}
if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType,
});
}
if (options.serviceTypeId) {
qb.andWhere('booking.service_type_id = :serviceTypeId', {
serviceTypeId: options.serviceTypeId,
});
}
if (options.cargoTypeId) {
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
cargoTypeId: options.cargoTypeId,
});
}
if (options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.paymentCurrency) {
qb.andWhere('booking.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
});
}
if (options.allowConsolidation !== undefined) {
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
allowConsolidation: options.allowConsolidation,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {
qb.andWhere('booking.consolidation_partner_id IS NULL');
}
}
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
skip: number;
take: number;
order: Record<string, 'ASC' | 'DESC'>;
}): Promise<[Booking[], number]> {
return this.repository.findAndCount({
where,
skip: options.skip,
take: options.take,
order: options.order,
});
}
findContractSignatures(bookingId: string): Promise<BookingContractSignature[]> {
return this.dataSource.getRepository(BookingContractSignature).find({
where: { bookingId },
relations: ['signatureFile'],
order: { signedAt: 'ASC' },
});
}
findContractSignature(
bookingId: string,
role: ContractSignerRole,
): Promise<BookingContractSignature | null> {
return this.dataSource.getRepository(BookingContractSignature).findOne({
where: { bookingId, signerRole: role },
relations: ['signatureFile'],
});
}
async saveContractSignature(
data: Partial<BookingContractSignature>,
): Promise<BookingContractSignature> {
const repo = this.dataSource.getRepository(BookingContractSignature);
const existing = await repo.findOne({
where: {
bookingId: data.bookingId!,
signerRole: data.signerRole!,
},
});
if (existing) {
Object.assign(existing, data);
return repo.save(existing);
}
return repo.save(repo.create(data));
}
}

View File

@@ -4,9 +4,8 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import { CustomersService } from '../customers/customers.service';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service';
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
@@ -16,20 +15,35 @@ import {
} from '../rule-engine/rule-engine.service';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { UpdateStatusDto } from './dto/update-status.dto';
import {
BOOKING_STATUSES,
CUSTOMER_EDITABLE_STATUSES,
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
] as const;
@Injectable()
export class BookingsService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
private readonly minioService: MinioService,
private readonly customersService: CustomersService,
// private readonly customersService: CustomersService,
private readonly companiesService: CompaniesService,
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
@@ -42,22 +56,23 @@ export class BookingsService {
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
}
/** Build evaluation input from DTO containers. */
private async buildEvalInput(
dto: Pick<
CreateBookingDto,
| 'cargoTypeId'
| 'serviceTypeId'
| 'paymentCurrency'
| 'tradeDirection'
| 'isHazardous'
| 'allowConsolidation'
| 'shippingLineId'
| 'containers'
>,
): Promise<BookingEvaluationInput> {
/** Build evaluation input from booking freight shape. */
private async buildEvalInput(dto: {
freightType: FreightType;
cargoTypeId?: string | null;
serviceTypeId: string;
paymentCurrency: string;
tradeDirection: string;
isHazardous?: boolean;
allowConsolidation?: boolean;
shippingLineId?: string | null;
containers: CreateBookingContainerDto[];
}): Promise<BookingEvaluationInput> {
const containerLines =
dto.freightType === 'CONTAINER' ? dto.containers : [];
const containers = await Promise.all(
dto.containers.map(async (c) => {
containerLines.map(async (c) => {
const ct = await this.containerTypesService.findById(c.containerTypeId);
const totalVgmTons = c.quantity * c.vgmPerUnitTons;
return {
@@ -69,13 +84,16 @@ export class BookingsService {
};
}),
);
return {
cargoTypeId: dto.cargoTypeId,
freightType: dto.freightType,
cargoTypeId: dto.cargoTypeId ?? null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous ?? false,
allowConsolidation: dto.allowConsolidation,
allowConsolidation:
dto.freightType === 'CONTAINER' ? dto.allowConsolidation : false,
shippingLineId: dto.shippingLineId,
containers,
};
@@ -149,24 +167,52 @@ export class BookingsService {
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
let customerId = dto.customerId;
if (!customerId) {
// let customerId = dto.customerId;
// if (!customerId) {
// if (!userId) {
// throw new BadRequestException(
// 'customerId is required or must be resolvable from auth token',
// );
// }
// const customer = await this.customersService.findByUserId(userId);
// customerId = customer.id;
// }
let companyId = dto.companyId;
if (!companyId) {
if (!userId) {
throw new BadRequestException(
'customerId is required or must be resolvable from auth token',
'companyId is required or must be resolvable from auth token',
);
}
const customer = await this.customersService.findByUserId(userId);
customerId = customer.id;
const { company } = await this.companiesService.getCompanyInfoByUserId(userId);
companyId = company.id;
}
const reference = dto.reference || (await this.generateReference());
const allowConsolidation = await this.resolveConsolidation(
dto.containers,
dto.allowConsolidation,
);
const containers = dto.containers ?? [];
assertFreightShape({
freightType: dto.freightType,
cargoTypeId: dto.cargoTypeId,
containers,
});
const evalInput = await this.buildEvalInput({ ...dto, allowConsolidation });
const allowConsolidation =
dto.freightType === 'CONTAINER'
? await this.resolveConsolidation(containers, dto.allowConsolidation)
: false;
const evalInput = await this.buildEvalInput({
freightType: dto.freightType as FreightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId : null,
serviceTypeId: dto.serviceTypeId,
paymentCurrency: dto.paymentCurrency,
tradeDirection: dto.tradeDirection,
isHazardous: dto.isHazardous,
allowConsolidation,
shippingLineId: dto.shippingLineId,
containers,
});
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
this.ruleEngineService.assertNoHardBlocks(ruleResult);
@@ -174,7 +220,7 @@ export class BookingsService {
const booking = await this.bookingsRepository.create({
reference,
customerId,
companyId,
trainId: dto.trainId,
contractType: dto.contractType,
previousContractId: dto.previousContractId,
@@ -185,7 +231,8 @@ export class BookingsService {
originYardId: dto.originYardId,
destinationYardId: dto.destinationYardId,
tradeDirection: dto.tradeDirection,
cargoTypeId: dto.cargoTypeId,
freightType: dto.freightType,
cargoTypeId: dto.freightType === 'BULK' ? dto.cargoTypeId! : null,
cargoFreeText: dto.cargoFreeText,
shippingLineId: dto.shippingLineId,
cargoTotalWeightVgm: dto.cargoTotalWeightVgm,
@@ -203,18 +250,19 @@ export class BookingsService {
paymentStatus: 'PENDING',
});
await this.bookingsRepository.createContainers(
booking.id,
dto.containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
warnings.push(`Estimated wagons required: ${wagonCount}`);
if (dto.freightType === 'CONTAINER') {
await this.bookingsRepository.createContainers(
booking.id,
containers.map((c, i) => ({
containerTypeId: c.containerTypeId,
quantity: c.quantity,
vgmPerUnitTons: c.vgmPerUnitTons,
weightResult: ruleResult.containerWeightResults[i],
})),
);
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
warnings.push(`Estimated wagons required: ${wagonCount}`);
}
if (files.length > 0) {
try {
@@ -242,24 +290,51 @@ export class BookingsService {
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const existing = await this.findById(id);
if (existing.status !== 'DRAFT') {
throw new BadRequestException('Only DRAFT bookings can be updated');
if (!CUSTOMER_EDITABLE_STATUSES.includes(existing.status as never)) {
throw new BadRequestException(
'Only DRAFT or CHANGES_REQUESTED bookings can be updated',
);
}
const warnings: string[] = [];
const containers = dto.containers ?? existing.bookingContainers?.map((bc) => ({
containerTypeId: bc.containerTypeId,
quantity: bc.quantity,
vgmPerUnitTons: Number(bc.vgmPerUnitTons),
})) ?? [];
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),
})) ??
[];
const allowConsolidation = await this.resolveConsolidation(
containers,
dto.allowConsolidation ?? existing.allowConsolidation,
);
let cargoTypeId =
dto.cargoTypeId !== undefined ? dto.cargoTypeId : existing.cargoTypeId;
if (freightType === 'BULK') {
containers = [];
if (dto.containers !== undefined) {
await this.bookingsRepository.deleteContainers(id);
}
} else {
cargoTypeId = null;
if (dto.cargoTypeId !== undefined && dto.cargoTypeId !== null) {
throw new BadRequestException('cargoTypeId is not allowed for CONTAINER freight');
}
}
assertFreightShape({ freightType, cargoTypeId, containers });
const allowConsolidation =
freightType === 'CONTAINER'
? await this.resolveConsolidation(
containers,
dto.allowConsolidation ?? existing.allowConsolidation,
)
: false;
const evalInput = await this.buildEvalInput({
cargoTypeId: dto.cargoTypeId ?? existing.cargoTypeId,
freightType,
cargoTypeId,
serviceTypeId: dto.serviceTypeId ?? existing.serviceTypeId,
paymentCurrency: dto.paymentCurrency ?? existing.paymentCurrency,
tradeDirection: dto.tradeDirection ?? existing.tradeDirection,
@@ -275,6 +350,8 @@ export class BookingsService {
const updates: Record<string, unknown> = {
...dto,
freightType,
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
allowConsolidation,
priorityScore: ruleResult.priorityScore,
};
@@ -285,7 +362,7 @@ export class BookingsService {
await this.bookingsRepository.update(id, updates);
if (dto.containers) {
if (freightType === 'CONTAINER' && dto.containers) {
await this.bookingsRepository.deleteContainers(id);
await this.bookingsRepository.createContainers(
id,
@@ -313,40 +390,88 @@ export class BookingsService {
return { booking, warnings };
}
/** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterBookingDto): {
statuses?: string[];
status?: string;
} {
const allowed = new Set<string>(BOOKING_STATUSES);
const raw = filter.statuses;
const statusList = raw
? raw
.split(',')
.map((s) => s.trim())
.filter((s) => allowed.has(s))
: [];
if (statusList.length > 0) {
return { statuses: statusList };
}
if (filter.status && allowed.has(filter.status)) {
return { status: filter.status };
}
return {};
}
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
if (filter.customerId) where.customerId = filter.customerId;
if (filter.contractType) where.contractType = filter.contractType;
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
if (filter.allowConsolidation !== undefined) {
where.allowConsolidation = filter.allowConsolidation;
}
if (filter.consolidationPaired === 'true') {
where.consolidationPartnerId = Not(IsNull());
} else if (filter.consolidationPaired === 'false') {
where.consolidationPartnerId = IsNull();
}
const sortField = filter.sortBy ?? 'createdAt';
const sortDir = filter.sortOrder ?? 'DESC';
const [items, total] = await this.bookingsRepository.findAndCount({
where,
skip: (page - 1) * pageSize,
take: pageSize,
order: { [sortField]: sortDir },
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
return { items, total };
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const listFilter = {
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
const [statusCounts, metrics] = await Promise.all([
this.bookingsRepository.getStatusCounts(),
this.bookingsRepository.getListSummaryMetrics({
...listFilter,
page,
pageSize,
needsActionStatuses: NEEDS_ACTION_STATUSES,
urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
}),
]);
return {
metrics,
tabs: mapStatusCountsToTabs(statusCounts),
};
}
/** Get a single booking by ID with files. */
@@ -359,7 +484,7 @@ export class BookingsService {
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.extractObjectName(file.url);
const objectName = this.minioService.getObjectNameFromUrl(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
}),
@@ -369,11 +494,6 @@ export class BookingsService {
return booking;
}
private extractObjectName(url: string): string {
const parts = url.split('/');
return parts.slice(4).join('/');
}
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
if (!booking) {
@@ -382,6 +502,21 @@ export class BookingsService {
return this.findById(booking.id);
}
/** Upload documents for a DRAFT booking. */
async uploadDocuments(
id: string,
files: Express.Multer.File[],
): Promise<Booking> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
throw new BadRequestException(
'Documents can only be uploaded for DRAFT bookings',
);
}
await this.filesService.uploadMany(id, 'bookings', files);
return this.findById(id);
}
async remove(id: string): Promise<void> {
const booking = await this.findById(id);
if (booking.status !== 'DRAFT') {
@@ -390,201 +525,33 @@ export class BookingsService {
await this.bookingsRepository.softDelete(id);
}
/** Unified status transition handler. */
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
const booking = await this.findById(id);
const { action, actorId, reason, requiredRole } = dto;
async findQueue(
queue: string,
filter: FilterBookingDto,
options?: { excludeBulk?: boolean },
): Promise<{ items: Booking[]; total: number }> {
const statusMap: Record<string, string | string[]> = {
intake: 'SUBMITTED',
approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
marketing: 'SIGNED_CUSTOMER',
finance: 'FULLY_EXECUTED',
};
switch (action) {
case 'SUBMIT':
return this.handleSubmit(booking);
case 'SEND_QUOTATION':
return this.handleSendQuotation(booking);
case 'APPROVE_QUOTATION':
return this.handleApproveQuotation(booking);
case 'REJECT_QUOTATION':
return this.handleRejectQuotation(booking, reason);
case 'APPROVE_STEP':
return this.handleApproveStep(booking, actorId, requiredRole);
case 'APPROVE':
return this.handleFullyApproved(booking);
case 'CUSTOMER_SIGN':
return this.handleCustomerSign(booking);
case 'MARK_FULLY_EXECUTED':
return this.handleFullyExecuted(booking);
case 'MARK_PAID':
return this.handleMarkPaid(booking);
case 'START_TRANSIT':
return this.handleStartTransit(booking);
case 'COMPLETE':
return this.handleComplete(booking);
case 'REJECT':
return this.handleReject(booking, actorId, reason);
case 'CANCEL':
return this.handleCancel(booking, reason);
default:
throw new BadRequestException(`Unknown action: ${action}`);
}
}
/** SUBMIT: DRAFT → RFQ_SUBMITTED → PENDING_APPROVAL with approval steps and rate snapshots. */
private async handleSubmit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['DRAFT']);
await this.bookingsRepository.update(booking.id, { status: 'RFQ_SUBMITTED' } as never);
await this.ruleEngineService.snapshotLiveRates(booking.id);
await this.ruleEngineService.instantiateApprovalSteps(booking.id, booking.cargoTypeId);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'PENDING_APPROVAL',
} as never);
return updated!;
}
private async handleSendQuotation(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['RFQ_SUBMITTED']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'QUOTATION_SENT',
} as never);
return updated!;
}
private async handleApproveQuotation(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['QUOTATION_SENT']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'QUOTATION_APPROVED',
} as never);
return updated!;
}
private async handleRejectQuotation(booking: Booking, reason?: string): Promise<Booking> {
this.assertStatus(booking, ['QUOTATION_SENT']);
if (!reason) throw new BadRequestException('reason is required for REJECT_QUOTATION');
const updated = await this.bookingsRepository.update(booking.id, {
status: 'QUOTATION_REJECTED',
} as never);
return updated!;
}
private async handleApproveStep(
booking: Booking,
actorId?: string,
requiredRole?: string,
): Promise<Booking> {
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
if (!actorId || !requiredRole) {
throw new BadRequestException('actorId and requiredRole are required for APPROVE_STEP');
const status = statusMap[queue];
if (!status) {
throw new BadRequestException(`Unknown queue: ${queue}`);
}
const step = await this.bookingsRepository.findPendingApprovalStep(
booking.id,
requiredRole,
);
if (!step) {
throw new BadRequestException(`No pending approval step for role ${requiredRole}`);
}
await this.bookingsRepository.completeApprovalStep(step.id, actorId, 'APPROVED');
const allDone = await this.bookingsRepository.allApprovalStepsComplete(booking.id);
if (allDone) {
const updated = await this.bookingsRepository.update(booking.id, {
status: 'APPROVED',
} as never);
return updated!;
}
return this.findById(booking.id);
}
private async handleFullyApproved(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'APPROVED',
} as never);
return updated!;
}
private async handleCustomerSign(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['APPROVED']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'SIGNED_CUSTOMER',
customerSignedAt: new Date(),
} as never);
return updated!;
}
private async handleFullyExecuted(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'FULLY_EXECUTED',
fullyExecutedAt: new Date(),
} as never);
return updated!;
}
private async handleMarkPaid(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['FULLY_EXECUTED', 'APPROVED', 'SIGNED_CUSTOMER']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'PAID',
paymentStatus: 'PAID',
} as never);
return updated!;
}
private async handleStartTransit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['PAID']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'IN_TRANSIT',
} as never);
return updated!;
}
private async handleComplete(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ['IN_TRANSIT']);
const updated = await this.bookingsRepository.update(booking.id, {
status: 'COMPLETED',
endDate: new Date(),
} as never);
return updated!;
}
private async handleReject(
booking: Booking,
actorId?: string,
reason?: string,
): Promise<Booking> {
this.assertStatus(booking, ['PENDING_APPROVAL', 'QUOTATION_APPROVED']);
if (!actorId || !reason) {
throw new BadRequestException('actorId and reason are required for REJECT');
}
const updated = await this.bookingsRepository.update(booking.id, {
status: 'CANCELLED',
} as never);
return updated!;
}
private async handleCancel(booking: Booking, reason?: string): Promise<Booking> {
this.assertStatus(booking, [
'DRAFT',
'RFQ_SUBMITTED',
'QUOTATION_SENT',
'QUOTATION_APPROVED',
'PENDING_APPROVAL',
]);
if (!reason) throw new BadRequestException('reason is required for CANCEL');
const updated = await this.bookingsRepository.update(booking.id, {
status: 'CANCELLED',
} as never);
return updated!;
}
private assertStatus(booking: Booking, allowed: string[]): void {
if (!allowed.includes(booking.status)) {
throw new ConflictException(
`Cannot perform this action on status "${booking.status}". Allowed: ${allowed.join(', ')}`,
);
}
return this.bookingsRepository.findQueue({
status,
page: filter.page,
pageSize: filter.pageSize,
excludeBulk: options?.excludeBulk ?? queue === 'approval',
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
}
async requestConsolidation(id: string): Promise<{

View File

@@ -0,0 +1,34 @@
import { ApiProperty } from '@nestjs/swagger';
export class BookingListSummaryMetricsDto {
@ApiProperty({ example: 42 })
inQueue!: number;
@ApiProperty({ example: 10 })
onThisPage!: number;
@ApiProperty({ example: 8 })
needsAction!: number;
@ApiProperty({ example: 3 })
urgent!: number;
}
export class BookingListSummaryTabsDto {
@ApiProperty() all!: number;
@ApiProperty() intake!: number;
@ApiProperty() in_approval!: number;
@ApiProperty() approved_contract!: number;
@ApiProperty() payment!: number;
@ApiProperty() operations!: number;
@ApiProperty() completed!: number;
@ApiProperty() closed!: number;
}
export class BookingListSummaryDto {
@ApiProperty({ type: BookingListSummaryMetricsDto })
metrics!: BookingListSummaryMetricsDto;
@ApiProperty({ type: BookingListSummaryTabsDto })
tabs!: BookingListSummaryTabsDto;
}

View File

@@ -0,0 +1,50 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class ContractSignatureDto {
@ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
role!: string;
@ApiProperty()
signerDisplayName!: string;
@ApiProperty()
signedAt!: string;
@ApiPropertyOptional()
signatureImageUrl?: string | null;
}
export class ContractViewDto {
@ApiProperty()
bookingId!: string;
@ApiProperty()
reference!: string;
@ApiProperty()
status!: string;
@ApiProperty()
templateKey!: string;
@ApiProperty()
title!: string;
@ApiProperty({ description: 'Full HTML document for in-browser display' })
html!: string;
@ApiProperty()
canSignCustomer!: boolean;
@ApiProperty()
canSignStaff!: boolean;
@ApiProperty()
hasContractDocument!: boolean;
@ApiProperty({ type: [ContractSignatureDto] })
signatures!: ContractSignatureDto[];
@ApiPropertyOptional()
pricingSchedule?: Record<string, unknown>;
}

View File

@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsDateString,
@@ -11,9 +12,12 @@ import {
IsString,
IsUUID,
Min,
Validate,
ValidateIf,
ValidateNested,
} from 'class-validator';
import { BOOKING_STATUSES } from '../entities/booking.entity';
import { BOOKING_STATUSES, FREIGHT_TYPES } from '../entities/booking.entity';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
const CONTRACT_TYPES = ['NEW', 'RENEWAL'] as const;
const EQUIPMENT_RETURNS = ['WITH_RETURN', 'WITHOUT_RETURN', 'NA'] as const;
@@ -24,6 +28,7 @@ export {
BOOKING_STATUSES,
CONTRACT_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
};
@@ -47,16 +52,24 @@ export class CreateBookingContainerDto {
}
export class CreateBookingDto {
/** Class-level freight shape check (not a request field). */
@Validate(BookingFreightShapeConstraint)
freightShapeValidation?: boolean;
@ApiPropertyOptional({ description: 'Unique booking reference (auto-generated if omitted)' })
@IsOptional()
@IsString()
@Transform(({ value }) => (typeof value === 'string' ? value.trim() : value))
reference?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer' })
// @ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target customer (legacy)' })
// @IsOptional()
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Admin only: target company' })
@IsOptional()
@IsUUID()
customerId?: string;
companyId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@@ -107,9 +120,17 @@ export class CreateBookingDto {
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ format: 'uuid', description: 'FK to cargo_types.id' })
@ApiProperty({ enum: FREIGHT_TYPES, description: 'CONTAINER or BULK (mutually exclusive cargo shape)' })
@IsIn([...FREIGHT_TYPES])
freightType!: string;
@ApiPropertyOptional({
format: 'uuid',
description: 'Required for BULK; must be omitted for CONTAINER',
})
@ValidateIf((o) => o.freightType === 'BULK')
@IsUUID()
cargoTypeId!: string;
cargoTypeId?: string;
@ApiPropertyOptional({ maxLength: 200 })
@IsOptional()
@@ -157,11 +178,16 @@ export class CreateBookingDto {
@IsString()
financialTerms?: string;
@ApiProperty({ type: [CreateBookingContainerDto] })
@ApiPropertyOptional({
type: [CreateBookingContainerDto],
description: 'Required for CONTAINER (min 1 line); must be empty for BULK',
})
@ValidateIf((o) => o.freightType === 'CONTAINER')
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => CreateBookingContainerDto)
containers!: CreateBookingContainerDto[];
containers?: CreateBookingContainerDto[];
@ApiPropertyOptional({ default: false })
@IsOptional()

View File

@@ -1,7 +1,12 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsIn, IsOptional, IsUUID } from 'class-validator';
import { BOOKING_STATUSES, PAYMENT_CURRENCIES, TRADE_DIRECTIONS } from './create-booking.dto';
import {
BOOKING_STATUSES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
TRADE_DIRECTIONS,
} from './create-booking.dto';
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@@ -9,10 +14,27 @@ export class FilterBookingDto {
@IsIn([...BOOKING_STATUSES])
status?: string;
@ApiPropertyOptional({
description:
'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.',
})
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (Array.isArray(value)) return value.map(String).join(',');
return String(value);
})
statuses?: string;
// @ApiPropertyOptional({ format: 'uuid' })
// @IsOptional()
// @IsUUID()
// customerId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
customerId?: string;
companyId?: string;
@ApiPropertyOptional()
@IsOptional()
@@ -28,6 +50,11 @@ export class FilterBookingDto {
@IsUUID()
cargoTypeId?: string;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])

View File

@@ -0,0 +1,32 @@
import { ApiProperty } from '@nestjs/swagger';
export class PriceLineItemDto {
@ApiProperty()
code!: string;
@ApiProperty()
description!: string;
@ApiProperty()
amount!: number;
@ApiProperty()
currency!: string;
}
export class GeneratePriceResponseDto {
@ApiProperty()
bookingId!: string;
@ApiProperty()
totalAmount!: number;
@ApiProperty()
currency!: string;
@ApiProperty({ type: [PriceLineItemDto] })
lineItems!: PriceLineItemDto[];
@ApiProperty({ type: [String] })
warnings!: string[];
}

View File

@@ -0,0 +1,26 @@
import { ApiProperty } from '@nestjs/swagger';
export class InAppPaymentReceiptDto {
@ApiProperty({ example: true })
success!: boolean;
@ApiProperty({ example: 'TELEBIRR' })
provider!: string;
@ApiProperty({ example: 'TB-BK-2026-000123-1717584000000' })
providerRef!: string;
@ApiProperty({ example: 15000 })
amount!: number;
@ApiProperty({ example: 'ETB' })
currency!: string;
@ApiProperty({ example: '2026-06-05T12:00:00.000Z' })
paidAt!: string;
}
export class PayBookingResponseDto {
@ApiProperty({ type: InAppPaymentReceiptDto })
paymentReceipt!: InAppPaymentReceiptDto;
}

View File

@@ -0,0 +1,36 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, MinLength } from 'class-validator';
export class RequestChangesDto {
@ApiProperty({ description: 'Staff note explaining what the customer must fix' })
@IsString()
@MinLength(1)
note!: string;
}
export class StaffRejectDto {
@ApiProperty()
@IsString()
@MinLength(1)
reason!: string;
}
export class ApproveStepDto {
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
@IsString()
requiredRole!: string;
}
export class RejectStepDto {
@ApiProperty()
@IsString()
@MinLength(1)
reason!: string;
}
export class CancelBookingDto {
@ApiProperty()
@IsString()
@MinLength(1)
reason!: string;
}

View File

@@ -0,0 +1,23 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, MinLength } from 'class-validator';
export class SignContractDto {
@ApiProperty({ enum: ['CUSTOMER', 'STAFF'] })
@IsIn(['CUSTOMER', 'STAFF'])
role!: 'CUSTOMER' | 'STAFF';
@ApiProperty({ description: 'PNG signature image as base64 (with or without data URL prefix)' })
@IsString()
@MinLength(20)
signatureImageBase64!: string;
@ApiProperty()
@IsString()
@MinLength(1)
signerDisplayName!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
consentText?: string;
}

View File

@@ -1,5 +1,10 @@
import { PartialType } from "@nestjs/mapped-types";
import { PartialType } from '@nestjs/mapped-types';
import { Validate } from 'class-validator';
import { CreateBookingDto } from "./create-booking.dto";
import { CreateBookingDto } from './create-booking.dto';
import { BookingFreightShapeConstraint } from './validators/booking-freight.validator';
export class UpdateBookingDto extends PartialType(CreateBookingDto) {}
export class UpdateBookingDto extends PartialType(CreateBookingDto) {
@Validate(BookingFreightShapeConstraint)
freightShapeValidation?: boolean;
}

View File

@@ -1,41 +0,0 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
const STATUS_ACTIONS = [
'SUBMIT',
'SEND_QUOTATION',
'APPROVE_QUOTATION',
'REJECT_QUOTATION',
'APPROVE_STEP',
'APPROVE',
'CUSTOMER_SIGN',
'MARK_FULLY_EXECUTED',
'MARK_PAID',
'START_TRANSIT',
'COMPLETE',
'REJECT',
'CANCEL',
] as const;
export { STATUS_ACTIONS };
export class UpdateStatusDto {
@ApiProperty({ enum: STATUS_ACTIONS })
@IsIn([...STATUS_ACTIONS])
action!: string;
@ApiPropertyOptional({ format: 'uuid', description: 'Staff/director/CEO actor' })
@IsOptional()
@IsUUID()
actorId?: string;
@ApiPropertyOptional({ description: 'Required role for APPROVE_STEP (LINE_STAFF, DIRECTOR, CEO)' })
@IsOptional()
@IsString()
requiredRole?: string;
@ApiPropertyOptional({ description: 'Required for REJECT, REJECT_QUOTATION, CANCEL' })
@IsOptional()
@IsString()
reason?: string;
}

View File

@@ -0,0 +1,60 @@
import {
ValidationArguments,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
import { FREIGHT_TYPES, FreightType } from '../../entities/booking.entity';
export interface BookingFreightShapeInput {
freightType?: string;
cargoTypeId?: string | null;
containers?: Array<{ containerTypeId?: string }> | null;
}
@ValidatorConstraint({ name: 'BookingFreightShape', async: false })
export class BookingFreightShapeConstraint implements ValidatorConstraintInterface {
validate(_value: unknown, args: ValidationArguments): boolean {
const dto = args.object as BookingFreightShapeInput;
if (!dto.freightType || !FREIGHT_TYPES.includes(dto.freightType as FreightType)) {
return true;
}
const containers = dto.containers ?? [];
const hasContainers = containers.length > 0;
const hasCargoType =
dto.cargoTypeId !== undefined &&
dto.cargoTypeId !== null &&
String(dto.cargoTypeId).trim() !== '';
if (dto.freightType === 'BULK') {
if (hasContainers) return false;
if (!hasCargoType) return false;
return true;
}
if (dto.freightType === 'CONTAINER') {
if (hasCargoType) return false;
if (!hasContainers) return false;
return containers.every(
(c) =>
c.containerTypeId !== undefined &&
c.containerTypeId !== null &&
String(c.containerTypeId).trim() !== '',
);
}
return true;
}
defaultMessage(args: ValidationArguments): string {
const dto = args.object as BookingFreightShapeInput;
if (dto.freightType === 'BULK') {
return 'BULK freight requires cargoTypeId and must not include container lines';
}
if (dto.freightType === 'CONTAINER') {
return 'CONTAINER freight requires at least one container line with containerTypeId and must not include cargoTypeId';
}
return 'Invalid freight type shape';
}
}

View File

@@ -31,6 +31,9 @@ export class BookingApprovalStep extends BaseEntity {
@Column({ name: 'required_role', type: 'varchar', length: 30 })
requiredRole!: string;
@Column({ name: 'blocks_role', type: 'varchar', length: 30, nullable: true })
blocksRole?: string | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'PENDING' })
status!: ApprovalStepStatus;

View File

@@ -0,0 +1,44 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, Unique } from 'typeorm';
import { FileRecord } from '../../files/entities/file.entity';
import { Booking } from './booking.entity';
export const CONTRACT_SIGNER_ROLES = ['CUSTOMER', 'STAFF'] as const;
export type ContractSignerRole = (typeof CONTRACT_SIGNER_ROLES)[number];
@Entity({ schema: 'freight', name: 'booking_contract_signatures' })
@Unique(['bookingId', 'signerRole'])
@Index(['bookingId'])
export class BookingContractSignature extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'signer_role', type: 'varchar', length: 20 })
signerRole!: ContractSignerRole;
@Column({ name: 'signer_user_id', type: 'uuid', nullable: true })
signerUserId?: string | null;
@Column({ name: 'signer_display_name', type: 'varchar', length: 200 })
signerDisplayName!: string;
@Column({ name: 'signed_at', type: 'timestamptz' })
signedAt!: Date;
@Column({ name: 'signature_file_id', type: 'uuid', nullable: true })
signatureFileId?: string | null;
@ManyToOne(() => FileRecord, { nullable: true })
@JoinColumn({ name: 'signature_file_id' })
signatureFile?: FileRecord | null;
@Column({ name: 'consent_text', type: 'text', nullable: true })
consentText?: string | null;
@Column({ name: 'ip_address', type: 'varchar', length: 64, nullable: true })
ipAddress?: string | null;
}

View File

@@ -0,0 +1,26 @@
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 type ReviewNoteType = (typeof REVIEW_NOTE_TYPES)[number];
@Entity({ schema: 'freight', name: 'booking_review_note' })
@Index(['bookingId'])
export class BookingReviewNote extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, (b) => b.reviewNotes, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'author_id', type: 'uuid', nullable: true })
authorId?: string | null;
@Column({ name: 'note', type: 'text' })
note!: string;
@Column({ name: 'type', type: 'varchar', length: 30 })
type!: ReviewNoteType;
}

View File

@@ -1,6 +1,7 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Customer } from '../../customers/entities/customer.entity';
// import { Customer } from '../../customers/entities/customer.entity';
import { Company } from '../../companies/entities/company.entity';
import { CargoType } from '../../rule-engine/entities/cargo-type.entity';
import { ServiceType } from '../../rule-engine/entities/service-type.entity';
import { ShippingLine } from '../../rule-engine/entities/shipping-line.entity';
@@ -11,36 +12,68 @@ import { BookingApprovalStep } from './booking-approval-step.entity';
import { BookingCargoModifier } from './booking-cargo-modifier.entity';
import { BookingContainer } from './booking-container.entity';
import { BookingRateSnapshot } from './booking-rate-snapshot.entity';
import { BookingReviewNote } from './booking-review-note.entity';
export const BOOKING_STATUSES = [
'DRAFT',
'RFQ_SUBMITTED',
'QUOTATION_SENT',
'QUOTATION_APPROVED',
'QUOTATION_REJECTED',
'SUBMITTED',
'CHANGES_REQUESTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
'APPROVED',
'CONTRACT_READY',
'SIGNED_CUSTOMER',
'FULLY_EXECUTED',
'PNR_GENERATED',
'PAYMENT_VERIFICATION_IN_PROGRESS',
'PAID',
'IN_TRANSIT',
'COMPLETED',
'REJECTED',
'CANCELLED',
'PENDING_CONSOLIDATION',
'CONSOLIDATED',
] as const;
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
export const PAYMENT_STATUSES = [
'PENDING',
'PNR_GENERATED',
'VERIFICATION_IN_PROGRESS',
'PAID',
'FAILED',
] as const;
export type PaymentStatus = (typeof PAYMENT_STATUSES)[number];
export const FREIGHT_TYPES = ['CONTAINER', 'BULK'] as const;
export type FreightType = (typeof FREIGHT_TYPES)[number];
/** Statuses where the customer may edit booking fields. */
export const CUSTOMER_EDITABLE_STATUSES: BookingStatus[] = [
'DRAFT',
'CHANGES_REQUESTED',
];
@Entity({ schema: 'freight', name: 'bookings' })
export class Booking extends BaseEntity {
@Column({ name: 'reference', type: 'varchar', length: 64, unique: true })
reference!: string;
@Column({ name: 'customer_id', type: 'uuid' })
customerId!: string;
// Legacy — superseded by companyId (column kept in DB)
// @Column({ name: 'customer_id', type: 'uuid' })
// customerId!: string;
// @ManyToOne(() => Customer)
// @JoinColumn({ name: 'customer_id' })
// customer?: Customer;
@ManyToOne(() => Customer)
@JoinColumn({ name: 'customer_id' })
customer?: Customer;
@Column({ name: 'company_id', type: 'uuid' })
companyId!: string;
@ManyToOne(() => Company)
@JoinColumn({ name: 'company_id' })
company?: Company;
@Column({ name: 'train_id', type: 'uuid', nullable: true })
trainId?: string | null;
@@ -104,8 +137,11 @@ export class Booking extends BaseEntity {
@Column({ name: 'trade_direction', type: 'varchar', length: 10 })
tradeDirection!: string;
@Column({ name: 'cargo_type_id', type: 'uuid' })
cargoTypeId!: string;
@Column({ name: 'freight_type', type: 'varchar', length: 20 })
freightType!: string;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId?: string | null;
@ManyToOne(() => CargoType)
@JoinColumn({ name: 'cargo_type_id' })
@@ -169,6 +205,27 @@ export class Booking extends BaseEntity {
@Column({ name: 'fully_executed_at', type: 'timestamptz', nullable: true })
fullyExecutedAt?: Date | null;
@Column({ name: 'marketing_approved_by_id', type: 'uuid', nullable: true })
marketingApprovedById?: string | null;
@Column({ name: 'marketing_approved_at', type: 'timestamptz', nullable: true })
marketingApprovedAt?: Date | null;
@Column({ name: 'contract_summary', type: 'text', nullable: true })
contractSummary?: string | null;
@Column({ name: 'contract_template_key', type: 'varchar', length: 80, nullable: true })
contractTemplateKey?: string | null;
@Column({ name: 'contract_generated_at', type: 'timestamptz', nullable: true })
contractGeneratedAt?: Date | null;
@Column({ name: 'pricing_breakdown', type: 'jsonb', nullable: true })
pricingBreakdown?: Record<string, unknown> | null;
@Column({ name: 'locked_at', type: 'timestamptz', nullable: true })
lockedAt?: Date | null;
@Column({ name: 'priority_score', type: 'int', default: 0 })
priorityScore!: number;
@@ -194,6 +251,9 @@ export class Booking extends BaseEntity {
@OneToMany(() => BookingRateSnapshot, (s) => s.booking)
rateSnapshots?: BookingRateSnapshot[];
@OneToMany(() => BookingReviewNote, (n) => n.booking)
reviewNotes?: BookingReviewNote[];
@OneToMany(() => FileRecord, (file) => file.resourceId, {
createForeignKeyConstraints: false,
})

View File

@@ -0,0 +1,34 @@
import { Controller, Param, ParseUUIDPipe, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service';
import { BookingTransitionService } from './booking-transition.service';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
import { Booking } from './entities/booking.entity';
import { BookingNextStep } from './booking-next-step.util';
@ApiTags('payments')
@ApiBearerAuth()
@Controller('bookings')
export class PayController {
constructor(
private readonly paymentService: BookingPaymentService,
private readonly transitionService: BookingTransitionService,
) {}
@Post(':id/payment/pay')
@ApiOperation({ summary: 'Complete in-app payment (mock)' })
@ApiOkResponse({ description: 'Enriched booking with ephemeral payment receipt' })
async pay(@Param('id', ParseUUIDPipe) id: string): Promise<
Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
paymentReceipt: InAppPaymentReceiptDto;
}
> {
const { booking, receipt } = await this.paymentService.pay(id);
const abstract = await this.transitionService.enrichBookingResponse(booking);
return { ...abstract, paymentReceipt: receipt };
}
}

View File

@@ -0,0 +1,70 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto';
import { DeliverCargoDto } from './dto/deliver-cargo.dto';
import { CargoesService } from './cargoes.service';
@ApiTags('cargoes')
@Controller('cargoes')
export class CargoesController {
constructor(private readonly cargoesService: CargoesService) {}
@Post()
@ApiOperation({ summary: 'Create a new cargo' })
create(@Body() dto: CreateCargoDto) {
return this.cargoesService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List all cargoes' })
findAll() {
return this.cargoesService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get a cargo by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a cargo' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoDto) {
return this.cargoesService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a cargo' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.remove(id);
}
@Post(':id/load')
@ApiOperation({ summary: 'Load cargo into a container' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadCargoDto) {
return this.cargoesService.loadCargo(id, dto);
}
@Post(':id/unload')
@ApiOperation({ summary: 'Unload cargo from container' })
unload(@Param('id', ParseUUIDPipe) id: string) {
return this.cargoesService.unloadCargo(id);
}
@Post(':id/deliver')
@ApiOperation({ summary: 'Mark cargo as delivered' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto?: DeliverCargoDto) {
return this.cargoesService.deliverCargo(id, dto);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Cargo } from './entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
import { CargoesController } from './cargoes.controller';
import { CargoesService } from './cargoes.service';
@Module({
imports: [TypeOrmModule.forFeature([Cargo, Container])],
controllers: [CargoesController],
providers: [CargoesService],
exports: [CargoesService],
})
export class CargoesModule {}

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Cargo } from './entities/cargoes.entity';
@Injectable()
export class CargoesRepository extends BaseRepository<Cargo> {
constructor(
@InjectRepository(Cargo)
repository: Repository<Cargo>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,104 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateCargoDto } from './dto/create-cargo.dto';
import { UpdateCargoDto } from './dto/update-cargo.dto';
import { LoadCargoDto } from './dto/load-cargo.dto';
import { DeliverCargoDto } from './dto/deliver-cargo.dto';
import { Cargo } from './entities/cargoes.entity';
import { Container } from '../container-management/entities/container.entity';
@Injectable()
export class CargoesService {
constructor(
@InjectRepository(Cargo)
private readonly cargoRepo: Repository<Cargo>,
@InjectRepository(Container)
private readonly containerRepo: Repository<Container>,
) {}
async create(dto: CreateCargoDto): Promise<Cargo> {
const cargo = this.cargoRepo.create(dto);
return this.cargoRepo.save(cargo);
}
async findAll(): Promise<Cargo[]> {
return this.cargoRepo.find({ order: { cargoReference: 'ASC' } });
}
async findById(id: string): Promise<Cargo> {
const cargo = await this.cargoRepo.findOne({ where: { id } });
if (!cargo) throw new NotFoundException(`Cargo ${id} not found`);
return cargo;
}
async update(id: string, dto: UpdateCargoDto): Promise<Cargo> {
const cargo = await this.findById(id);
Object.assign(cargo, dto);
return this.cargoRepo.save(cargo);
}
async remove(id: string): Promise<void> {
const cargo = await this.findById(id);
await this.cargoRepo.remove(cargo);
}
async loadCargo(id: string, dto: LoadCargoDto): Promise<Cargo> {
const cargo = await this.cargoRepo.findOne({
where: { id },
relations: { container: true }, // ✅ fixed
});
if (!cargo) throw new NotFoundException('Cargo not found');
if (cargo.status !== 'PENDING') {
throw new ConflictException('Cargo already loaded or delivered');
}
cargo.status = 'LOADED';
cargo.loadedAt = new Date();
cargo.quantity = dto.quantity;
cargo.weight = dto.weight;
cargo.volume = dto.volume ?? null;
if (dto.description) cargo.description = dto.description;
if (cargo.container) {
cargo.container.status = 'LOADED';
await this.containerRepo.save(cargo.container);
}
return this.cargoRepo.save(cargo);
}
async unloadCargo(id: string): Promise<Cargo> {
const cargo = await this.findById(id);
if (cargo.status !== 'LOADED') {
throw new ConflictException('Cargo is not loaded');
}
cargo.status = 'UNLOADED';
cargo.unloadedAt = new Date();
return this.cargoRepo.save(cargo);
}
async deliverCargo(id: string, dto?: DeliverCargoDto): Promise<Cargo> {
const cargo = await this.cargoRepo.findOne({
where: { id },
relations: { container: true }, // ✅ fixed
});
if (!cargo) throw new NotFoundException('Cargo not found');
if (cargo.status !== 'LOADED') {
throw new ConflictException('Only loaded cargo can be delivered');
}
cargo.status = 'DELIVERED';
if (dto?.deliveryRemarks) cargo.description = dto.deliveryRemarks;
const remaining = await this.cargoRepo.count({
where: { containerId: cargo.containerId, status: 'LOADED' },
});
if (remaining === 0 && cargo.container) {
cargo.container.status = 'AVAILABLE';
await this.containerRepo.save(cargo.container);
}
return this.cargoRepo.save(cargo);
}
}

View File

@@ -0,0 +1,45 @@
import { IsString, IsUUID, IsOptional, IsNumber, Min, IsIn, IsDateString } from 'class-validator';
export class CreateCargoDto {
@IsString()
cargoReference!: string;
@IsUUID()
shipmentId!: string;
@IsUUID()
containerId!: string;
@IsOptional()
@IsUUID()
cargoTypeId?: string;
@IsOptional()
@IsString()
description?: string;
@IsNumber()
@Min(0.001)
quantity!: number;
@IsNumber()
@Min(0)
weight!: number;
@IsOptional()
@IsNumber()
@Min(0)
volume?: number;
@IsOptional()
@IsIn(['PENDING', 'LOADED', 'IN_TRANSIT', 'DELIVERED', 'UNLOADED'])
status?: string;
@IsOptional()
@IsDateString()
loadedAt?: string;
@IsOptional()
@IsDateString()
unloadedAt?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class DeliverCargoDto {
@IsOptional()
@IsString()
deliveryRemarks?: string;
}

View File

@@ -0,0 +1,20 @@
import { IsNumber, Min, IsOptional, IsString } from 'class-validator';
export class LoadCargoDto {
@IsNumber()
@Min(0.001)
quantity!: number;
@IsNumber()
@Min(0)
weight!: number;
@IsOptional()
@IsNumber()
@Min(0)
volume?: number;
@IsOptional()
@IsString()
description?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateCargoDto } from './create-cargo.dto';
export class UpdateCargoDto extends PartialType(CreateCargoDto) {}

View File

@@ -0,0 +1,45 @@
// apps/edr-freight-api/src/modules/cargoes/entities/cargo.entity.ts
import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
import { Container } from '../../container-management/entities/container.entity';
@Entity({ name: 'cargoes', schema: 'freight' })
export class Cargo extends BaseEntity {
@Column({ unique: true, name: 'cargo_reference' })
cargoReference!: string;
@Column({ name: 'shipment_id', type: 'uuid' })
shipmentId!: string;
@Column({ name: 'container_id', type: 'uuid' })
containerId!: string;
@Column({ name: 'cargo_type_id', type: 'uuid', nullable: true })
cargoTypeId!: string | null; // optional link to cargo_types table
@Column({ type: 'text', nullable: true })
description!: string | null;
@Column({ type: 'decimal', precision: 12, scale: 3 })
quantity!: number;
@Column({ type: 'decimal', precision: 10, scale: 2 })
weight!: number; // kg
@Column({ type: 'decimal', precision: 10, scale: 2, nullable: true })
volume!: number | null; // m³
@Column({ type: 'varchar', default: 'PENDING' })
status!: string; // PENDING, LOADED, IN_TRANSIT, DELIVERED, UNLOADED
@Column({ name: 'loaded_at', type: 'timestamp', nullable: true })
loadedAt!: Date | null;
@Column({ name: 'unloaded_at', type: 'timestamp', nullable: true })
unloadedAt!: Date | null;
// Relationship to Container
@ManyToOne(() => Container, (container) => container.cargoes, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'container_id' })
container!: Container;
}

View File

@@ -1,6 +1,8 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { Controller, Get, Post, Patch, Delete, Body, Param, Query, ParseUUIDPipe, HttpCode, HttpStatus, UseInterceptors, UploadedFiles } from '@nestjs/common';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import { ApiOperation, ApiTags, ApiConsumes } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FilesService } from '../files/files.service';
import { CompaniesService } from './companies.service';
import { CreateCompanyDto } from './dto/create-company.dto';
import { UpdateCompanyDto } from './dto/update-company.dto';
@@ -11,6 +13,8 @@ import { ResponseCompanyDto } from './dto/response-company.dto';
import { ResponseExternalProfileDto } from './dto/response-external-profile.dto';
import { ResponseFFClientDto } from './dto/response-ff-client.dto';
import { CompanyInfoResponseDto } from './dto/company-info-response.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
interface CurrentIamUser {
id: string;
@@ -22,7 +26,10 @@ interface CurrentIamUser {
@ApiTags('Companies')
@Controller('companies')
export class CompaniesController {
constructor(private readonly companiesService: CompaniesService) {}
constructor(
private readonly companiesService: CompaniesService,
private readonly filesService: FilesService,
) {}
@Get('getInfo')
@ApiOperation({ summary: 'Get company info for the current user' })
@@ -31,6 +38,22 @@ export class CompaniesController {
return new CompanyInfoResponseDto(profile, company);
}
@Get('profile')
@ApiOperation({ summary: 'Get flattened profile for the settings page' })
async getProfile(@CurrentUser() user: CurrentIamUser): Promise<ProfileResponseDto> {
const { profile, company } = await this.companiesService.getCompanyInfoByUserId(user.id);
return new ProfileResponseDto(profile, company);
}
@Patch('profile')
@ApiOperation({ summary: 'Update profile (flattened settings page)' })
async updateProfile(
@CurrentUser() user: CurrentIamUser,
@Body() dto: UpdateProfileDto,
): Promise<ProfileResponseDto> {
return this.companiesService.updateProfile(user.id, dto);
}
@Post('create')
@ApiOperation({ summary: 'Create a company with its associated external profile (onboarding)' })
async createWithProfile(
@@ -105,6 +128,17 @@ export class CompaniesController {
await this.companiesService.deleteCompany(id);
}
@Post(':companyId/documents')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload documents for a company (onboarding)' })
async uploadDocuments(
@Param('companyId', ParseUUIDPipe) companyId: string,
@UploadedFiles() files: Array<Express.Multer.File>,
) {
return this.filesService.uploadMany(companyId, 'companies', files);
}
@Post(':companyId/profiles')
@ApiOperation({ summary: 'Add a profile (employee) to a company' })
async createProfile(

View File

@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FilesModule } from '../files/files.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { CompaniesRepository } from './companies.repository';
@@ -10,7 +11,7 @@ import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@Module({
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient])],
imports: [TypeOrmModule.forFeature([Company, ExternalProfile, FFClient]), FilesModule],
controllers: [CompaniesController],
providers: [CompaniesService, CompaniesRepository, ExternalProfileRepository, FFClientRepository],
exports: [CompaniesService],

View File

@@ -7,6 +7,8 @@ import { UpdateCompanyDto } from './dto/update-company.dto';
import { CreateExternalProfileDto } from './dto/create-external-profile.dto';
import { CreateFFClientDto } from './dto/create-ff-client.dto';
import { CreateCompanyWithProfileDto } from './dto/create-company-with-profile.dto';
import { UpdateProfileDto } from './dto/update-profile.dto';
import { ProfileResponseDto } from './dto/profile-response.dto';
import { Company } from './entities/company.entity';
import { ExternalProfile } from './entities/external-profile.entity';
import { FFClient } from './entities/ff-client.entity';
@@ -103,6 +105,42 @@ export class CompaniesService {
return updated;
}
async updateProfile(userId: string, dto: UpdateProfileDto): Promise<ProfileResponseDto> {
const { profile, company } = await this.getCompanyInfoByUserId(userId);
const companyUpdates: Record<string, any> = {};
const attrUpdates: Record<string, any> = { ...(company.attributes ?? {}) };
if (dto.companyName !== undefined) companyUpdates.name = dto.companyName;
if (dto.companyEmail !== undefined) companyUpdates.email = dto.companyEmail;
if (dto.companyPhone !== undefined) companyUpdates.phone = dto.companyPhone;
if (dto.companyLocation !== undefined) companyUpdates.country = dto.companyLocation;
if (dto.companyAddress !== undefined) companyUpdates.address = dto.companyAddress;
if (dto.tin !== undefined) companyUpdates.tin = dto.tin;
if (dto.vatNumber !== undefined) companyUpdates.vatNumber = dto.vatNumber;
if (dto.fanNumber !== undefined) {
companyUpdates.businessLicense = dto.fanNumber;
companyUpdates.fanNumber = dto.fanNumber;
}
if (dto.contactPersonName !== undefined) attrUpdates.contactPersonName = dto.contactPersonName;
if (dto.contactPersonPhone !== undefined) attrUpdates.contactPersonPhone = dto.contactPersonPhone;
if (dto.generalManagerName !== undefined) attrUpdates.generalManagerName = dto.generalManagerName;
if (dto.generalManagerEmail !== undefined) attrUpdates.generalManagerEmail = dto.generalManagerEmail;
if (dto.generalManagerPhone !== undefined) attrUpdates.generalManagerPhone = dto.generalManagerPhone;
if (dto.poaName !== undefined) attrUpdates.poaName = dto.poaName;
if (dto.poaPhone !== undefined) attrUpdates.poaPhone = dto.poaPhone;
if (dto.poaEmail !== undefined) attrUpdates.poaEmail = dto.poaEmail;
if (dto.poaLocation !== undefined) attrUpdates.poaLocation = dto.poaLocation;
if (dto.poaAddress !== undefined) attrUpdates.poaAddress = dto.poaAddress;
companyUpdates.attributes = attrUpdates;
const updated = await this.companiesRepo.update(company.id, companyUpdates);
if (!updated) throw new NotFoundException(`Company ${company.id} not found`);
return new ProfileResponseDto(profile, updated);
}
async deleteCompany(id: string): Promise<void> {
await this.findCompanyById(id);
await this.companiesRepo.softDelete(id);

View File

@@ -0,0 +1,53 @@
import { Company } from '../entities/company.entity';
import { ExternalProfile } from '../entities/external-profile.entity';
export class ProfileResponseDto {
companyId: string;
companyName: string;
companyEmail: string | null;
companyPhone: string | null;
companyLocation: string;
companyAddress: string | null;
tinNumber: string;
vatNumber: string | null;
fanNumber: string | null;
contactPersonName: string | null;
contactPersonPhone: string | null;
generalManagerName: string | null;
generalManagerEmail: string | null;
generalManagerPhone: string | null;
poaName: string | null;
poaPhone: string | null;
poaEmail: string | null;
poaLocation: string | null;
poaAddress: string | null;
profileId: string;
constructor(profile: ExternalProfile, company: Company) {
this.companyId = company.id;
this.companyName = company.name;
this.companyEmail = company.email ?? null;
this.companyPhone = company.phone ?? null;
this.companyLocation = company.country;
this.companyAddress = company.address ?? null;
this.tinNumber = company.tin;
this.vatNumber = company.vatNumber ?? null;
this.fanNumber = company.fanNumber ?? null;
this.profileId = profile.id;
const attrs = company.attributes ?? {};
this.contactPersonName = attrs.contactPersonName ?? null;
this.contactPersonPhone = attrs.contactPersonPhone ?? null;
this.generalManagerName = attrs.generalManagerName ?? null;
this.generalManagerEmail = attrs.generalManagerEmail ?? null;
this.generalManagerPhone = attrs.generalManagerPhone ?? null;
this.poaName = attrs.poaName ?? null;
this.poaPhone = attrs.poaPhone ?? null;
this.poaEmail = attrs.poaEmail ?? null;
this.poaLocation = attrs.poaLocation ?? null;
this.poaAddress = attrs.poaAddress ?? null;
}
}

View File

@@ -0,0 +1,83 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches } from 'class-validator';
export class UpdateProfileDto {
@IsOptional()
@IsString()
@MaxLength(200)
companyName?: string;
@IsOptional()
@IsEmail()
@MaxLength(150)
companyEmail?: string;
@IsOptional()
@IsString()
@MaxLength(20)
companyPhone?: string;
@IsOptional()
@IsString()
@MaxLength(32)
companyLocation?: string;
@IsOptional()
@IsString()
companyAddress?: string;
@IsOptional()
@IsString()
@Length(10, 10)
@Matches(/^\d+$/, { message: 'TIN must contain only digits' })
tin?: string;
@IsOptional()
@IsString()
@MaxLength(50)
vatNumber?: string;
@IsOptional()
@IsString()
@MaxLength(16)
fanNumber?: string;
@IsOptional()
@IsString()
contactPersonName?: string;
@IsOptional()
@IsString()
contactPersonPhone?: string;
@IsOptional()
@IsString()
generalManagerName?: string;
@IsOptional()
@IsEmail()
generalManagerEmail?: string;
@IsOptional()
@IsString()
generalManagerPhone?: string;
@IsOptional()
@IsString()
poaName?: string;
@IsOptional()
@IsString()
poaPhone?: string;
@IsOptional()
@IsEmail()
poaEmail?: string;
@IsOptional()
@IsString()
poaLocation?: string;
@IsOptional()
@IsString()
poaAddress?: string;
}

View File

@@ -0,0 +1,63 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
import { ContainersService } from './containers.service';
@ApiTags('containers')
@Controller('containers')
export class ContainersController {
constructor(private readonly containersService: ContainersService) {}
@Post()
@ApiOperation({ summary: 'Create a new container' })
create(@Body() dto: CreateContainerDto) {
return this.containersService.create(dto);
}
@Get()
@ApiOperation({ summary: 'List all containers' })
findAll() {
return this.containersService.findAll();
}
@Get(':id')
@ApiOperation({ summary: 'Get a container by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.findById(id);
}
@Patch(':id')
@ApiOperation({ summary: 'Update a container' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerDto) {
return this.containersService.update(id, dto);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete a container' })
remove(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.remove(id);
}
@Post(':id/assign-wagon')
@ApiOperation({ summary: 'Assign container to a wagon' })
assignToWagon(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignContainerToWagonDto) {
return this.containersService.assignToWagon(id, dto);
}
@Post(':id/unassign-wagon')
@ApiOperation({ summary: 'Unassign container from wagon' })
unassignFromWagon(@Param('id', ParseUUIDPipe) id: string) {
return this.containersService.unassignFromWagon(id);
}
}

View File

@@ -0,0 +1,14 @@
// apps/edr-freight-api/src/modules/container-management/containers.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Container } from './entities/container.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
import { ContainersController } from './containers.controller';
import { ContainersService } from './containers.service';
@Module({
imports: [TypeOrmModule.forFeature([Container, Wagon])], // ✅ add Wagon
controllers: [ContainersController],
providers: [ContainersService],
})
export class ContainersModule {}

View File

@@ -0,0 +1,15 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Container } from './entities/container.entity';
@Injectable()
export class ContainersRepository extends BaseRepository<Container> {
constructor(
@InjectRepository(Container)
repository: Repository<Container>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,86 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
import { Container } from './entities/container.entity';
//import { ContainersRepository } from './containers.repository';
import { WagonsRepository } from '../wagons/wagons.repository';
@Injectable()
export class ContainersService {
constructor(
@InjectRepository(Container)
private readonly containerRepo: Repository<Container>,
private readonly wagonsRepository: WagonsRepository,
) {}
async create(dto: CreateContainerDto): Promise<Container> {
const container = this.containerRepo.create(dto);
// Convert undefined to null for optional fields
if (dto.wagonId === undefined) container.wagonId = null;
if (dto.position === undefined) container.position = null;
return this.containerRepo.save(container);
}
async findAll(): Promise<Container[]> {
return this.containerRepo.find({ order: { containerNumber: 'ASC' } });
}
async findById(id: string): Promise<Container> {
const container = await this.containerRepo.findOne({ where: { id } });
if (!container) throw new NotFoundException(`Container ${id} not found`);
return container;
}
async update(id: string, dto: UpdateContainerDto): Promise<Container> {
const container = await this.findById(id);
Object.assign(container, dto);
// Convert undefined to null for nullable fields
if (dto.wagonId === undefined) container.wagonId = null;
if (dto.position === undefined) container.position = null;
return this.containerRepo.save(container);
}
async remove(id: string): Promise<void> {
const container = await this.findById(id);
await this.containerRepo.remove(container);
}
async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise<Container> {
const container = await this.findById(containerId);
if (container.status === 'LOADED') {
throw new ConflictException('Cannot reassign a loaded container');
}
const wagon = await this.wagonsRepository.findById(dto.wagonId);
if (!wagon) throw new NotFoundException('Wagon not found');
let position: number | null = dto.position ?? null; // convert undefined to null
if (position === null) {
const maxPos = await this.containerRepo
.createQueryBuilder('c')
.select('MAX(c.position)', 'max')
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
.getRawOne();
position = (maxPos?.max ?? 0) + 1;
}
container.wagonId = wagon.id;
container.position = position; // now position is number | null, safe
container.status = 'AVAILABLE';
return this.containerRepo.save(container);
}
async unassignFromWagon(containerId: string): Promise<Container> {
const container = await this.findById(containerId);
if (container.status === 'LOADED') {
throw new ConflictException('Cannot unassign a loaded container');
}
container.wagonId = null;
container.position = null;
container.status = 'AVAILABLE';
return this.containerRepo.save(container);
}
}

View File

@@ -0,0 +1,85 @@
// apps/edr-freight-api/src/modules/container-management/containers.service.ts
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateContainerDto } from './dto/create-container.dto';
import { UpdateContainerDto } from './dto/update-container.dto';
import { AssignContainerToWagonDto } from './dto/assign-container-to-wagon.dto';
import { Container } from './entities/container.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@Injectable()
export class ContainersService {
constructor(
@InjectRepository(Container)
private readonly containerRepo: Repository<Container>,
@InjectRepository(Wagon)
private readonly wagonRepo: Repository<Wagon>, // ✅ use raw repository
) {}
async create(dto: CreateContainerDto): Promise<Container> {
const container = this.containerRepo.create(dto);
if (dto.wagonId === undefined) container.wagonId = null;
if (dto.position === undefined) container.position = null;
return this.containerRepo.save(container);
}
async findAll(): Promise<Container[]> {
return this.containerRepo.find({ order: { containerNumber: 'ASC' } });
}
async findById(id: string): Promise<Container> {
const container = await this.containerRepo.findOne({ where: { id } });
if (!container) throw new NotFoundException(`Container ${id} not found`);
return container;
}
async update(id: string, dto: UpdateContainerDto): Promise<Container> {
const container = await this.findById(id);
Object.assign(container, dto);
if (dto.wagonId === undefined) container.wagonId = null;
if (dto.position === undefined) container.position = null;
return this.containerRepo.save(container);
}
async remove(id: string): Promise<void> {
const container = await this.findById(id);
await this.containerRepo.remove(container);
}
async assignToWagon(containerId: string, dto: AssignContainerToWagonDto): Promise<Container> {
const container = await this.findById(containerId);
if (container.status === 'LOADED') {
throw new ConflictException('Cannot reassign a loaded container');
}
const wagon = await this.wagonRepo.findOne({ where: { id: dto.wagonId } });
if (!wagon) throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
let position: number | null = dto.position ?? null;
if (position === null) {
const maxPos = await this.containerRepo
.createQueryBuilder('c')
.select('MAX(c.position)', 'max')
.where('c.wagonId = :wagonId', { wagonId: wagon.id })
.getRawOne();
position = (maxPos?.max ?? 0) + 1;
}
container.wagonId = wagon.id;
container.position = position;
container.status = 'AVAILABLE';
return this.containerRepo.save(container);
}
async unassignFromWagon(containerId: string): Promise<Container> {
const container = await this.findById(containerId);
if (container.status === 'LOADED') {
throw new ConflictException('Cannot unassign a loaded container');
}
container.wagonId = null;
container.position = null;
container.status = 'AVAILABLE';
return this.containerRepo.save(container);
}
}

View File

@@ -0,0 +1,11 @@
import { IsUUID, IsOptional, IsInt, Min } from 'class-validator';
export class AssignContainerToWagonDto {
@IsUUID()
wagonId!: string;
@IsOptional()
@IsInt()
@Min(1)
position?: number;
}

View File

@@ -0,0 +1,34 @@
import { IsString, IsUUID, IsOptional, IsInt, Min, IsNumber, IsIn } from 'class-validator';
export class CreateContainerDto {
@IsString()
containerNumber!: string;
@IsUUID()
containerTypeId!: string;
@IsOptional()
@IsUUID()
wagonId?: string;
@IsOptional()
@IsInt()
@Min(1)
position?: number;
@IsNumber()
@Min(0)
tareWeight!: number;
@IsNumber()
@Min(0)
maxGrossWeight!: number;
@IsOptional()
@IsString()
sealNumber?: string;
@IsOptional()
@IsIn(['AVAILABLE', 'LOADED', 'IN_TRANSIT', 'MAINTENANCE', 'DAMAGED'])
status?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateContainerDto } from './create-container.dto';
export class UpdateContainerDto extends PartialType(CreateContainerDto) {}

View File

@@ -0,0 +1,45 @@
// apps/edr-freight-api/src/modules/container-management/entities/container.entity.ts
import { Entity, Column, ManyToOne, OneToMany, JoinColumn } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
import { Wagon } from '../../wagons/entities/wagon.entity';
import { Cargo } from '../../cargoes/entities/cargoes.entity';
@Entity({ name: 'containers', schema: 'freight' })
export class Container extends BaseEntity {
@Column({ unique: true, name: 'container_number' })
containerNumber!: string;
@Column({ name: 'container_type_id', type: 'uuid' })
containerTypeId!: string;
@Column({ name: 'wagon_id', type: 'uuid', nullable: true })
wagonId!: string | null;
@Column({ type: 'int', nullable: true })
position!: number | null; // position on the wagon (1..N)
@Column({ name: 'tare_weight', type: 'decimal', precision: 10, scale: 2 })
tareWeight!: number;
@Column({ name: 'max_gross_weight', type: 'decimal', precision: 10, scale: 2 })
maxGrossWeight!: number;
@Column({
name: 'seal_number',
type: 'varchar',
nullable: true,
})
sealNumber!: string | null;
@Column({ type: 'varchar', default: 'AVAILABLE' })
status!: string; // AVAILABLE, LOADED, IN_TRANSIT, MAINTENANCE, DAMAGED
// Relationship to Wagon
@ManyToOne(() => Wagon, (wagon) => wagon.containers, { onDelete: 'SET NULL' })
@JoinColumn({ name: 'wagon_id' })
wagon!: Wagon | null;
// Relationship to Cargo
@OneToMany(() => Cargo, (cargo) => cargo.container)
cargoes!: Cargo[];
}

View File

@@ -25,4 +25,12 @@ export class FilesRepository extends BaseRepository<FileRecord> {
): Promise<FileRecord | null> {
return this.repository.findOne({ where: { resourceId, resource, code } });
}
async deleteByCode(
resourceId: string,
resource: string,
code: string,
): Promise<void> {
await this.repository.delete({ resourceId, resource, code });
}
}

View File

@@ -35,6 +35,13 @@ export class FilesService {
});
}
/** Replace existing file row for the same resource + code (e.g. contract PDF). */
async upsertByCode(input: CreateFileInput): Promise<FileRecord> {
const { resourceId, resource, code } = input;
await this.filesRepository.deleteByCode(resourceId, resource, code);
return this.upload(input);
}
async uploadMany(
resourceId: string,
resource: string,
@@ -72,13 +79,8 @@ export class FilesService {
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id);
const objectName = this.extractObjectName(record.url);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
return { stream, record };
}
private extractObjectName(url: string): string {
const parts = url.split("/");
return parts.slice(4).join("/");
}
}

View File

@@ -0,0 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
import { LOCOMOTIVE_STATUSES } from '../entities/locomotive.entity';
export class FilterLocomotivesDto {
@ApiPropertyOptional({ enum: LOCOMOTIVE_STATUSES })
@IsOptional()
@IsIn([...LOCOMOTIVE_STATUSES])
status?: string;
}

View File

@@ -0,0 +1,36 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
export const LOCOMOTIVE_STATUSES = [
'AVAILABLE',
'ASSIGNED',
'MAINTENANCE',
'INACTIVE',
] as const;
export type LocomotiveStatus = (typeof LOCOMOTIVE_STATUSES)[number];
@Entity({ schema: 'freight', name: 'locomotives' })
@Index(['code'])
@Index(['status'])
export class Locomotive extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 32, unique: true })
code!: string;
@Column({ name: 'name', type: 'varchar', length: 100, nullable: true })
name?: string | null;
@Column({ name: 'max_pull_weight_tons', type: 'numeric', precision: 10, scale: 3 })
maxPullWeightTons!: number;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'AVAILABLE' })
status!: LocomotiveStatus;
@Column({ name: 'available_from', type: 'timestamptz', nullable: true })
availableFrom?: Date | null;
@OneToMany(() => TrainSet, (trainSet) => trainSet.locomotive)
trainSets?: TrainSet[];
}

View File

@@ -0,0 +1,18 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { LocomotivesService } from './locomotives.service';
@ApiTags('locomotives')
@ApiBearerAuth()
@Controller('locomotives')
export class LocomotivesController {
constructor(private readonly locomotivesService: LocomotivesService) {}
@Get()
@ApiOperation({ summary: 'List locomotives' })
findAll(@Query() filter: FilterLocomotivesDto) {
return this.locomotivesService.findAll(filter);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { LocomotivesController } from './locomotives.controller';
import { Locomotive } from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
import { LocomotivesService } from './locomotives.service';
@Module({
imports: [TypeOrmModule.forFeature([Locomotive])],
controllers: [LocomotivesController],
providers: [LocomotivesRepository, LocomotivesService],
exports: [LocomotivesRepository, LocomotivesService],
})
export class LocomotivesModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Locomotive } from './entities/locomotive.entity';
@Injectable()
export class LocomotivesRepository extends BaseRepository<Locomotive> {
constructor(
@InjectRepository(Locomotive)
repository: Repository<Locomotive>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { FilterLocomotivesDto } from './dto/filter-locomotives.dto';
import { Locomotive, type LocomotiveStatus } from './entities/locomotive.entity';
import { LocomotivesRepository } from './locomotives.repository';
@Injectable()
export class LocomotivesService {
constructor(private readonly locomotivesRepository: LocomotivesRepository) {}
findAll(filter: FilterLocomotivesDto): Promise<Locomotive[]> {
return this.locomotivesRepository.findAll({
where: filter.status
? { status: filter.status as LocomotiveStatus }
: undefined,
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<Locomotive> {
const locomotive = await this.locomotivesRepository.findById(id);
if (!locomotive) {
throw new NotFoundException(`Locomotive ${id} not found`);
}
return locomotive;
}
}

View File

@@ -1,4 +1,4 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { Inject, Injectable, Logger, NotFoundException } from "@nestjs/common";
import { ConfigType } from "@nestjs/config";
import { Client } from "minio";
import { Readable } from "stream";
@@ -54,6 +54,30 @@ export class MinioService {
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
}
getObjectNameFromUrl(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new NotFoundException("File object path is empty");
}
if (!/^https?:\/\//i.test(trimmed)) {
return trimmed.replace(/^\/+/, "");
}
const url = new URL(trimmed);
const parts = url.pathname.split("/").filter(Boolean);
if (parts[0] === this.bucket) {
parts.shift();
}
const objectName = parts.join("/");
if (!objectName) {
throw new NotFoundException("File object path is empty");
}
return objectName;
}
async deleteFile(objectName: string): Promise<void> {
try {
await this.client.removeObject(this.bucket, objectName);

View File

@@ -0,0 +1,31 @@
/** ITMLS US-06 default approval chains — seeded automatically when missing. */
export const DEFAULT_APPROVAL_RULE_ROWS = [
{
requiresDirectorApproval: false,
stepOrder: 1,
requiredRole: 'LINE_STAFF',
actionLabel: 'Review & Approve',
blocksRole: null as string | null,
},
{
requiresDirectorApproval: false,
stepOrder: 2,
requiredRole: 'DIRECTOR',
actionLabel: 'Final Signature',
blocksRole: 'LINE_STAFF',
},
{
requiresDirectorApproval: true,
stepOrder: 1,
requiredRole: 'DIRECTOR',
actionLabel: 'Review & Approve',
blocksRole: 'LINE_STAFF',
},
{
requiresDirectorApproval: true,
stepOrder: 2,
requiredRole: 'CEO',
actionLabel: 'Final Signature',
blocksRole: null as string | null,
},
] as const;

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateApprovalRuleDto } from '../dto/create-approval-rule.dto';
import { UpdateApprovalRuleDto } from '../dto/update-approval-rule.dto';
@@ -9,12 +10,12 @@ import { ApprovalRulesService } from '../services/approval-rules.service';
@ApiTags('approval-rules')
@Controller('approval-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ApprovalRulesController {
constructor(private readonly service: ApprovalRulesService) {}
@Get()
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'List approval rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -28,30 +29,35 @@ export class ApprovalRulesController {
}
@Get('chain')
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'Get approval chain for cargo routing flag' })
findChain(@Query('requiresDirectorApproval') flag: string) {
return this.service.findChain(flag === 'true');
}
@Get(':id')
@RuleEngineView('approval-rules')
@ApiOperation({ summary: 'Get an approval rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('approval-rules')
@ApiOperation({ summary: 'Create an approval rule step' })
create(@Body() dto: CreateApprovalRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('approval-rules')
@ApiOperation({ summary: 'Update an approval rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateApprovalRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('approval-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete an approval rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,18 +3,19 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateCargoTypeDto } from '../dto/create-cargo-type.dto';
import { UpdateCargoTypeDto } from '../dto/update-cargo-type.dto';
import { CargoTypesService } from '../services/cargo-types.service';
@ApiTags('cargo-types')
@Controller('cargo-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class CargoTypesController {
constructor(private readonly service: CargoTypesService) {}
@Get()
@RuleEngineView('cargo-types')
@ApiOperation({ summary: 'List cargo types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -32,24 +33,28 @@ export class CargoTypesController {
}
@Get(':id')
@RuleEngineView('cargo-types')
@ApiOperation({ summary: 'Get a cargo type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('cargo-types')
@ApiOperation({ summary: 'Create a cargo type' })
create(@Body() dto: CreateCargoTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('cargo-types')
@ApiOperation({ summary: 'Update a cargo type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateCargoTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('cargo-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a cargo type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,18 +3,19 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateContainerTypeDto } from '../dto/create-container-type.dto';
import { UpdateContainerTypeDto } from '../dto/update-container-type.dto';
import { ContainerTypesService } from '../services/container-types.service';
@ApiTags('container-types')
@Controller('container-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ContainerTypesController {
constructor(private readonly service: ContainerTypesService) {}
@Get()
@RuleEngineView('container-types')
@ApiOperation({ summary: 'List container types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -25,24 +26,28 @@ export class ContainerTypesController {
}
@Get(':id')
@RuleEngineView('container-types')
@ApiOperation({ summary: 'Get a container type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('container-types')
@ApiOperation({ summary: 'Create a container type' })
create(@Body() dto: CreateContainerTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('container-types')
@ApiOperation({ summary: 'Update a container type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateContainerTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('container-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a container type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreatePriorityRuleDto } from '../dto/create-priority-rule.dto';
import { UpdatePriorityRuleDto } from '../dto/update-priority-rule.dto';
@@ -9,12 +10,12 @@ import { PriorityRulesService } from '../services/priority-rules.service';
@ApiTags('priority-rules')
@Controller('priority-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class PriorityRulesController {
constructor(private readonly service: PriorityRulesService) {}
@Get()
@RuleEngineView('priority-rules')
@ApiOperation({ summary: 'List priority rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -25,24 +26,28 @@ export class PriorityRulesController {
}
@Get(':id')
@RuleEngineView('priority-rules')
@ApiOperation({ summary: 'Get a priority rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('priority-rules')
@ApiOperation({ summary: 'Create a priority rule' })
create(@Body() dto: CreatePriorityRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('priority-rules')
@ApiOperation({ summary: 'Update a priority rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdatePriorityRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('priority-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a priority rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -3,18 +3,24 @@ import {
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
import { CurrentUser } from '@edr/api-common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateRateDto } from '../dto/create-rate.dto';
import {
type AuthUserPayload,
resolveAuthUserId,
} from '../../../common/resolve-auth-user-id';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { RatesService } from '../services/rates.service';
@ApiTags('rates')
@Controller('rates')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class RatesController {
constructor(private readonly service: RatesService) {}
@Get()
@RuleEngineView('rates')
@ApiOperation({ summary: 'List rates' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -26,42 +32,55 @@ export class RatesController {
}
@Get('live')
@RuleEngineView('rates')
@ApiOperation({ summary: 'List all LIVE rates effective now' })
findLive() {
return this.service.findLiveRates();
}
@Get(':id')
@RuleEngineView('rates')
@ApiOperation({ summary: 'Get a rate by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
create(@Body() dto: CreateRateDto) {
return this.service.create(dto);
create(
@Body() dto: CreateRateDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.service.create(dto, resolveAuthUserId(user));
}
@Patch(':id')
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Update a DRAFT rate' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
return this.service.update(id, dto);
}
@Post(':id/submit')
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Submit rate for CEO approval' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.service.submitForApproval(id);
}
@Post(':id/approve')
@RuleEngineManage('rates')
@ApiOperation({ summary: 'CEO approves a rate' })
approve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ApproveRateDto) {
return this.service.approve(id, dto);
approve(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.service.approve(id, resolveAuthUserId(user));
}
@Delete(':id')
@RuleEngineManage('rates')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a rate' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateServiceTypeDto } from '../dto/create-service-type.dto';
import { UpdateServiceTypeDto } from '../dto/update-service-type.dto';
@@ -9,12 +10,12 @@ import { ServiceTypesService } from '../services/service-types.service';
@ApiTags('service-types')
@Controller('service-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ServiceTypesController {
constructor(private readonly service: ServiceTypesService) {}
@Get()
@RuleEngineView('service-types')
@ApiOperation({ summary: 'List service types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -29,24 +30,28 @@ export class ServiceTypesController {
}
@Get(':id')
@RuleEngineView('service-types')
@ApiOperation({ summary: 'Get a service type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('service-types')
@ApiOperation({ summary: 'Create a service type' })
create(@Body() dto: CreateServiceTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('service-types')
@ApiOperation({ summary: 'Update a service type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateServiceTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('service-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a service type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateShippingLineDto } from '../dto/create-shipping-line.dto';
import { UpdateShippingLineDto } from '../dto/update-shipping-line.dto';
@@ -9,12 +10,12 @@ import { ShippingLinesService } from '../services/shipping-lines.service';
@ApiTags('shipping-lines')
@Controller('shipping-lines')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class ShippingLinesController {
constructor(private readonly service: ShippingLinesService) {}
@Get()
@RuleEngineView('shipping-lines')
@ApiOperation({ summary: 'List shipping lines' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -25,24 +26,28 @@ export class ShippingLinesController {
}
@Get(':id')
@RuleEngineView('shipping-lines')
@ApiOperation({ summary: 'Get a shipping line by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('shipping-lines')
@ApiOperation({ summary: 'Create a shipping line' })
create(@Body() dto: CreateShippingLineDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('shipping-lines')
@ApiOperation({ summary: 'Update a shipping line' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateShippingLineDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('shipping-lines')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a shipping line' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateSurchargeTypeDto } from '../dto/create-surcharge-type.dto';
import { UpdateSurchargeTypeDto } from '../dto/update-surcharge-type.dto';
@@ -9,12 +10,12 @@ import { SurchargeTypesService } from '../services/surcharge-types.service';
@ApiTags('surcharge-types')
@Controller('surcharge-types')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class SurchargeTypesController {
constructor(private readonly service: SurchargeTypesService) {}
@Get()
@RuleEngineView('surcharge-types')
@ApiOperation({ summary: 'List surcharge types' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -25,24 +26,28 @@ export class SurchargeTypesController {
}
@Get(':id')
@RuleEngineView('surcharge-types')
@ApiOperation({ summary: 'Get a surcharge type by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('surcharge-types')
@ApiOperation({ summary: 'Create a surcharge type' })
create(@Body() dto: CreateSurchargeTypeDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('surcharge-types')
@ApiOperation({ summary: 'Update a surcharge type' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateSurchargeTypeDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('surcharge-types')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a surcharge type' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateWeightLimitRuleDto } from '../dto/create-weight-limit-rule.dto';
import { UpdateWeightLimitRuleDto } from '../dto/update-weight-limit-rule.dto';
@@ -9,12 +10,12 @@ import { WeightLimitRulesService } from '../services/weight-limit-rules.service'
@ApiTags('weight-limit-rules')
@Controller('weight-limit-rules')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class WeightLimitRulesController {
constructor(private readonly service: WeightLimitRulesService) {}
@Get()
@RuleEngineView('weight-limit-rules')
@ApiOperation({ summary: 'List weight limit rules' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -26,24 +27,28 @@ export class WeightLimitRulesController {
}
@Get(':id')
@RuleEngineView('weight-limit-rules')
@ApiOperation({ summary: 'Get a weight limit rule by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('weight-limit-rules')
@ApiOperation({ summary: 'Create a weight limit rule' })
create(@Body() dto: CreateWeightLimitRuleDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('weight-limit-rules')
@ApiOperation({ summary: 'Update a weight limit rule' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateWeightLimitRuleDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('weight-limit-rules')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a weight limit rule' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -2,6 +2,7 @@ import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateYardDto } from '../dto/create-yard.dto';
import { UpdateYardDto } from '../dto/update-yard.dto';
@@ -9,12 +10,12 @@ import { YardsService } from '../services/yards.service';
@ApiTags('yards')
@Controller('yards')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class YardsController {
constructor(private readonly service: YardsService) {}
@Get()
@RuleEngineView('yards')
@ApiOperation({ summary: 'List yards' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -26,24 +27,28 @@ export class YardsController {
}
@Get(':id')
@RuleEngineView('yards')
@ApiOperation({ summary: 'Get a yard by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@RuleEngineManage('yards')
@ApiOperation({ summary: 'Create a yard' })
create(@Body() dto: CreateYardDto) {
return this.service.create(dto);
}
@Patch(':id')
@RuleEngineManage('yards')
@ApiOperation({ summary: 'Update a yard' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateYardDto) {
return this.service.update(id, dto);
}
@Delete(':id')
@RuleEngineManage('yards')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a yard' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

@@ -35,10 +35,6 @@ export class CreateRateDto {
@IsIn([...RATE_UNITS])
rateUnit!: string;
@ApiProperty({ description: 'ID of the staff member (Director) proposing this rate' })
@IsUUID()
proposedByStaffId!: string;
@ApiProperty({ description: 'Date from which this rate is effective (ISO date)', example: '2025-01-01' })
@IsDateString()
effectiveFrom!: string;
@@ -49,12 +45,6 @@ export class CreateRateDto {
effectiveTo?: string;
}
export class ApproveRateDto {
@ApiProperty({ description: 'ID of the CEO approving this rate' })
@IsUUID()
approvedByCeoId!: string;
}
export class SubmitRateForApprovalDto {
@ApiPropertyOptional({ description: 'Optional note for the approval request', maxLength: 500 })
@IsOptional()

View File

@@ -35,6 +35,7 @@ import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
export interface BookingContainerEvalInput {
containerTypeId: string;
@@ -47,7 +48,8 @@ export interface BookingContainerEvalInput {
}
export interface BookingEvaluationInput {
cargoTypeId: string;
cargoTypeId?: string | null;
freightType?: 'CONTAINER' | 'BULK';
serviceTypeId: string;
paymentCurrency: string;
tradeDirection: string;
@@ -115,13 +117,19 @@ export class RuleEngineService {
let priorityScore = 0;
let requiresDirectorApproval = false;
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) {
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
} else if (cargoType.requiresDirectorApproval) {
if (input.freightType === 'BULK') {
requiresDirectorApproval = true;
}
if (input.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(input.cargoTypeId);
if (!cargoType) {
hardBlocked.push(`Cargo type ${input.cargoTypeId} not found`);
} else if (cargoType.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
}
for (const container of input.containers) {
const rules = await this.weightLimitRulesRepo.findActiveByContainerTypeId(
container.containerTypeId,
@@ -232,18 +240,62 @@ export class RuleEngineService {
}
/**
* Instantiate booking_approval_step rows from approval_rules for a cargo type.
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/
async instantiateApprovalSteps(bookingId: string, cargoTypeId: string): Promise<BookingApprovalStep[]> {
const cargoType = await this.cargoTypesRepo.findById(cargoTypeId);
if (!cargoType) {
throw new BadRequestException(`Cargo type ${cargoTypeId} not found`);
async ensureDefaultApprovalRules(): Promise<void> {
for (const flag of [false, true] as const) {
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
if (existing.length > 0) continue;
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
(r) => r.requiresDirectorApproval === flag,
);
for (const row of rows) {
await this.approvalRulesRepo.create({
requiresDirectorApproval: row.requiresDirectorApproval,
stepOrder: row.stepOrder,
requiredRole: row.requiredRole,
actionLabel: row.actionLabel,
blocksRole: row.blocksRole,
});
}
}
}
/**
* Instantiate booking_approval_step rows from approval_rules by freight type.
*/
async instantiateApprovalSteps(
bookingId: string,
options: {
freightType: 'CONTAINER' | 'BULK';
cargoTypeId?: string | null;
},
): Promise<BookingApprovalStep[]> {
await this.ensureDefaultApprovalRules();
let requiresDirectorApproval = options.freightType === 'BULK';
if (options.cargoTypeId) {
const cargoType = await this.cargoTypesRepo.findById(options.cargoTypeId);
if (!cargoType) {
throw new BadRequestException(`Cargo type ${options.cargoTypeId} not found`);
}
if (cargoType.requiresDirectorApproval) {
requiresDirectorApproval = true;
}
}
const chain = await this.approvalRulesRepo.findChainForCargo(
cargoType.requiresDirectorApproval,
requiresDirectorApproval,
);
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
);
}
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
const steps: BookingApprovalStep[] = [];
@@ -253,6 +305,7 @@ export class RuleEngineService {
approvalRuleId: rule.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING',
});
steps.push(await stepRepo.save(step));

View File

@@ -1,5 +1,5 @@
import { BadRequestException, Inject, Injectable, NotFoundException } from '@nestjs/common';
import { ApproveRateDto, CreateRateDto } from '../dto/create-rate.dto';
import { CreateRateDto } from '../dto/create-rate.dto';
import { UpdateRateDto } from '../dto/update-rate.dto';
import { Rate } from '../entities/rate.entity';
import { IRatesRepository, RATES_REPOSITORY } from '../interfaces/rates.repository.interface';
@@ -46,7 +46,7 @@ export class RatesService {
}
/** Create a rate in DRAFT status. */
async create(dto: CreateRateDto): Promise<Rate> {
async create(dto: CreateRateDto, proposedByStaffId: string): Promise<Rate> {
return this.repository.create({
rateType: dto.rateType as Rate['rateType'],
containerTypeId: dto.containerTypeId,
@@ -55,7 +55,7 @@ export class RatesService {
rateValue: dto.rateValue,
rateUnit: dto.rateUnit as Rate['rateUnit'],
status: 'DRAFT',
proposedByStaffId: dto.proposedByStaffId,
proposedByStaffId,
effectiveFrom: new Date(dto.effectiveFrom),
effectiveTo: dto.effectiveTo ? new Date(dto.effectiveTo) : undefined,
});
@@ -74,7 +74,6 @@ export class RatesService {
if (dto.currency) updates.currency = dto.currency;
if (dto.rateValue !== undefined) updates.rateValue = dto.rateValue;
if (dto.rateUnit) updates.rateUnit = dto.rateUnit as Rate['rateUnit'];
if (dto.proposedByStaffId) updates.proposedByStaffId = dto.proposedByStaffId;
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);
@@ -93,14 +92,14 @@ export class RatesService {
}
/** CEO approves a rate — moves to LIVE. */
async approve(id: string, dto: ApproveRateDto): Promise<Rate> {
async approve(id: string, approverUserId: string): Promise<Rate> {
const rate = await this.findById(id);
if (rate.status !== 'PENDING_APPROVAL') {
throw new BadRequestException('Only PENDING_APPROVAL rates can be approved');
}
const updated = await this.repository.update(id, {
status: 'LIVE',
approvedByCeoId: dto.approvedByCeoId,
approvedByCeoId: approverUserId,
approvedAt: new Date(),
});
return updated!;

View File

@@ -28,6 +28,7 @@ export class SurchargeTypesService {
const [data, total] = await this.repository.findAndCount({
where,
relations: { rate: true },
order: { label: 'ASC' },
skip: (page - 1) * pageSize,
take: pageSize,

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Entity, Index, JoinColumn, ManyToOne, Column } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSchedule } from './train-schedule.entity';
@Entity({ schema: 'freight', name: 'train_schedule_bookings' })
@Index(['trainScheduleId', 'bookingId'], { unique: true })
@Index(['bookingId'], { unique: true })
export class TrainScheduleBooking extends BaseEntity {
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@ManyToOne(() => TrainSchedule, (trainSchedule) => trainSchedule.scheduleBookings, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
}

View File

@@ -0,0 +1,54 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { TrainSet } from '../../train-sets/entities/train-set.entity';
import { TrainScheduleBooking } from './train-schedule-booking.entity';
export const TRAIN_SCHEDULE_STATUSES = [
'DRAFT',
'SCHEDULED',
'DISPATCHED',
'ARRIVED',
'CANCELLED',
] as const;
export type TrainScheduleStatus = (typeof TRAIN_SCHEDULE_STATUSES)[number];
@Entity({ schema: 'freight', name: 'train_schedules' })
@Index(['scheduledDepartureDate'])
@Index(['status'])
export class TrainSchedule extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid', unique: true })
trainSetId!: string;
@OneToOne(() => TrainSet, (trainSet) => trainSet.trainSchedule)
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'origin_station_id', type: 'uuid' })
originStationId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_station_id' })
originStation?: Yard;
@Column({ name: 'destination_station_id', type: 'uuid' })
destinationStationId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'destination_station_id' })
destinationStation?: Yard;
@Column({ name: 'scheduled_departure_date', type: 'timestamptz' })
scheduledDepartureDate!: Date;
@Column({ name: 'scheduled_arrival_date', type: 'timestamptz', nullable: true })
scheduledArrivalDate?: Date | null;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: TrainScheduleStatus;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSetWagon } from '../../train-sets/entities/train-set-wagon.entity';
@Entity({ schema: 'freight', name: 'wagon_booking_allocations' })
@Index(['trainSetWagonId', 'bookingId'])
export class WagonBookingAllocation extends BaseEntity {
@Column({ name: 'train_set_wagon_id', type: 'uuid' })
trainSetWagonId!: string;
@ManyToOne(() => TrainSetWagon, (wagon) => wagon.allocations, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_wagon_id' })
trainSetWagon?: TrainSetWagon;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking)
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'allocated_weight_tons', type: 'numeric', precision: 10, scale: 3 })
allocatedWeightTons!: number;
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
@Injectable()
export class TrainScheduleBookingsRepository extends BaseRepository<TrainScheduleBooking> {
constructor(
@InjectRepository(TrainScheduleBooking)
repository: Repository<TrainScheduleBooking>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainScheduleBooking } from './entities/train-schedule-booking.entity';
import { TrainSchedule } from './entities/train-schedule.entity';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
import { TrainScheduleBookingsRepository } from './train-schedule-bookings.repository';
import { TrainSchedulesRepository } from './train-schedules.repository';
import { WagonBookingAllocationsRepository } from './wagon-booking-allocations.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSchedule, TrainScheduleBooking, WagonBookingAllocation])],
providers: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository,
],
exports: [
TrainSchedulesRepository,
TrainScheduleBookingsRepository,
WagonBookingAllocationsRepository,
],
})
export class TrainSchedulesModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSchedule } from './entities/train-schedule.entity';
@Injectable()
export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
constructor(
@InjectRepository(TrainSchedule)
repository: Repository<TrainSchedule>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WagonBookingAllocation } from './entities/wagon-booking-allocation.entity';
@Injectable()
export class WagonBookingAllocationsRepository extends BaseRepository<WagonBookingAllocation> {
constructor(
@InjectRepository(WagonBookingAllocation)
repository: Repository<WagonBookingAllocation>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUUID } from 'class-validator';
import { PreviewContainerTrainScheduleDto } from './preview-container-train-schedule.dto';
export class CreateContainerTrainScheduleDto extends PreviewContainerTrainScheduleDto {
@ApiProperty({ format: 'uuid' })
@IsUUID()
locomotiveId!: string;
}

View File

@@ -0,0 +1,23 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsOptional, IsUUID } from 'class-validator';
export class GetEligibleContainerBookingsDto {
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
originStationId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@IsOptional()
@IsUUID()
destinationStationId?: string;
@ApiPropertyOptional({ example: '2026-06-20T08:00:00.000Z' })
@IsOptional()
@IsDateString()
scheduleDate?: string;
@ApiPropertyOptional()
@IsOptional()
status?: string;
}

View File

@@ -0,0 +1,22 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize, IsArray, IsDateString, IsUUID } from 'class-validator';
export class PreviewContainerTrainScheduleDto {
@ApiProperty({ type: [String] })
@IsArray()
@ArrayMinSize(1)
@IsUUID('4', { each: true })
bookingIds!: string[];
@ApiProperty({ example: '2026-06-20T08:00:00.000Z' })
@IsDateString()
scheduleDate!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
originStationId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
destinationStationId!: string;
}

View File

@@ -0,0 +1,58 @@
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateContainerTrainScheduleDto } from './dto/create-container-train-schedule.dto';
import { GetEligibleContainerBookingsDto } from './dto/get-eligible-container-bookings.dto';
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
import { TrainSchedulingService } from './train-scheduling.service';
@ApiTags('train-scheduling')
@ApiBearerAuth()
@Controller('train-scheduling')
export class TrainSchedulingController {
constructor(private readonly trainSchedulingService: TrainSchedulingService) {}
@Get('container/eligible-bookings')
@ApiOperation({ summary: 'List eligible container bookings' })
getEligibleContainerBookings(@Query() query: GetEligibleContainerBookingsDto) {
return this.trainSchedulingService.getEligibleContainerBookings(query);
}
@Post('container/preview')
@ApiOperation({ summary: 'Preview a container train schedule' })
previewContainerTrainSchedule(@Body() dto: PreviewContainerTrainScheduleDto) {
return this.trainSchedulingService.previewContainerTrainSchedule(dto);
}
@Post('container/schedules')
@ApiOperation({ summary: 'Create a container train schedule' })
createContainerTrainSchedule(@Body() dto: CreateContainerTrainScheduleDto) {
return this.trainSchedulingService.createContainerTrainSchedule(dto);
}
@Get('container/schedules')
@ApiOperation({ summary: 'List container train schedules' })
getContainerTrainSchedules() {
return this.trainSchedulingService.getContainerTrainSchedules();
}
@Get('container/schedules/:id')
@ApiOperation({ summary: 'Get container train schedule detail' })
getContainerTrainScheduleById(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post('container/schedules/:id/cancel')
@ApiOperation({ summary: 'Cancel container train schedule' })
cancelTrainSchedule(@Param('id', ParseUUIDPipe) id: string) {
return this.trainSchedulingService.cancelTrainSchedule(id);
}
}

View File

@@ -0,0 +1,46 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BookingsModule } from '../bookings/bookings.module';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { LocomotivesModule } from '../locomotives/locomotives.module';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonTypesModule } from '../wagon-types/wagon-types.module';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
import { TrainSetsModule } from '../train-sets/train-sets.module';
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
import { TrainSchedulesModule } from '../train-schedules/train-schedules.module';
import { Yard } from '../rule-engine/entities/yard.entity';
import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
@Module({
imports: [
TypeOrmModule.forFeature([
Booking,
BookingContainer,
Locomotive,
WagonType,
TrainSet,
TrainSetWagon,
TrainSchedule,
TrainScheduleBooking,
WagonBookingAllocation,
Yard,
]),
BookingsModule,
LocomotivesModule,
WagonTypesModule,
TrainSetsModule,
TrainSchedulesModule,
],
controllers: [TrainSchedulingController],
providers: [TrainSchedulingService],
exports: [TrainSchedulingService],
})
export class TrainSchedulingModule {}

View File

@@ -0,0 +1,328 @@
import { ConflictException } from '@nestjs/common';
import { TrainSchedulingService } from './train-scheduling.service';
const nw5 = {
id: 'wagon-type-1',
code: 'NW5',
name: 'Flat Wagon',
capacityTons: 70,
lengthMeters: 14,
maxWagonsPerTrain: 53,
supportedLoadTypes: ['CONTAINER'],
isActive: true,
};
const locomotive = {
id: 'loc-1',
code: 'LOC-001',
maxPullWeightTons: 3500,
status: 'AVAILABLE',
};
const makeBooking = (
id: string,
reference: string,
weight: number,
quantity: number,
containerCode: string,
scheduledDate = '2026-06-20T08:00:00.000Z',
originYardId = 'yard-origin',
destinationYardId = 'yard-destination',
) => ({
id,
reference,
freightType: 'CONTAINER',
cargoTotalWeightVgm: weight,
scheduledDate: new Date(scheduledDate),
originYardId,
destinationYardId,
status: 'APPROVED',
customer: { companyName: 'Demo Customer' },
originYard: { label: 'Djibouti', code: 'DJIBOUTI' },
destinationYard: { label: 'Addis Ababa', code: 'ADDIS_ABABA' },
bookingContainers: [
{
quantity,
containerType: { code: containerCode, label: containerCode },
},
],
});
describe('TrainSchedulingService', () => {
let service: TrainSchedulingService;
let dataSource: {
getRepository: jest.Mock;
transaction: jest.Mock;
};
let locomotivesRepository: {
findById: jest.Mock;
};
let wagonTypesRepository: {
findAll: jest.Mock;
};
beforeEach(() => {
dataSource = {
getRepository: jest.fn(),
transaction: jest.fn(),
};
locomotivesRepository = {
findById: jest.fn(),
};
wagonTypesRepository = {
findAll: jest.fn(),
};
service = new TrainSchedulingService(
dataSource as never,
locomotivesRepository as never,
wagonTypesRepository as never,
);
});
it('computes the expected valid preview for Group A', async () => {
const bookings = [
makeBooking('b1', 'BKG-CONT-001', 500, 20, '40FT'),
makeBooking('b2', 'BKG-CONT-002', 300, 10, '20FT'),
makeBooking('b3', 'BKG-CONT-003', 450, 15, '40FT'),
];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(2),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
const result = await service.previewContainerTrainSchedule({
bookingIds: bookings.map((booking) => booking.id),
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(true);
expect(result.violations).toEqual([]);
expect(result.summary).toEqual({
totalBookings: 3,
totalWeightTons: 1250,
wagonType: 'NW5',
wagonsNeeded: 18,
totalLengthMeters: 252,
});
expect(result.wagonPlan).toHaveLength(18);
expect(result.wagonPlan[0]?.allocations[0]).toEqual({
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
});
});
it('flags the overweight booking as invalid', async () => {
const bookings = [makeBooking('b6', 'BKG-CONT-006', 3600, 80, '40FT')];
wagonTypesRepository.findAll.mockResolvedValue([nw5]);
dataSource.getRepository.mockImplementation((entity: { name?: string }) => {
if (entity?.name === 'Booking') {
return { find: jest.fn().mockResolvedValue(bookings) };
}
if (entity?.name === 'TrainScheduleBooking') {
return { find: jest.fn().mockResolvedValue([]) };
}
if (entity?.name === 'Locomotive') {
return {
count: jest.fn().mockResolvedValue(1),
find: jest.fn().mockResolvedValue([locomotive]),
};
}
throw new Error(`Unexpected repository ${entity?.name}`);
});
const result = await service.previewContainerTrainSchedule({
bookingIds: ['b6'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
});
expect(result.valid).toBe(false);
expect(result.summary.totalWeightTons).toBe(3600);
expect(result.violations).toContain(
'Total booking weight 3600T exceeds max train weight 3500T',
);
});
it('creates a schedule transactionally when validation passes', async () => {
const bookings = [makeBooking('b1', 'BKG-CONT-001', 140, 2, '40FT')];
const validation = {
valid: true,
violations: [],
bookings,
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 140,
wagonType: 'NW5',
wagonsNeeded: 2,
totalLengthMeters: 28,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
{
sequenceNo: 2,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [
{
bookingId: 'b1',
bookingReference: 'BKG-CONT-001',
allocatedWeightTons: 70,
},
],
},
],
};
const lockedLocomotiveRepo = {
findOne: jest.fn().mockResolvedValue(locomotive),
update: jest.fn().mockResolvedValue(undefined),
};
const trainScheduleRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'schedule-1' }),
};
const trainScheduleBookingRepo = {
count: jest.fn().mockResolvedValue(0),
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetWagonRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
find: jest.fn().mockResolvedValue([
{ id: 'wagon-1', sequenceNo: 1 },
{ id: 'wagon-2', sequenceNo: 2 },
]),
};
const wagonAllocRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue(undefined),
};
const trainSetRepo = {
create: jest.fn().mockImplementation((value) => value),
save: jest.fn().mockResolvedValue({ id: 'train-set-1' }),
};
const manager = {
getRepository: jest.fn((entity: { name?: string }) => {
switch (entity?.name) {
case 'Locomotive':
return lockedLocomotiveRepo;
case 'TrainSchedule':
return trainScheduleRepo;
case 'TrainScheduleBooking':
return trainScheduleBookingRepo;
case 'TrainSetWagon':
return trainSetWagonRepo;
case 'WagonBookingAllocation':
return wagonAllocRepo;
case 'TrainSet':
return trainSetRepo;
default:
throw new Error(`Unexpected transaction repository ${entity?.name}`);
}
}),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
jest.spyOn(service, 'getContainerTrainScheduleById').mockResolvedValue({ id: 'schedule-1' } as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
const result = await service.createContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
});
expect(trainSetRepo.save).toHaveBeenCalled();
expect(trainScheduleRepo.save).toHaveBeenCalled();
expect(trainSetWagonRepo.save).toHaveBeenCalled();
expect(wagonAllocRepo.save).toHaveBeenCalled();
expect(lockedLocomotiveRepo.update).toHaveBeenCalledWith('loc-1', { status: 'ASSIGNED' });
expect(result).toEqual({ id: 'schedule-1' });
});
it('rejects create when the locked locomotive is no longer available', async () => {
const validation = {
valid: true,
violations: [],
bookings: [makeBooking('b1', 'BKG-CONT-001', 70, 1, '40FT')],
wagonType: nw5,
summary: {
totalBookings: 1,
totalWeightTons: 70,
wagonType: 'NW5',
wagonsNeeded: 1,
totalLengthMeters: 14,
},
wagonPlan: [
{
sequenceNo: 1,
capacityTons: 70,
lengthMeters: 14,
assignedWeightTons: 70,
allocations: [],
},
],
};
const manager = {
getRepository: jest.fn(() => ({
findOne: jest.fn().mockResolvedValue({ ...locomotive, status: 'ASSIGNED' }),
})),
};
jest.spyOn(service, 'validateContainerBookingsForScheduling').mockResolvedValue(validation as never);
jest.spyOn(service, 'selectOrValidateLocomotive').mockResolvedValue(locomotive as never);
dataSource.transaction.mockImplementation(async (callback: (tx: typeof manager) => Promise<string>) =>
callback(manager),
);
await expect(
service.createContainerTrainSchedule({
bookingIds: ['b1'],
scheduleDate: '2026-06-20T08:00:00.000Z',
originStationId: 'yard-origin',
destinationStationId: 'yard-destination',
locomotiveId: 'loc-1',
}),
).rejects.toBeInstanceOf(ConflictException);
});
});

View File

@@ -0,0 +1,814 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource, EntityManager, In } from "typeorm";
import { Booking } from "../bookings/entities/booking.entity";
import {
Locomotive,
type LocomotiveStatus,
} from "../locomotives/entities/locomotive.entity";
import { LocomotivesRepository } from "../locomotives/locomotives.repository";
import { TrainSetWagon } from "../train-sets/entities/train-set-wagon.entity";
import { TrainSet } from "../train-sets/entities/train-set.entity";
import { TrainScheduleBooking } from "../train-schedules/entities/train-schedule-booking.entity";
import { TrainSchedule } from "../train-schedules/entities/train-schedule.entity";
import { WagonBookingAllocation } from "../train-schedules/entities/wagon-booking-allocation.entity";
import { WagonType } from "../wagon-types/entities/wagon-type.entity";
import { WagonTypesRepository } from "../wagon-types/wagon-types.repository";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
import { GetEligibleContainerBookingsDto } from "./dto/get-eligible-container-bookings.dto";
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
const DEFAULT_WAGON_TYPE_CODE = "NW5";
const MAX_TRAIN_WEIGHT_TONS = 3500;
const MAX_TRAIN_LENGTH_METERS = 760;
type EligibleBookingItem = {
id: string;
reference: string;
customer: string;
containerType: string;
quantity: number;
weightTons: number;
origin: string;
destination: string;
preferredDepartureDate: string;
status: string;
};
type WagonAllocationRecord = {
bookingId: string;
bookingReference: string;
allocatedWeightTons: number;
};
type WagonPlanRecord = {
sequenceNo: number;
capacityTons: number;
lengthMeters: number;
assignedWeightTons: number;
allocations: WagonAllocationRecord[];
};
type ValidationResult = {
valid: boolean;
violations: string[];
bookings: Booking[];
wagonType: WagonType;
summary: {
totalBookings: number;
totalWeightTons: number;
wagonType: string;
wagonsNeeded: number;
totalLengthMeters: number;
};
wagonPlan: WagonPlanRecord[];
};
@Injectable()
export class TrainSchedulingService {
constructor(
@InjectDataSource()
private readonly dataSource: DataSource,
private readonly locomotivesRepository: LocomotivesRepository,
private readonly wagonTypesRepository: WagonTypesRepository,
) { }
async getEligibleContainerBookings(query: GetEligibleContainerBookingsDto) {
const bookingRepository = this.dataSource.getRepository(Booking);
const queryBuilder = bookingRepository
.createQueryBuilder("booking")
.leftJoinAndSelect("booking.customer", "customer")
.leftJoinAndSelect("booking.originYard", "originYard")
.leftJoinAndSelect("booking.destinationYard", "destinationYard")
.leftJoinAndSelect("booking.bookingContainers", "bookingContainer")
.leftJoinAndSelect("bookingContainer.containerType", "containerType")
.leftJoin(
TrainScheduleBooking,
"scheduleBooking",
"scheduleBooking.booking_id = booking.id",
)
.where("booking.freightType = :freightType", { freightType: "CONTAINER" })
.andWhere("scheduleBooking.id IS NULL");
if (query.originStationId) {
queryBuilder.andWhere("booking.originYardId = :originStationId", {
originStationId: query.originStationId,
});
}
if (query.destinationStationId) {
queryBuilder.andWhere(
"booking.destinationYardId = :destinationStationId",
{
destinationStationId: query.destinationStationId,
},
);
}
if (query.scheduleDate) {
queryBuilder.andWhere(
`DATE(booking.scheduled_date AT TIME ZONE 'UTC') = :scheduleDate`,
{ scheduleDate: this.toUtcDateKey(query.scheduleDate) },
);
}
if (query.status) {
queryBuilder.andWhere("booking.status = :status", {
status: query.status,
});
}
const bookings = await queryBuilder
.orderBy("booking.scheduled_date", "ASC")
.addOrderBy("booking.created_at", "ASC")
.getMany();
const items: EligibleBookingItem[] = bookings.map((booking) => ({
id: booking.id,
reference: booking.reference,
customer:
booking.company?.name ?? booking.company?.email ?? "Unknown customer",
containerType:
booking.bookingContainers
?.map(
(container) =>
container.containerType?.label ??
container.containerType?.code ??
"Container",
)
.join(", ") ?? "Container",
quantity:
booking.bookingContainers?.reduce(
(sum, container) => sum + Number(container.quantity ?? 0),
0,
) ?? 0,
weightTons: this.roundTons(booking.cargoTotalWeightVgm),
origin:
booking.originYard?.label ??
booking.originYard?.code ??
"Unknown origin",
destination:
booking.destinationYard?.label ??
booking.destinationYard?.code ??
"Unknown destination",
preferredDepartureDate: booking.scheduledDate.toISOString(),
status: booking.status,
}));
return {
count: items.length,
items,
};
}
async previewContainerTrainSchedule(dto: PreviewContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
return {
valid: validation.valid,
violations: validation.violations,
summary: validation.summary,
bookingIds: validation.bookings.map((booking) => booking.id),
wagonPlan: validation.wagonPlan,
};
}
async createContainerTrainSchedule(dto: CreateContainerTrainScheduleDto) {
const validation = await this.validateContainerBookingsForScheduling(dto);
if (!validation.valid) {
throw new BadRequestException({
message: "train_schedule_invalid",
violations: validation.violations,
});
}
const locomotive = await this.selectOrValidateLocomotive(
dto.locomotiveId,
validation.summary.totalWeightTons,
);
const createdSchedule = await this.dataSource.transaction(
async (manager) => {
const locomotiveRepository = manager.getRepository(Locomotive);
const lockedLocomotive = await locomotiveRepository.findOne({
where: { id: locomotive.id },
lock: { mode: "pessimistic_write" },
});
if (!lockedLocomotive) {
throw new NotFoundException(`Locomotive ${locomotive.id} not found`);
}
if (lockedLocomotive.status !== "AVAILABLE") {
throw new ConflictException(
`Locomotive ${lockedLocomotive.code} is not available`,
);
}
if (
Number(lockedLocomotive.maxPullWeightTons) <
validation.summary.totalWeightTons
) {
throw new BadRequestException(
`Locomotive ${lockedLocomotive.code} cannot pull ${validation.summary.totalWeightTons}T`,
);
}
const existingScheduleCount = await manager
.getRepository(TrainScheduleBooking)
.count({
where: {
bookingId: In(validation.bookings.map((booking) => booking.id)),
},
});
if (existingScheduleCount > 0) {
throw new BadRequestException(
"One or more bookings are already scheduled",
);
}
const trainSet = await this.buildTrainSet(
manager,
lockedLocomotive,
validation.wagonType,
validation.summary.totalWeightTons,
validation.summary.totalLengthMeters,
validation.wagonPlan,
);
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
originStationId: dto.originStationId,
destinationStationId: dto.destinationStationId,
scheduledDepartureDate: new Date(dto.scheduleDate),
status: "SCHEDULED",
});
const savedSchedule = await manager
.getRepository(TrainSchedule)
.save(schedule);
const scheduleBookings = validation.bookings.map((booking) =>
manager.getRepository(TrainScheduleBooking).create({
trainScheduleId: savedSchedule.id,
bookingId: booking.id,
}),
);
await manager
.getRepository(TrainScheduleBooking)
.save(scheduleBookings);
const savedWagons = await manager.getRepository(TrainSetWagon).find({
where: { trainSetId: trainSet.id },
order: { sequenceNo: "ASC" },
});
const wagonBySequence = new Map(
savedWagons.map((wagon) => [wagon.sequenceNo, wagon]),
);
const allocationRows = validation.wagonPlan.flatMap((wagonPlan) => {
const wagon = wagonBySequence.get(wagonPlan.sequenceNo);
if (!wagon) {
throw new BadRequestException(
`Missing wagon sequence ${wagonPlan.sequenceNo}`,
);
}
return wagonPlan.allocations.map((allocation) =>
manager.getRepository(WagonBookingAllocation).create({
trainSetWagonId: wagon.id,
bookingId: allocation.bookingId,
allocatedWeightTons: allocation.allocatedWeightTons,
}),
);
});
await manager
.getRepository(WagonBookingAllocation)
.save(allocationRows);
await locomotiveRepository.update(lockedLocomotive.id, {
status: "ASSIGNED",
});
return savedSchedule.id;
},
);
return this.getContainerTrainScheduleById(createdSchedule);
}
async validateContainerBookingsForScheduling(
dto: PreviewContainerTrainScheduleDto,
): Promise<ValidationResult> {
const bookingIds = [...new Set(dto.bookingIds)];
if (!bookingIds.length) {
throw new BadRequestException("At least one booking is required");
}
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { code: DEFAULT_WAGON_TYPE_CODE, isActive: true },
});
if (!wagonType) {
throw new NotFoundException(
`Wagon type ${DEFAULT_WAGON_TYPE_CODE} not found`,
);
}
const bookings = await this.loadBookingsForScheduling(bookingIds);
const violations: string[] = [];
if (bookings.length !== bookingIds.length) {
const foundIds = new Set(bookings.map((booking) => booking.id));
const missing = bookingIds.filter((id) => !foundIds.has(id));
violations.push(`Bookings not found: ${missing.join(", ")}`);
}
const scheduledLinks = await this.dataSource
.getRepository(TrainScheduleBooking)
.find({
where: { bookingId: In(bookingIds) },
select: { bookingId: true },
});
if (scheduledLinks.length > 0) {
violations.push(
"One or more selected bookings are already assigned to a train schedule",
);
}
const nonContainerBookings = bookings.filter(
(booking) => booking.freightType !== "CONTAINER",
);
if (nonContainerBookings.length > 0) {
violations.push(
"Only CONTAINER bookings are supported for train scheduling",
);
}
const scheduleDateKey = this.toUtcDateKey(dto.scheduleDate);
const routeMismatch = bookings.some(
(booking) =>
booking.originYardId !== dto.originStationId ||
booking.destinationYardId !== dto.destinationStationId,
);
if (routeMismatch) {
violations.push(
"Selected bookings must share the same origin and destination as the schedule",
);
}
const dateMismatch = bookings.some(
(booking) => this.toUtcDateKey(booking.scheduledDate) !== scheduleDateKey,
);
if (dateMismatch) {
violations.push("Selected bookings must share the same schedule date");
}
const uniqueOriginCount = new Set(
bookings.map((booking) => booking.originYardId),
).size;
if (uniqueOriginCount > 1) {
violations.push("Selected bookings must share the same origin station");
}
const uniqueDestinationCount = new Set(
bookings.map((booking) => booking.destinationYardId),
).size;
if (uniqueDestinationCount > 1) {
violations.push(
"Selected bookings must share the same destination station",
);
}
const uniqueDateCount = new Set(
bookings.map((booking) => this.toUtcDateKey(booking.scheduledDate)),
).size;
if (uniqueDateCount > 1) {
violations.push(
"Selected bookings must share the same preferred departure date",
);
}
const totalWeightTons = this.roundTons(
bookings.reduce(
(sum, booking) => sum + Number(booking.cargoTotalWeightVgm ?? 0),
0,
),
);
const wagonPlan = this.allocateBookingsToWagons(
bookings,
this.calculateNW5WagonPlan(totalWeightTons, wagonType),
);
const totalLengthMeters = this.roundTons(
wagonPlan.reduce((sum, wagon) => sum + wagon.lengthMeters, 0),
);
if (totalWeightTons > MAX_TRAIN_WEIGHT_TONS) {
violations.push(
`Total booking weight ${totalWeightTons}T exceeds max train weight ${MAX_TRAIN_WEIGHT_TONS}T`,
);
}
if (totalLengthMeters > MAX_TRAIN_LENGTH_METERS) {
violations.push(
`Total wagon length ${totalLengthMeters}m exceeds max train length ${MAX_TRAIN_LENGTH_METERS}m`,
);
}
if (
wagonType.maxWagonsPerTrain != null &&
wagonPlan.length > Number(wagonType.maxWagonsPerTrain)
) {
violations.push(
`Wagon count ${wagonPlan.length} exceeds wagon marshalling limit ${wagonType.maxWagonsPerTrain}`,
);
}
const availableLocomotiveCount = await this.dataSource
.getRepository(Locomotive)
.count({
where: { status: "AVAILABLE" as LocomotiveStatus },
});
if (availableLocomotiveCount === 0) {
violations.push("No available locomotive exists for scheduling");
} else {
const capableLocomotives = await this.dataSource
.getRepository(Locomotive)
.find({
where: { status: "AVAILABLE" },
});
const canPull = capableLocomotives.some(
(locomotive) => Number(locomotive.maxPullWeightTons) >= totalWeightTons,
);
if (!canPull) {
violations.push("No available locomotive can pull the total weight");
}
}
return {
valid: violations.length === 0,
violations,
bookings,
wagonType,
summary: {
totalBookings: bookings.length,
totalWeightTons,
wagonType: wagonType.code,
wagonsNeeded: wagonPlan.length,
totalLengthMeters,
},
wagonPlan,
};
}
calculateNW5WagonPlan(
totalBookingWeightTons: number,
wagonType: WagonType,
): WagonPlanRecord[] {
const wagonCapacityTons = Number(wagonType.capacityTons);
const wagonsNeeded = Math.ceil(totalBookingWeightTons / wagonCapacityTons);
let remainingWeight = this.roundTons(totalBookingWeightTons);
return Array.from({ length: wagonsNeeded }, (_, index) => {
const assignedWeightTons = this.roundTons(
Math.min(wagonCapacityTons, remainingWeight),
);
remainingWeight = this.roundTons(
Math.max(0, remainingWeight - assignedWeightTons),
);
return {
sequenceNo: index + 1,
capacityTons: wagonCapacityTons,
lengthMeters: this.roundTons(Number(wagonType.lengthMeters)),
assignedWeightTons,
allocations: [],
};
});
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
) {
const locomotive = await this.locomotivesRepository.findById(locomotiveId);
if (!locomotive) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locomotive.status !== "AVAILABLE") {
throw new BadRequestException(
`Locomotive ${locomotive.code} is not available`,
);
}
if (Number(locomotive.maxPullWeightTons) < totalWeightTons) {
throw new BadRequestException(
`Locomotive ${locomotive.code} cannot pull ${totalWeightTons}T`,
);
}
return locomotive;
}
async buildTrainSet(
manager: EntityManager,
locomotive: Locomotive,
wagonType: WagonType,
totalWeightTons: number,
totalLengthMeters: number,
wagonPlan: WagonPlanRecord[],
) {
const trainSet = manager.getRepository(TrainSet).create({
locomotiveId: locomotive.id,
totalWeightTons,
totalLengthMeters,
wagonCount: wagonPlan.length,
status: "ASSIGNED",
});
const savedTrainSet = await manager.getRepository(TrainSet).save(trainSet);
const wagons = wagonPlan.map((wagon) =>
manager.getRepository(TrainSetWagon).create({
trainSetId: savedTrainSet.id,
wagonTypeId: wagonType.id,
sequenceNo: wagon.sequenceNo,
capacityTons: wagon.capacityTons,
lengthMeters: wagon.lengthMeters,
assignedWeightTons: wagon.assignedWeightTons,
}),
);
await manager.getRepository(TrainSetWagon).save(wagons);
return savedTrainSet;
}
allocateBookingsToWagons(
bookings: Booking[],
baseWagonPlan: WagonPlanRecord[],
): WagonPlanRecord[] {
const remaining = bookings.map((booking) => ({
bookingId: booking.id,
bookingReference: booking.reference,
remainingWeightTons: this.roundTons(
Number(booking.cargoTotalWeightVgm ?? 0),
),
}));
let bookingIndex = 0;
return baseWagonPlan.map((wagon) => {
let wagonRemaining = this.roundTons(wagon.capacityTons);
const allocations: WagonAllocationRecord[] = [];
let assignedWeightTons = 0;
while (wagonRemaining > 0 && bookingIndex < remaining.length) {
const booking = remaining[bookingIndex];
const allocatedWeightTons = this.roundTons(
Math.min(wagonRemaining, booking.remainingWeightTons),
);
if (allocatedWeightTons <= 0) {
bookingIndex += 1;
continue;
}
allocations.push({
bookingId: booking.bookingId,
bookingReference: booking.bookingReference,
allocatedWeightTons,
});
booking.remainingWeightTons = this.roundTons(
booking.remainingWeightTons - allocatedWeightTons,
);
wagonRemaining = this.roundTons(wagonRemaining - allocatedWeightTons);
assignedWeightTons = this.roundTons(
assignedWeightTons + allocatedWeightTons,
);
if (booking.remainingWeightTons <= 0) {
bookingIndex += 1;
}
}
return {
...wagon,
assignedWeightTons,
allocations,
};
});
}
async getContainerTrainSchedules() {
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
relations: {
trainSet: { locomotive: true },
originStation: true,
destinationStation: true,
scheduleBookings: true,
},
order: { scheduledDepartureDate: "DESC", createdAt: "DESC" },
});
return schedules.map((schedule) => ({
id: schedule.id,
scheduleDate: schedule.scheduledDepartureDate,
origin:
schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
schedule.destinationStation?.label ??
schedule.destinationStation?.code ??
null,
locomotive: schedule.trainSet?.locomotive
? {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name ?? null,
}
: null,
wagonCount: schedule.trainSet?.wagonCount ?? 0,
totalWeightTons: this.roundTons(
Number(schedule.trainSet?.totalWeightTons ?? 0),
),
totalLengthMeters: this.roundTons(
Number(schedule.trainSet?.totalLengthMeters ?? 0),
),
bookingsCount: schedule.scheduleBookings?.length ?? 0,
status: schedule.status,
}));
}
async getContainerTrainScheduleById(id: string) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({
where: { id },
relations: {
trainSet: {
locomotive: true,
wagons: { wagonType: true, allocations: { booking: true } },
},
originStation: true,
destinationStation: true,
scheduleBookings: {
booking: { company: true, originYard: true, destinationYard: true },
},
},
});
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
return {
id: schedule.id,
status: schedule.status,
scheduledDepartureDate: schedule.scheduledDepartureDate,
scheduledArrivalDate: schedule.scheduledArrivalDate,
originStation: schedule.originStation,
destinationStation: schedule.destinationStation,
trainSet: schedule.trainSet
? {
id: schedule.trainSet.id,
status: schedule.trainSet.status,
wagonCount: schedule.trainSet.wagonCount,
totalWeightTons: this.roundTons(
Number(schedule.trainSet.totalWeightTons),
),
totalLengthMeters: this.roundTons(
Number(schedule.trainSet.totalLengthMeters),
),
locomotive: schedule.trainSet.locomotive
? {
id: schedule.trainSet.locomotive.id,
code: schedule.trainSet.locomotive.code,
name: schedule.trainSet.locomotive.name,
status: schedule.trainSet.locomotive.status,
maxPullWeightTons: this.roundTons(
Number(schedule.trainSet.locomotive.maxPullWeightTons),
),
}
: null,
wagons: [...(schedule.trainSet.wagons ?? [])]
.sort((left, right) => left.sequenceNo - right.sequenceNo)
.map((wagon) => ({
id: wagon.id,
sequenceNo: wagon.sequenceNo,
capacityTons: this.roundTons(Number(wagon.capacityTons)),
lengthMeters: this.roundTons(Number(wagon.lengthMeters)),
assignedWeightTons: this.roundTons(
Number(wagon.assignedWeightTons),
),
wagonType: wagon.wagonType
? {
id: wagon.wagonType.id,
code: wagon.wagonType.code,
name: wagon.wagonType.name,
}
: null,
allocations:
wagon.allocations?.map((allocation) => ({
id: allocation.id,
bookingId: allocation.bookingId,
bookingReference: allocation.booking?.reference ?? null,
allocatedWeightTons: this.roundTons(
Number(allocation.allocatedWeightTons),
),
})) ?? [],
})),
}
: null,
bookings:
schedule.scheduleBookings?.map((scheduleBooking) => ({
id: scheduleBooking.booking?.id ?? scheduleBooking.bookingId,
reference: scheduleBooking.booking?.reference ?? null,
customer:
scheduleBooking.booking?.company?.name ??
scheduleBooking.booking?.company?.email ??
null,
weightTons: this.roundTons(
Number(scheduleBooking.booking?.cargoTotalWeightVgm ?? 0),
),
status: scheduleBooking.booking?.status ?? null,
})) ?? [],
};
}
async cancelTrainSchedule(id: string) {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({
where: { id },
relations: { trainSet: { locomotive: true } },
});
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(TrainSchedule).update(schedule.id, {
status: "CANCELLED",
});
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, {
status: "CANCELLED",
});
}
if (schedule.trainSet?.locomotiveId) {
await manager
.getRepository(Locomotive)
.update(schedule.trainSet.locomotiveId, {
status: "AVAILABLE",
});
}
});
return this.getContainerTrainScheduleById(id);
}
private async loadBookingsForScheduling(bookingIds: string[]) {
return this.dataSource.getRepository(Booking).find({
where: { id: In(bookingIds) },
relations: {
company: true,
originYard: true,
destinationYard: true,
bookingContainers: { containerType: true },
},
order: { createdAt: "ASC" },
});
}
private toUtcDateKey(value: Date | string) {
const date = value instanceof Date ? value : new Date(value);
return date.toISOString().slice(0, 10);
}
private roundTons(value: number | string | null | undefined) {
const numericValue = typeof value === "number" ? value : Number(value ?? 0);
if (!Number.isFinite(numericValue)) {
return 0;
}
return Number(numericValue.toFixed(3));
}
}

View File

@@ -0,0 +1,39 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WagonBookingAllocation } from '../../train-schedules/entities/wagon-booking-allocation.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
import { TrainSet } from './train-set.entity';
@Entity({ schema: 'freight', name: 'train_set_wagons' })
@Index(['trainSetId', 'sequenceNo'], { unique: true })
export class TrainSetWagon extends BaseEntity {
@Column({ name: 'train_set_id', type: 'uuid' })
trainSetId!: string;
@ManyToOne(() => TrainSet, (trainSet) => trainSet.wagons, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_set_id' })
trainSet?: TrainSet;
@Column({ name: 'wagon_type_id', type: 'uuid' })
wagonTypeId!: string;
@ManyToOne(() => WagonType, (wagonType) => wagonType.trainSetWagons)
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType;
@Column({ name: 'sequence_no', type: 'int' })
sequenceNo!: number;
@Column({ name: 'capacity_tons', type: 'numeric', precision: 10, scale: 3 })
capacityTons!: number;
@Column({ name: 'length_meters', type: 'numeric', precision: 10, scale: 3 })
lengthMeters!: number;
@Column({ name: 'assigned_weight_tons', type: 'numeric', precision: 10, scale: 3, default: 0 })
assignedWeightTons!: number;
@OneToMany(() => WagonBookingAllocation, (allocation) => allocation.trainSetWagon)
allocations?: WagonBookingAllocation[];
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany, OneToOne } from 'typeorm';
import { Locomotive } from '../../locomotives/entities/locomotive.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { TrainSetWagon } from './train-set-wagon.entity';
export const TRAIN_SET_STATUSES = [
'DRAFT',
'ASSIGNED',
'DISPATCHED',
'COMPLETED',
'CANCELLED',
] as const;
export type TrainSetStatus = (typeof TRAIN_SET_STATUSES)[number];
@Entity({ schema: 'freight', name: 'train_sets' })
@Index(['locomotiveId'])
@Index(['status'])
export class TrainSet extends BaseEntity {
@Column({ name: 'locomotive_id', type: 'uuid' })
locomotiveId!: string;
@ManyToOne(() => Locomotive, (locomotive) => locomotive.trainSets)
@JoinColumn({ name: 'locomotive_id' })
locomotive?: Locomotive;
@Column({ name: 'total_weight_tons', type: 'numeric', precision: 10, scale: 3 })
totalWeightTons!: number;
@Column({ name: 'total_length_meters', type: 'numeric', precision: 10, scale: 3 })
totalLengthMeters!: number;
@Column({ name: 'wagon_count', type: 'int' })
wagonCount!: number;
@Column({ name: 'status', type: 'varchar', length: 20, default: 'DRAFT' })
status!: TrainSetStatus;
@OneToMany(() => TrainSetWagon, (wagon) => wagon.trainSet)
wagons?: TrainSetWagon[];
@OneToOne(() => TrainSchedule, (schedule) => schedule.trainSet)
trainSchedule?: TrainSchedule;
}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
@Injectable()
export class TrainSetWagonsRepository extends BaseRepository<TrainSetWagon> {
constructor(
@InjectRepository(TrainSetWagon)
repository: Repository<TrainSetWagon>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSet } from './entities/train-set.entity';
import { TrainSetWagon } from './entities/train-set-wagon.entity';
import { TrainSetWagonsRepository } from './train-set-wagons.repository';
import { TrainSetsRepository } from './train-sets.repository';
@Module({
imports: [TypeOrmModule.forFeature([TrainSet, TrainSetWagon])],
providers: [TrainSetsRepository, TrainSetWagonsRepository],
exports: [TrainSetsRepository, TrainSetWagonsRepository],
})
export class TrainSetsModule {}

View File

@@ -0,0 +1,16 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TrainSet } from './entities/train-set.entity';
@Injectable()
export class TrainSetsRepository extends BaseRepository<TrainSet> {
constructor(
@InjectRepository(TrainSet)
repository: Repository<TrainSet>,
) {
super(repository);
}
}

View File

@@ -1,5 +1,5 @@
import { Freight } from "@edr/types";
import { IsEnum, IsNumber, IsOptional, IsString, Min } from "class-validator";
import { IsString, IsNumber, IsOptional, IsUUID, IsDateString, Min, IsEnum } from 'class-validator';
import { Freight } from '@edr/types';
export class CreateTrainDto {
@IsString()
@@ -11,9 +11,45 @@ export class CreateTrainDto {
@IsOptional()
@IsEnum(Freight.TrainStatus)
status?: Freight.TrainStatus;
status?: Freight.TrainStatus; // ✅ uses enum, not string
@IsOptional()
@IsString()
notes?: string;
}
@IsOptional()
@IsString()
trainNumber?: string;
@IsOptional()
@IsString()
trainName?: string;
@IsOptional()
@IsUUID()
routeId?: string;
@IsOptional()
@IsUUID()
originStationId?: string;
@IsOptional()
@IsUUID()
destinationStationId?: string;
@IsOptional()
@IsDateString()
departureTime?: string;
@IsOptional()
@IsDateString()
arrivalTime?: string;
@IsOptional()
@IsString()
locomotiveNumber?: string;
@IsOptional()
@IsString()
remarks?: string;
}

View File

@@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateTrainDto } from './create-train.dto';
export class UpdateTrainDto extends PartialType(CreateTrainDto) {}

Some files were not shown because too many files have changed in this diff Show More