implement booking flow

This commit is contained in:
marshal
2026-06-04 15:16:27 +03:00
parent a5578ce714
commit 125ee18308
89 changed files with 6190 additions and 1724 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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,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()

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

View File

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

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

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

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

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

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,

View File

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

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

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

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,