mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
implement booking flow
This commit is contained in:
@@ -1,16 +1,34 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
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 { 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 {
|
||||
@@ -22,7 +40,7 @@ export class BookingContractService {
|
||||
: booking.tradeDirection;
|
||||
|
||||
const cargo = booking.cargoType;
|
||||
const isBulk = cargo?.requiresDirectorApproval;
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
|
||||
let cargoLabel: string;
|
||||
if (isBulk) {
|
||||
@@ -36,7 +54,7 @@ export class BookingContractService {
|
||||
cargoLabel =
|
||||
lines.length > 0
|
||||
? `Container (${lines.join(', ')})`
|
||||
: `Container (${cargo?.cargoTypeName ?? 'Standard'})`;
|
||||
: 'Container (Standard)';
|
||||
}
|
||||
|
||||
return `Operation: ${direction} | Cargo Type: ${cargoLabel}`;
|
||||
@@ -48,25 +66,117 @@ export class BookingContractService {
|
||||
return { summary };
|
||||
}
|
||||
|
||||
async getContractView(bookingId: string): Promise<ContractViewDto> {
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
await this.enrichSignatureUrls(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 summary = this.buildContractSummary(booking);
|
||||
const body = [
|
||||
'FREIGHT CONTRACT (STUB)',
|
||||
`Reference: ${booking.reference}`,
|
||||
summary,
|
||||
`Total: ${booking.totalAmount} ${booking.paymentCurrency}`,
|
||||
`Trade: ${booking.tradeDirection}`,
|
||||
].join('\n');
|
||||
const templateKey = this.templateResolver.resolve(booking);
|
||||
const { view } = await this.viewModelBuilder.build(bookingId);
|
||||
view.templateKey = templateKey;
|
||||
view.template = getTemplateMeta(templateKey);
|
||||
|
||||
const html = this.renderer.render(view);
|
||||
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
|
||||
const summary = this.buildContractSummary(booking);
|
||||
|
||||
const buffer = Buffer.from(body, 'utf-8');
|
||||
const file: Express.Multer.File = {
|
||||
fieldname: 'contract',
|
||||
originalname: `contract-${booking.reference}.txt`,
|
||||
originalname: `contract-${booking.reference}.pdf`,
|
||||
encoding: '7bit',
|
||||
mimetype: 'text/plain',
|
||||
mimetype: 'application/pdf',
|
||||
size: pdfBuffer.length,
|
||||
buffer: pdfBuffer,
|
||||
stream: Readable.from(pdfBuffer),
|
||||
destination: '',
|
||||
filename: '',
|
||||
path: '',
|
||||
};
|
||||
|
||||
await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'contract',
|
||||
file,
|
||||
});
|
||||
|
||||
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) {
|
||||
try {
|
||||
const record = await this.filesService.findByCode(
|
||||
bookingId,
|
||||
'bookings',
|
||||
'contract',
|
||||
);
|
||||
return this.filesService.streamById(record.id);
|
||||
} catch {
|
||||
throw new NotFoundException(
|
||||
'Contract document not found. Generate the contract first.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
@@ -75,27 +185,71 @@ export class BookingContractService {
|
||||
path: '',
|
||||
};
|
||||
|
||||
await this.filesService.upload({
|
||||
const fileRecord = await this.filesService.upsertByCode({
|
||||
resourceId: bookingId,
|
||||
resource: 'bookings',
|
||||
code: 'contract',
|
||||
file,
|
||||
code: role === 'CUSTOMER' ? 'signature_customer' : 'signature_staff',
|
||||
file: sigFile,
|
||||
});
|
||||
|
||||
const updated = await this.bookingsRepository.update(bookingId, {
|
||||
status: 'CONTRACT_READY',
|
||||
contractSummary: summary,
|
||||
} as never);
|
||||
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);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async streamContract(bookingId: string) {
|
||||
const record = await this.filesService.findByCode(
|
||||
bookingId,
|
||||
'bookings',
|
||||
'contract',
|
||||
);
|
||||
return this.filesService.streamById(record.id);
|
||||
async getSignatures(bookingId: string) {
|
||||
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
|
||||
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
|
||||
await this.enrichSignatureUrls(views);
|
||||
return { signatures: views };
|
||||
}
|
||||
|
||||
private async enrichSignatureUrls(
|
||||
signatures: Array<{ signatureImageUrl?: string | null }>,
|
||||
): Promise<void> {
|
||||
for (const sig of signatures) {
|
||||
if (!sig.signatureImageUrl) continue;
|
||||
try {
|
||||
const objectName = this.extractObjectName(sig.signatureImageUrl);
|
||||
sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600);
|
||||
} catch {
|
||||
/* keep original url */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractObjectName(url: string): string {
|
||||
const parts = url.split('/');
|
||||
return parts.slice(4).join('/');
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { Inject, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import {
|
||||
IRatesRepository,
|
||||
RATES_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/rates.repository.interface';
|
||||
import {
|
||||
IServiceTypesRepository,
|
||||
SERVICE_TYPES_REPOSITORY,
|
||||
} from '../rule-engine/interfaces/service-types.repository.interface';
|
||||
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,
|
||||
@@ -26,10 +20,8 @@ export class BookingPricingService {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
@Inject(RATES_REPOSITORY)
|
||||
private readonly ratesRepo: IRatesRepository,
|
||||
@Inject(SERVICE_TYPES_REPOSITORY)
|
||||
private readonly serviceTypesRepo: IServiceTypesRepository,
|
||||
private readonly ratesService: RatesService,
|
||||
private readonly serviceTypesService: ServiceTypesService,
|
||||
) {}
|
||||
|
||||
async generatePrice(bookingId: string): Promise<GeneratePriceResponseDto> {
|
||||
@@ -37,6 +29,7 @@ export class BookingPricingService {
|
||||
assertBookingStatus(booking, ['DRAFT']);
|
||||
|
||||
const evalInput = await this.buildEvalInputForBooking(booking);
|
||||
console.log('evalInput----', evalInput);
|
||||
const ruleResult = await this.ruleEngineService.evaluate(evalInput);
|
||||
this.ruleEngineService.assertNoHardBlocks(ruleResult);
|
||||
|
||||
@@ -65,6 +58,12 @@ export class BookingPricingService {
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
totalAmount: total,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
pricingBreakdown: {
|
||||
lineItems,
|
||||
totalAmount: total,
|
||||
currency: booking.paymentCurrency,
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
} as never);
|
||||
|
||||
return {
|
||||
@@ -92,7 +91,8 @@ export class BookingPricingService {
|
||||
}),
|
||||
);
|
||||
return {
|
||||
cargoTypeId: booking.cargoTypeId,
|
||||
freightType: booking.freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId: booking.cargoTypeId ?? null,
|
||||
serviceTypeId: booking.serviceTypeId,
|
||||
paymentCurrency: booking.paymentCurrency,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
@@ -109,13 +109,71 @@ export class BookingPricingService {
|
||||
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.serviceTypesRepo.findById(booking.serviceTypeId);
|
||||
const serviceType = await this.serviceTypesService.findById(booking.serviceTypeId);
|
||||
if (booking.paymentCurrency === 'USD' && serviceType) {
|
||||
const code = (serviceType.code ?? '').toUpperCase();
|
||||
const hasForwarding =
|
||||
@@ -136,10 +194,10 @@ export class BookingPricingService {
|
||||
booking: Booking,
|
||||
evalInput: BookingEvaluationInput,
|
||||
): Promise<PriceLineItemDto[]> {
|
||||
const liveRates = await this.ratesRepo.findLiveRates();
|
||||
const liveRates = await this.ratesService.findLiveRates();
|
||||
const currency = booking.paymentCurrency;
|
||||
const isBulk = booking.cargoType?.requiresDirectorApproval ?? false;
|
||||
|
||||
const isBulk = booking.freightType === 'BULK';
|
||||
console.log('liveRates----', liveRates);
|
||||
const rateType =
|
||||
booking.tradeDirection === 'IMPORT'
|
||||
? isBulk
|
||||
@@ -151,11 +209,16 @@ export class BookingPricingService {
|
||||
: '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);
|
||||
|
||||
@@ -42,7 +42,7 @@ export class BookingTransitionService {
|
||||
async requestChanges(
|
||||
bookingId: string,
|
||||
note: string,
|
||||
actorId?: string,
|
||||
actorId: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
@@ -60,19 +60,19 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async acceptIntake(bookingId: string, actorId?: string): Promise<Booking> {
|
||||
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED']);
|
||||
|
||||
await this.ruleEngineService.instantiateApprovalSteps(
|
||||
bookingId,
|
||||
booking.cargoTypeId,
|
||||
);
|
||||
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 ?? booking.approvedByStaffId,
|
||||
approvedByStaffAt: actorId ? new Date() : booking.approvedByStaffAt,
|
||||
approvedByStaffId: actorId,
|
||||
approvedByStaffAt: new Date(),
|
||||
} as never);
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ export class BookingTransitionService {
|
||||
async staffReject(
|
||||
bookingId: string,
|
||||
reason: string,
|
||||
actorId?: string,
|
||||
actorId: string,
|
||||
): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, ['SUBMITTED', 'PENDING_APPROVAL']);
|
||||
@@ -211,17 +211,14 @@ export class BookingTransitionService {
|
||||
return this.bookingsService.findById(updated!.id);
|
||||
}
|
||||
|
||||
async marketingApprove(
|
||||
bookingId: string,
|
||||
actorId?: string,
|
||||
): Promise<Booking> {
|
||||
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 ?? null,
|
||||
marketingApprovedById: actorId,
|
||||
marketingApprovedAt: new Date(),
|
||||
lockedAt: new Date(),
|
||||
} as never);
|
||||
|
||||
@@ -14,8 +14,11 @@ import {
|
||||
Res,
|
||||
StreamableFile,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { CurrentUser } from '@edr/api-common';
|
||||
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
import { AnyFilesInterceptor } from '@nestjs/platform-express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
@@ -41,12 +44,16 @@ import {
|
||||
ApproveStepDto,
|
||||
CancelBookingDto,
|
||||
RejectStepDto,
|
||||
MarketingApproveDto,
|
||||
RequestChangesDto,
|
||||
StaffAcceptDto,
|
||||
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')
|
||||
@@ -155,96 +162,146 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/staff/request-changes')
|
||||
@UseGuards(JwtGuard)
|
||||
@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,
|
||||
dto.actorId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/accept')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
|
||||
async acceptIntake(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: StaffAcceptDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.acceptIntake(id, dto.actorId);
|
||||
const booking = await this.transitionService.acceptIntake(
|
||||
id,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/staff/reject')
|
||||
@UseGuards(JwtGuard)
|
||||
@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,
|
||||
dto.actorId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/approve')
|
||||
@UseGuards(JwtGuard)
|
||||
@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: AuthUserPayload,
|
||||
) {
|
||||
const booking = await this.transitionService.approveStep(
|
||||
id,
|
||||
stepId,
|
||||
dto.actorId,
|
||||
resolveAuthUserId(user),
|
||||
dto.requiredRole,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/approval-steps/:stepId/reject')
|
||||
@UseGuards(JwtGuard)
|
||||
@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,
|
||||
dto.actorId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(':id/contract/generate')
|
||||
@ApiOperation({ summary: 'Generate contract document' })
|
||||
@UseGuards(JwtGuard)
|
||||
@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')
|
||||
@ApiOperation({ summary: 'Download contract file' })
|
||||
async downloadContract(
|
||||
@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({ passthrough: true }) res: Response,
|
||||
) {
|
||||
const { stream, record } = await this.contractService.streamContract(id);
|
||||
res.set({
|
||||
'Content-Type': record.mimeType ?? 'application/octet-stream',
|
||||
'Content-Type': record.mimeType ?? 'application/pdf',
|
||||
'Content-Disposition': `attachment; filename="${record.name}"`,
|
||||
});
|
||||
return new StreamableFile(stream);
|
||||
}
|
||||
|
||||
@Get(':id/contract')
|
||||
@ApiOperation({ summary: 'Download contract file (alias)' })
|
||||
async downloadContract(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Res({ passthrough: true }) res: Response,
|
||||
) {
|
||||
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) {
|
||||
@@ -252,22 +309,41 @@ export class BookingsController {
|
||||
}
|
||||
|
||||
@Post(':id/customer/sign')
|
||||
@ApiOperation({ summary: 'Customer digital signature' })
|
||||
async customerSign(@Param('id', ParseUUIDPipe) id: string) {
|
||||
const booking = await this.transitionService.customerSign(id);
|
||||
@ApiOperation({
|
||||
summary: 'Customer digital signature (deprecated — use POST contract/sign)',
|
||||
})
|
||||
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')
|
||||
@ApiOperation({ summary: 'Marketing verify and fully execute' })
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiOperation({
|
||||
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
|
||||
})
|
||||
async marketingApprove(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: MarketingApproveDto,
|
||||
@Body() dto: SignContractDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Request() req: { ip?: string },
|
||||
) {
|
||||
const booking = await this.transitionService.marketingApprove(
|
||||
id,
|
||||
dto.actorId,
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,8 +19,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: [
|
||||
@@ -31,6 +37,7 @@ import { Booking } from './entities/booking.entity';
|
||||
BookingApprovalStep,
|
||||
BookingRateSnapshot,
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
]),
|
||||
FilesModule,
|
||||
MinioModule,
|
||||
@@ -47,6 +54,11 @@ import { Booking } from './entities/booking.entity';
|
||||
BookingTransitionService,
|
||||
BookingContractService,
|
||||
BookingPaymentService,
|
||||
ContractTemplateResolver,
|
||||
ContractViewModelBuilder,
|
||||
ContractPricingScheduleBuilder,
|
||||
ContractRendererService,
|
||||
ContractPdfService,
|
||||
],
|
||||
exports: [BookingsService],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,10 @@ 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';
|
||||
|
||||
@@ -353,7 +357,7 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('booking.status IN (:...statuses)', { statuses });
|
||||
|
||||
if (options.excludeBulk) {
|
||||
qb.andWhere('cargo.requires_director_approval = false');
|
||||
qb.andWhere("booking.freight_type = 'CONTAINER'");
|
||||
}
|
||||
|
||||
const sortField =
|
||||
@@ -380,4 +384,39 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,10 +16,11 @@ 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 { FilterBookingDto } from './dto/filter-booking.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import { CUSTOMER_EDITABLE_STATUSES } from './entities/booking.entity';
|
||||
import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { FileRecord } from '../files/entities/file.entity';
|
||||
|
||||
@@ -42,22 +43,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 +71,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,
|
||||
};
|
||||
@@ -161,12 +166,29 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -185,7 +207,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 +226,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 {
|
||||
@@ -249,19 +273,44 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -277,6 +326,8 @@ export class BookingsService {
|
||||
|
||||
const updates: Record<string, unknown> = {
|
||||
...dto,
|
||||
freightType,
|
||||
cargoTypeId: freightType === 'BULK' ? cargoTypeId : null,
|
||||
allowConsolidation,
|
||||
priorityScore: ruleResult.priorityScore,
|
||||
};
|
||||
@@ -287,7 +338,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,
|
||||
@@ -328,6 +379,7 @@ export class BookingsService {
|
||||
if (filter.contractType) where.contractType = filter.contractType;
|
||||
if (filter.serviceTypeId) where.serviceTypeId = filter.serviceTypeId;
|
||||
if (filter.cargoTypeId) where.cargoTypeId = filter.cargoTypeId;
|
||||
if (filter.freightType) where.freightType = filter.freightType;
|
||||
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
|
||||
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
|
||||
if (filter.allowConsolidation !== undefined) {
|
||||
@@ -347,6 +399,7 @@ export class BookingsService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
order: { [sortField]: sortDir },
|
||||
relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
|
||||
});
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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,6 +52,9 @@ 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()
|
||||
@@ -107,9 +115,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 +173,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()
|
||||
|
||||
@@ -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 })
|
||||
@@ -28,6 +33,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])
|
||||
|
||||
@@ -1,30 +1,11 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, IsUUID, MinLength } from 'class-validator';
|
||||
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;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export class StaffAcceptDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export class MarketingApproveDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export class StaffRejectDto {
|
||||
@@ -32,28 +13,15 @@ export class StaffRejectDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
actorId?: string;
|
||||
}
|
||||
|
||||
export class ApproveStepDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
actorId!: string;
|
||||
|
||||
@ApiProperty({ description: 'LINE_STAFF | DIRECTOR | CEO' })
|
||||
@IsString()
|
||||
requiredRole!: string;
|
||||
}
|
||||
|
||||
export class RejectStepDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
actorId!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -46,6 +46,9 @@ export const PAYMENT_STATUSES = [
|
||||
|
||||
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',
|
||||
@@ -126,8 +129,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' })
|
||||
@@ -200,6 +206,15 @@ export class Booking extends BaseEntity {
|
||||
@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;
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import {
|
||||
Body, Controller, Delete, Get, HttpCode, HttpStatus,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query,
|
||||
Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
|
||||
} 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 { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
|
||||
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';
|
||||
|
||||
@@ -38,9 +44,13 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard)
|
||||
@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')
|
||||
@@ -56,9 +66,13 @@ export class RatesController {
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
@UseGuards(JwtGuard)
|
||||
@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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -47,7 +47,8 @@ export interface BookingContainerEvalInput {
|
||||
}
|
||||
|
||||
export interface BookingEvaluationInput {
|
||||
cargoTypeId: string;
|
||||
cargoTypeId?: string | null;
|
||||
freightType?: 'CONTAINER' | 'BULK';
|
||||
serviceTypeId: string;
|
||||
paymentCurrency: string;
|
||||
tradeDirection: string;
|
||||
@@ -115,13 +116,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,16 +239,29 @@ export class RuleEngineService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate booking_approval_step rows from approval_rules for a cargo type.
|
||||
* Instantiate booking_approval_step rows from approval_rules by freight type.
|
||||
*/
|
||||
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 instantiateApprovalSteps(
|
||||
bookingId: string,
|
||||
options: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
cargoTypeId?: string | null;
|
||||
},
|
||||
): Promise<BookingApprovalStep[]> {
|
||||
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,
|
||||
);
|
||||
|
||||
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user