booking flow,summtion, approval, contract, mock payemnt and integration to back office, and also add permissions

This commit is contained in:
marshal
2026-06-05 10:38:31 +03:00
parent 810bbc1168
commit d226d7ef22
94 changed files with 3509 additions and 1042 deletions

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import { ContractTemplateResolver } from '../../contracts/contract-template.reso
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { MinioService } from '../minio/minio.service';
import { FilesService } from '../files/files.service';
import { FileRecord } from '../files/entities/file.entity';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
@@ -68,7 +69,7 @@ export class BookingContractService {
async getContractView(bookingId: string): Promise<ContractViewDto> {
const { view } = await this.viewModelBuilder.build(bookingId);
await this.enrichSignatureUrls(view.signatures);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
return {
bookingId: view.bookingId,
@@ -90,33 +91,8 @@ export class BookingContractService {
assertBookingStatus(booking, ['APPROVED']);
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 file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${booking.reference}.pdf`,
encoding: '7bit',
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,
});
await this.upsertContractPdf(bookingId, booking.reference, templateKey);
const now = new Date();
const updated = await this.bookingsRepository.update(bookingId, {
@@ -129,18 +105,15 @@ export class BookingContractService {
}
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.',
);
}
const booking = await this.requireBooking(bookingId);
const templateKey =
booking.contractTemplateKey ?? this.templateResolver.resolve(booking);
const record = await this.upsertContractPdf(
bookingId,
booking.reference,
templateKey,
);
return this.filesService.streamById(record.id);
}
async signContract(
@@ -218,33 +191,84 @@ export class BookingContractService {
}
const updated = await this.bookingsRepository.update(bookingId, updates as never);
await this.upsertContractPdf(
bookingId,
booking.reference,
booking.contractTemplateKey ?? this.templateResolver.resolve(booking),
);
return updated!;
}
async getSignatures(bookingId: string) {
const rows = await this.bookingsRepository.findContractSignatures(bookingId);
const views = rows.map((r) => this.viewModelBuilder.toSignatureView(r));
await this.enrichSignatureUrls(views);
await this.inlineSignatureImages(views);
return { signatures: views };
}
private async enrichSignatureUrls(
private async upsertContractPdf(
bookingId: string,
reference: string,
templateKey: string,
): Promise<FileRecord> {
const { view } = await this.viewModelBuilder.build(bookingId);
view.templateKey = templateKey;
view.template = getTemplateMeta(templateKey);
await this.inlineSignatureImages(view.signatures);
const html = this.renderer.render(view);
const pdfBuffer = await this.pdfService.htmlToPdfBuffer(html);
const file: Express.Multer.File = {
fieldname: 'contract',
originalname: `contract-${reference}.pdf`,
encoding: '7bit',
mimetype: 'application/pdf',
size: pdfBuffer.length,
buffer: pdfBuffer,
stream: Readable.from(pdfBuffer),
destination: '',
filename: '',
path: '',
};
return this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
code: 'contract',
file,
});
}
private async inlineSignatureImages(
signatures: Array<{ signatureImageUrl?: string | null }>,
): Promise<void> {
for (const sig of signatures) {
if (!sig.signatureImageUrl) continue;
try {
const objectName = this.extractObjectName(sig.signatureImageUrl);
sig.signatureImageUrl = await this.minioService.getSignedUrl(objectName, 3600);
if (sig.signatureImageUrl.startsWith('data:')) continue;
const objectName = this.minioService.getObjectNameFromUrl(
sig.signatureImageUrl,
);
const stream = await this.minioService.getFileStream(objectName);
const buffer = await this.streamToBuffer(stream);
sig.signatureImageUrl = `data:image/png;base64,${buffer.toString(
'base64',
)}`;
} catch {
/* keep original url */
}
}
}
private extractObjectName(url: string): string {
const parts = url.split('/');
return parts.slice(4).join('/');
private streamToBuffer(stream: Readable): Promise<Buffer> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk: Buffer | string) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks)));
});
}
private decodeSignatureImage(base64: string): Buffer {

View File

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

View File

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

View File

@@ -1,127 +1,47 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { FilesService } from '../files/files.service';
import { Injectable, NotFoundException } from '@nestjs/common';
import { BookingsRepository } from './bookings.repository';
import { Booking } from './entities/booking.entity';
import { assertBookingStatus } from './booking-status.util';
import { InAppPaymentReceiptDto } from './dto/pay-booking.dto';
const PROOF_MAX_BYTES = 5 * 1024 * 1024;
const PROOF_MIMES = ['application/pdf', 'image/jpeg', 'image/png'];
export interface InAppPaymentReceipt extends InAppPaymentReceiptDto {}
@Injectable()
export class BookingPaymentService {
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly filesService: FilesService,
) {}
constructor(private readonly bookingsRepository: BookingsRepository) {}
async generatePnr(bookingId: string): Promise<Booking> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
if (booking.paymentCurrency !== 'ETB') {
throw new BadRequestException('PNR generation is only for ETB payers');
}
const year = new Date().getFullYear();
const pnrCode = `PNR-${year}-${Math.random().toString(36).slice(2, 10).toUpperCase()}`;
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PNR_GENERATED',
pnrCode,
paymentStatus: 'PNR_GENERATED',
} as never);
return updated!;
}
async submitPaymentProof(
async pay(
bookingId: string,
file: Express.Multer.File,
): Promise<Booking> {
): Promise<{ booking: Booking; receipt: InAppPaymentReceipt }> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['FULLY_EXECUTED']);
if (booking.paymentCurrency !== 'USD') {
throw new BadRequestException('Payment proof upload is only for USD payers');
}
this.validateProofFile(file);
await this.filesService.upload({
resourceId: bookingId,
resource: 'bookings',
code: 'payment_proof',
file,
});
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PAYMENT_VERIFICATION_IN_PROGRESS',
paymentStatus: 'VERIFICATION_IN_PROGRESS',
} as never);
return updated!;
}
async verifyPayment(bookingId: string): Promise<Booking> {
const booking = await this.requireBooking(bookingId);
assertBookingStatus(booking, ['PAYMENT_VERIFICATION_IN_PROGRESS']);
const receipt = this.buildMockReceipt(booking);
const updated = await this.bookingsRepository.update(bookingId, {
status: 'PAID',
paymentStatus: 'PAID',
} as never);
return updated!;
return { booking: updated!, receipt };
}
async handleBankCallback(pnrCode: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByPnrCode(pnrCode);
if (!booking) {
throw new NotFoundException(`No booking found for PNR ${pnrCode}`);
}
private buildMockReceipt(booking: Booking): InAppPaymentReceipt {
const timestamp = Date.now();
const isEtb = booking.paymentCurrency === 'ETB';
const prefix = isEtb ? 'TB' : 'CARD';
const provider = isEtb ? 'TELEBIRR' : 'CARD';
if (booking.status !== 'PNR_GENERATED') {
throw new BadRequestException(
`Booking ${booking.reference} is not awaiting bank payment (status: ${booking.status})`,
);
}
const updated = await this.bookingsRepository.update(booking.id, {
status: 'PAID',
paymentStatus: 'PAID',
} as never);
return updated!;
}
async getPaymentRequestLetter(
bookingId: string,
): Promise<{ buffer: Buffer; filename: string }> {
const booking = await this.requireBooking(bookingId);
const body = [
'PAYMENT REQUEST LETTER (STUB)',
`Reference: ${booking.reference}`,
`Amount: ${booking.totalAmount} ${booking.paymentCurrency}`,
'Pay at your bank and upload stamped proof.',
].join('\n');
return {
buffer: Buffer.from(body, 'utf-8'),
filename: `payment-request-${booking.reference}.txt`,
success: true,
provider,
providerRef: `${prefix}-${booking.reference}-${timestamp}`,
amount: booking.totalAmount,
currency: booking.paymentCurrency,
paidAt: new Date().toISOString(),
};
}
private validateProofFile(file: Express.Multer.File): void {
if (!file?.buffer?.length) {
throw new BadRequestException('Payment proof file is required');
}
if (file.size > PROOF_MAX_BYTES) {
throw new BadRequestException('Payment proof must be 5MB or less');
}
if (!PROOF_MIMES.includes(file.mimetype)) {
throw new BadRequestException('Payment proof must be PDF, JPG, or PNG');
}
}
private async requireBooking(id: string): Promise<Booking> {
const booking = await this.bookingsRepository.findById(id);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);

View File

@@ -1,10 +1,13 @@
import { BadRequestException, forwardRef, Inject, Injectable } from '@nestjs/common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { assertCanApproveBookingStep } from '../../common/freight-permission.util';
import { RuleEngineService } from '../rule-engine/rule-engine.service';
import { BookingContractService } from './booking-contract.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingsRepository } from './bookings.repository';
import { assertBookingStatus } from './booking-status.util';
import { computeNextStep, type BookingNextStep } from './booking-next-step.util';
import { Booking } from './entities/booking.entity';
import { BookingsService } from './bookings.service';
@@ -60,6 +63,16 @@ export class BookingTransitionService {
return this.bookingsService.findById(updated!.id);
}
/** Auto-create booking approval steps from system rules when none exist yet. */
private async ensureBookingApprovalSteps(booking: Booking): Promise<void> {
if ((booking.approvalSteps?.length ?? 0) > 0) return;
await this.ruleEngineService.instantiateApprovalSteps(booking.id, {
freightType: booking.freightType as 'CONTAINER' | 'BULK',
cargoTypeId: booking.cargoTypeId,
});
}
async acceptIntake(bookingId: string, actorId: string): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, ['SUBMITTED']);
@@ -103,13 +116,23 @@ export class BookingTransitionService {
stepId: string,
actorId: string,
requiredRole: string,
authUser?: TCurrentUser,
): Promise<Booking> {
const booking = await this.bookingsService.findById(bookingId);
if (authUser) {
assertCanApproveBookingStep(authUser, requiredRole);
}
let booking = await this.bookingsService.findById(bookingId);
assertBookingStatus(booking, [
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
]);
if ((booking.approvalSteps?.length ?? 0) === 0) {
await this.ensureBookingApprovalSteps(booking);
booking = await this.bookingsService.findById(bookingId);
}
const step = await this.bookingsRepository.findApprovalStepById(
bookingId,
stepId,
@@ -131,7 +154,7 @@ export class BookingTransitionService {
);
}
const blocksRole = step.approvalRule?.blocksRole;
const blocksRole = step.blocksRole;
if (blocksRole && blocksRole === requiredRole) {
throw new BadRequestException(`Role ${requiredRole} is blocked for this step`);
}
@@ -271,6 +294,7 @@ export class BookingTransitionService {
async enrichBookingResponse(booking: Booking): Promise<Booking & {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
}> {
const note = await this.bookingsRepository.findLatestReviewNote(
booking.id,
@@ -279,10 +303,17 @@ export class BookingTransitionService {
const summary =
booking.contractSummary ??
this.contractService.buildContractSummary(booking);
const nextPending =
booking.status === 'PENDING_APPROVAL' ||
booking.status === 'APPROVED_PENDING_SIGNATURE'
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
};
}
}

View File

@@ -3,7 +3,6 @@ import {
Controller,
Delete,
Get,
Header,
HttpCode,
Param,
ParseUUIDPipe,
@@ -12,13 +11,13 @@ import {
Query,
Request,
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 type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AnyFilesInterceptor } from '@nestjs/platform-express';
import {
ApiBearerAuth,
@@ -31,13 +30,13 @@ import {
import type { Response } from 'express';
import { BookingContractService } from './booking-contract.service';
import { BookingPaymentService } from './booking-payment.service';
import { BookingPricingService } from './booking-pricing.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingsService } from './bookings.service';
import { BookingReferenceDataDto } from './dto/booking-reference-data.dto';
import { CreateBookingDto } from './dto/create-booking.dto';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { GeneratePriceResponseDto } from './dto/generate-price-response.dto';
import {
@@ -65,7 +64,6 @@ export class BookingsController {
private readonly pricingService: BookingPricingService,
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
private readonly paymentService: BookingPaymentService,
) {}
@Post()
@@ -104,6 +102,13 @@ export class BookingsController {
return this.bookingsService.findAll(filter);
}
@Get('list-summary')
@ApiOperation({ summary: 'Booking list metrics and tab counts (backoffice)' })
@ApiOkResponse({ type: BookingListSummaryDto })
findListSummary(@Query() filter: FilterBookingDto) {
return this.bookingsService.getListSummary(filter);
}
@Get('queues/:queue')
@ApiOperation({
summary: 'List bookings for a dashboard queue',
@@ -162,7 +167,7 @@ export class BookingsController {
}
@Post(':id/staff/request-changes')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.requestChanges)
@ApiOperation({ summary: 'Staff return booking for customer updates' })
async requestChanges(
@Param('id', ParseUUIDPipe) id: string,
@@ -178,7 +183,7 @@ export class BookingsController {
}
@Post(':id/staff/accept')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.staffAccept)
@ApiOperation({ summary: 'Staff accept intake → start approval chain' })
async acceptIntake(
@Param('id', ParseUUIDPipe) id: string,
@@ -192,7 +197,7 @@ export class BookingsController {
}
@Post(':id/staff/reject')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.reject)
@ApiOperation({ summary: 'Staff final reject' })
async staffReject(
@Param('id', ParseUUIDPipe) id: string,
@@ -208,25 +213,30 @@ export class BookingsController {
}
@Post(':id/approval-steps/:stepId/approve')
@UseGuards(JwtGuard)
@BookingStaff([
FREIGHT_PERMS.bookings.approveLineStaff,
FREIGHT_PERMS.bookings.approveDirector,
FREIGHT_PERMS.bookings.approveCeo,
])
@ApiOperation({ summary: 'Approve one approval step in sequence' })
async approveStep(
@Param('id', ParseUUIDPipe) id: string,
@Param('stepId', ParseUUIDPipe) stepId: string,
@Body() dto: ApproveStepDto,
@CurrentUser() user: AuthUserPayload,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.transitionService.approveStep(
id,
stepId,
resolveAuthUserId(user),
dto.requiredRole,
user,
);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/approval-steps/:stepId/reject')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.rejectApproval)
@ApiOperation({ summary: 'Reject at approval step' })
async rejectStep(
@Param('id', ParseUUIDPipe) id: string,
@@ -244,7 +254,7 @@ export class BookingsController {
}
@Post(':id/contract/generate')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.generateContract)
@ApiOperation({ summary: 'Generate contract PDF from template' })
async generateContract(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.contractService.generateContract(id);
@@ -262,22 +272,23 @@ export class BookingsController {
@ApiOperation({ summary: 'Download contract PDF' })
async downloadContractDocument(
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
@Res() res: Response,
): Promise<void> {
const { stream, record } = await this.contractService.streamContract(id);
res.set({
'Content-Type': record.mimeType ?? 'application/pdf',
'Content-Disposition': `attachment; filename="${record.name}"`,
});
return new StreamableFile(stream);
res.setHeader('Content-Type', record.mimeType ?? 'application/pdf');
res.setHeader(
'Content-Disposition',
`attachment; filename="${record.name}"`,
);
stream.pipe(res);
}
@Get(':id/contract')
@ApiOperation({ summary: 'Download contract file (alias)' })
async downloadContract(
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
@Res() res: Response,
): Promise<void> {
return this.downloadContractDocument(id, res);
}
@@ -326,7 +337,7 @@ export class BookingsController {
}
@Post(':id/marketing/approve')
@UseGuards(JwtGuard)
@BookingStaff(FREIGHT_PERMS.bookings.signStaff)
@ApiOperation({
summary: 'Staff contract signature and fully execute (use contract/sign STAFF preferred)',
})
@@ -347,47 +358,8 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/payment/pnr')
@ApiOperation({ summary: 'Generate PNR code (ETB)' })
async generatePnr(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.paymentService.generatePnr(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/payment/proof')
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Upload USD payment proof' })
async submitPaymentProof(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFiles() files: Express.Multer.File[],
) {
const file = files?.[0];
const booking = await this.paymentService.submitPaymentProof(id, file);
return this.transitionService.enrichBookingResponse(booking);
}
@Get(':id/payment/request-letter')
@ApiOperation({ summary: 'Download payment request letter (USD stub)' })
@Header('Content-Type', 'text/plain')
async paymentRequestLetter(
@Param('id', ParseUUIDPipe) id: string,
@Res({ passthrough: true }) res: Response,
) {
const { buffer, filename } =
await this.paymentService.getPaymentRequestLetter(id);
res.set('Content-Disposition', `attachment; filename="${filename}"`);
return new StreamableFile(buffer);
}
@Post(':id/payment/verify')
@ApiOperation({ summary: 'Finance verify USD payment' })
async verifyPayment(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.paymentService.verifyPayment(id);
return this.transitionService.enrichBookingResponse(booking);
}
@Post(':id/operations/start-transit')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark in transit' })
async startTransit(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.startTransit(id);
@@ -395,6 +367,7 @@ export class BookingsController {
}
@Post(':id/operations/complete')
@BookingStaff(FREIGHT_PERMS.bookings.operations)
@ApiOperation({ summary: 'Mark completed' })
async complete(@Param('id', ParseUUIDPipe) id: string) {
const booking = await this.transitionService.complete(id);
@@ -402,6 +375,7 @@ export class BookingsController {
}
@Post(':id/cancel')
@BookingStaff(FREIGHT_PERMS.bookings.cancel)
@ApiOperation({ summary: 'Cancel booking' })
async cancel(
@Param('id', ParseUUIDPipe) id: string,

View File

@@ -12,10 +12,10 @@ import { BookingPricingService } from './booking-pricing.service';
import { BookingReferenceDataService } from './booking-reference-data.service';
import { BookingTransitionService } from './booking-transition.service';
import { BookingsController } from './bookings.controller';
import { PayController } from './pay.controller';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { BookingsService } from './bookings.service';
import { PaymentsWebhookController } from './payments-webhook.controller';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
import { BookingContainer } from './entities/booking-container.entity';
@@ -46,7 +46,7 @@ import { ContractViewModelBuilder } from '../../contracts/contract-view-model.bu
// CustomersModule,
RuleEngineModule,
],
controllers: [BookingsController, PaymentsWebhookController],
controllers: [BookingsController, PayController],
providers: [
BookingsService,
BookingsRepository,

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, FindOptionsWhere, Repository } from 'typeorm';
import { DataSource, FindOptionsWhere, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
@@ -17,6 +17,20 @@ import {
import { FileRecord } from '../files/entities/file.entity';
import { ContainerWeightResult } from '../rule-engine/rule-engine.service';
export interface BookingListFilterOptions {
statuses?: string[];
status?: string;
companyId?: string;
contractType?: string;
serviceTypeId?: string;
cargoTypeId?: string;
freightType?: string;
tradeDirection?: string;
paymentCurrency?: string;
allowConsolidation?: boolean;
consolidationPaired?: string;
}
@Injectable()
export class BookingsRepository extends BaseRepository<Booking> {
constructor(
@@ -230,7 +244,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, status: 'PENDING' },
order: { stepOrder: 'ASC' },
relations: ['approvalRule'],
});
}
@@ -240,7 +253,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
): Promise<BookingApprovalStep | null> {
return this.dataSource.getRepository(BookingApprovalStep).findOne({
where: { bookingId, id: stepId },
relations: ['approvalRule'],
});
}
@@ -333,10 +345,6 @@ export class BookingsRepository extends BaseRepository<Booking> {
await this.dataSource.getRepository(BookingRateSnapshot).delete({ bookingId });
}
async findByPnrCode(pnrCode: string): Promise<Booking | null> {
return this.repository.findOne({ where: { pnrCode } });
}
/** Queue listing with optional bulk exclusion for LINE_STAFF. */
async findQueue(options: {
status: string | string[];
@@ -353,9 +361,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
// .leftJoinAndSelect('booking.customer', 'customer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.cargoType', 'cargo')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.status IN (:...statuses)', { statuses });
if (options.excludeBulk) {
@@ -363,7 +373,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
}
const sortField =
options.sortBy === 'priorityScore' ? 'booking.priority_score' : 'booking.created_at';
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb
@@ -374,6 +386,158 @@ export class BookingsRepository extends BaseRepository<Booking> {
return { items, total };
}
/** Paginated list with optional multi-status filter (API tab queues). */
async findAllPaginated(options: BookingListFilterOptions & {
page: number;
pageSize: number;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
}): Promise<{ items: Booking[]; total: number }> {
const page = options.page;
const pageSize = options.pageSize;
const qb = this.repository
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.leftJoinAndSelect('booking.serviceType', 'serviceType')
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
const sortField =
options.sortBy === 'priorityScore'
? 'booking.priorityScore'
: 'booking.createdAt';
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
const [items, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { items, total };
}
async getStatusCounts(): Promise<Record<string, number>> {
const rows = await this.repository
.createQueryBuilder('booking')
.select('booking.status', 'status')
.addSelect('COUNT(*)::int', 'count')
.where('booking.deleted_at IS NULL')
.groupBy('booking.status')
.getRawMany<{ status: string; count: string }>();
return Object.fromEntries(
rows.map((row) => [row.status, Number(row.count)]),
);
}
async getListSummaryMetrics(
options: BookingListFilterOptions & {
page: number;
pageSize: number;
needsActionStatuses: readonly string[];
urgentPriorityThreshold: number;
},
): Promise<{
inQueue: number;
onThisPage: number;
needsAction: number;
urgent: number;
}> {
const baseQb = () => {
const qb = this.repository
.createQueryBuilder('booking')
.where('booking.deleted_at IS NULL');
this.applyListFilters(qb, options);
return qb;
};
const inQueue = await baseQb().getCount();
const needsAction = await baseQb()
.andWhere('booking.status IN (:...needsActionStatuses)', {
needsActionStatuses: [...options.needsActionStatuses],
})
.getCount();
const urgent = await baseQb()
.andWhere('booking.priority_score >= :urgentPriorityThreshold', {
urgentPriorityThreshold: options.urgentPriorityThreshold,
})
.getCount();
const offset = (options.page - 1) * options.pageSize;
const onThisPage = Math.min(
options.pageSize,
Math.max(0, inQueue - offset),
);
return { inQueue, onThisPage, needsAction, urgent };
}
private applyListFilters(
qb: SelectQueryBuilder<Booking>,
options: BookingListFilterOptions,
): void {
if (options.statuses?.length) {
qb.andWhere('booking.status IN (:...statuses)', {
statuses: options.statuses,
});
} else if (options.status) {
qb.andWhere('booking.status = :status', { status: options.status });
}
if (options.companyId) {
qb.andWhere('booking.company_id = :companyId', {
companyId: options.companyId,
});
}
if (options.contractType) {
qb.andWhere('booking.contract_type = :contractType', {
contractType: options.contractType,
});
}
if (options.serviceTypeId) {
qb.andWhere('booking.service_type_id = :serviceTypeId', {
serviceTypeId: options.serviceTypeId,
});
}
if (options.cargoTypeId) {
qb.andWhere('booking.cargo_type_id = :cargoTypeId', {
cargoTypeId: options.cargoTypeId,
});
}
if (options.freightType) {
qb.andWhere('booking.freight_type = :freightType', {
freightType: options.freightType,
});
}
if (options.tradeDirection) {
qb.andWhere('booking.trade_direction = :tradeDirection', {
tradeDirection: options.tradeDirection,
});
}
if (options.paymentCurrency) {
qb.andWhere('booking.payment_currency = :paymentCurrency', {
paymentCurrency: options.paymentCurrency,
});
}
if (options.allowConsolidation !== undefined) {
qb.andWhere('booking.allow_consolidation = :allowConsolidation', {
allowConsolidation: options.allowConsolidation,
});
}
if (options.consolidationPaired === 'true') {
qb.andWhere('booking.consolidation_partner_id IS NOT NULL');
} else if (options.consolidationPaired === 'false') {
qb.andWhere('booking.consolidation_partner_id IS NULL');
}
}
async findAndCountFiltered(where: FindOptionsWhere<Booking>, options: {
skip: number;
take: number;

View File

@@ -4,8 +4,6 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { FilesService } from '../files/files.service';
@@ -19,12 +17,25 @@ import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
import { BookingListSummaryDto } from './dto/booking-list-summary.dto';
import { FilterBookingDto } from './dto/filter-booking.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import { CUSTOMER_EDITABLE_STATUSES, FreightType } from './entities/booking.entity';
import {
BOOKING_STATUSES,
CUSTOMER_EDITABLE_STATUSES,
FreightType,
} from './entities/booking.entity';
import { Booking } from './entities/booking.entity';
import { FileRecord } from '../files/entities/file.entity';
const URGENT_PRIORITY_THRESHOLD = 1000;
const NEEDS_ACTION_STATUSES = [
'SUBMITTED',
'PENDING_APPROVAL',
'APPROVED_PENDING_SIGNATURE',
] as const;
@Injectable()
export class BookingsService {
constructor(
@@ -379,44 +390,88 @@ export class BookingsService {
return { booking, warnings };
}
/** Parse comma-separated or repeated status query values. */
private parseStatusFilter(filter: FilterBookingDto): {
statuses?: string[];
status?: string;
} {
const allowed = new Set<string>(BOOKING_STATUSES);
const raw = filter.statuses;
const statusList = raw
? raw
.split(',')
.map((s) => s.trim())
.filter((s) => allowed.has(s))
: [];
if (statusList.length > 0) {
return { statuses: statusList };
}
if (filter.status && allowed.has(filter.status)) {
return { status: filter.status };
}
return {};
}
/** Return a paginated list of bookings matching the filter. */
async findAll(
filter: FilterBookingDto,
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
// if (filter.customerId) where.customerId = filter.customerId;
if (filter.companyId) where.companyId = filter.companyId;
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) {
where.allowConsolidation = filter.allowConsolidation;
}
if (filter.consolidationPaired === 'true') {
where.consolidationPartnerId = Not(IsNull());
} else if (filter.consolidationPaired === 'false') {
where.consolidationPartnerId = IsNull();
}
const sortField = filter.sortBy ?? 'createdAt';
const sortDir = filter.sortOrder ?? 'DESC';
const [items, total] = await this.bookingsRepository.findAndCount({
where,
skip: (page - 1) * pageSize,
take: pageSize,
order: { [sortField]: sortDir },
relations: ['company', 'originYard', 'destinationYard', 'serviceType'],
// relations: ['customer', 'originYard', 'destinationYard', 'serviceType'],
return this.bookingsRepository.findAllPaginated({
page,
pageSize,
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
sortBy: filter.sortBy,
sortOrder: filter.sortOrder,
});
return { items, total };
}
/** Aggregate metrics and tab counts for the backoffice booking list. */
async getListSummary(filter: FilterBookingDto): Promise<BookingListSummaryDto> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const statusFilter = this.parseStatusFilter(filter);
const listFilter = {
...statusFilter,
companyId: filter.companyId,
contractType: filter.contractType,
serviceTypeId: filter.serviceTypeId,
cargoTypeId: filter.cargoTypeId,
freightType: filter.freightType,
tradeDirection: filter.tradeDirection,
paymentCurrency: filter.paymentCurrency,
allowConsolidation: filter.allowConsolidation,
consolidationPaired: filter.consolidationPaired,
};
const [statusCounts, metrics] = await Promise.all([
this.bookingsRepository.getStatusCounts(),
this.bookingsRepository.getListSummaryMetrics({
...listFilter,
page,
pageSize,
needsActionStatuses: NEEDS_ACTION_STATUSES,
urgentPriorityThreshold: URGENT_PRIORITY_THRESHOLD,
}),
]);
return {
metrics,
tabs: mapStatusCountsToTabs(statusCounts),
};
}
/** Get a single booking by ID with files. */
@@ -429,7 +484,7 @@ export class BookingsService {
if (booking.files && booking.files.length > 0) {
booking.files = await Promise.all(
booking.files.map(async (file: FileRecord) => {
const objectName = this.extractObjectName(file.url);
const objectName = this.minioService.getObjectNameFromUrl(file.url);
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
return { ...file, signedUrl };
}),
@@ -439,11 +494,6 @@ export class BookingsService {
return booking;
}
private extractObjectName(url: string): string {
const parts = url.split('/');
return parts.slice(4).join('/');
}
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
if (!booking) {
@@ -467,10 +517,11 @@ export class BookingsService {
): Promise<{ items: Booking[]; total: number }> {
const statusMap: Record<string, string | string[]> = {
intake: 'SUBMITTED',
approval: 'PENDING_APPROVAL',
approval: ['PENDING_APPROVAL', 'APPROVED_PENDING_SIGNATURE'],
signatures: ['APPROVED_PENDING_SIGNATURE', 'PENDING_APPROVAL'],
contract: ['APPROVED', 'CONTRACT_READY', 'SIGNED_CUSTOMER', 'FULLY_EXECUTED'],
marketing: 'SIGNED_CUSTOMER',
finance: 'PAYMENT_VERIFICATION_IN_PROGRESS',
finance: 'FULLY_EXECUTED',
};
const status = statusMap[queue];

View File

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

View File

@@ -14,6 +14,18 @@ export class FilterBookingDto {
@IsIn([...BOOKING_STATUSES])
status?: string;
@ApiPropertyOptional({
description:
'Filter by statuses: comma-separated (PENDING_APPROVAL,APPROVED) or repeated query params. Overrides status when set.',
})
@IsOptional()
@Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
if (Array.isArray(value)) return value.map(String).join(',');
return String(value);
})
statuses?: string;
// @ApiPropertyOptional({ format: 'uuid' })
// @IsOptional()
// @IsUUID()

View File

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

View File

@@ -34,9 +34,3 @@ export class CancelBookingDto {
@MinLength(1)
reason!: string;
}
export class BankCallbackDto {
@ApiProperty()
@IsString()
pnrCode!: string;
}

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { BookingPaymentService } from './booking-payment.service';
import { BankCallbackDto } from './dto/request-changes.dto';
@ApiTags('payments')
@Controller('webhooks/payments')
export class PaymentsWebhookController {
constructor(private readonly paymentService: BookingPaymentService) {}
@Post('bank')
@ApiOperation({ summary: 'Bank payment callback (stub)' })
bankCallback(@Body() dto: BankCallbackDto) {
return this.paymentService.handleBankCallback(dto.pnrCode);
}
}

View File

@@ -79,13 +79,8 @@ export class FilesService {
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
const record = await this.findById(id);
const objectName = this.extractObjectName(record.url);
const objectName = this.minioService.getObjectNameFromUrl(record.url);
const stream = await this.minioService.getFileStream(objectName);
return { stream, record };
}
private extractObjectName(url: string): string {
const parts = url.split("/");
return parts.slice(4).join("/");
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import {
Body, Controller, Delete, Get, HttpCode, HttpStatus,
Param, ParseUUIDPipe, Patch, Post, Query, UseGuards,
Param, ParseUUIDPipe, Patch, Post, Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { JwtGuard } from '@tria-plc/api-common/modules/auth/services/jwt.guard';
import { RuleEngineManage, RuleEngineView } from '../../../common/rule-engine-guards';
import { CreateRateDto } from '../dto/create-rate.dto';
import {
type AuthUserPayload,
@@ -15,12 +15,12 @@ import { RatesService } from '../services/rates.service';
@ApiTags('rates')
@Controller('rates')
// @UseGuards(JwtAuthGuard) — TODO: add when auth package integrated
@ApiBearerAuth()
export class RatesController {
constructor(private readonly service: RatesService) {}
@Get()
@RuleEngineView('rates')
@ApiOperation({ summary: 'List rates' })
findAll(@Query() query: Record<string, string>) {
return this.service.findAll({
@@ -32,19 +32,21 @@ export class RatesController {
}
@Get('live')
@RuleEngineView('rates')
@ApiOperation({ summary: 'List all LIVE rates effective now' })
findLive() {
return this.service.findLiveRates();
}
@Get(':id')
@RuleEngineView('rates')
@ApiOperation({ summary: 'Get a rate by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.service.findById(id);
}
@Post()
@UseGuards(JwtGuard)
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Create a rate (DRAFT)' })
create(
@Body() dto: CreateRateDto,
@@ -54,19 +56,21 @@ export class RatesController {
}
@Patch(':id')
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Update a DRAFT rate' })
update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateRateDto) {
return this.service.update(id, dto);
}
@Post(':id/submit')
@RuleEngineManage('rates')
@ApiOperation({ summary: 'Submit rate for CEO approval' })
submit(@Param('id', ParseUUIDPipe) id: string) {
return this.service.submitForApproval(id);
}
@Post(':id/approve')
@UseGuards(JwtGuard)
@RuleEngineManage('rates')
@ApiOperation({ summary: 'CEO approves a rate' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@@ -76,6 +80,7 @@ export class RatesController {
}
@Delete(':id')
@RuleEngineManage('rates')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiOperation({ summary: 'Soft-delete a rate' })
remove(@Param('id', ParseUUIDPipe) id: string) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,6 +35,7 @@ import {
IShippingLinesRepository,
SHIPPING_LINES_REPOSITORY,
} from './interfaces/shipping-lines.repository.interface';
import { DEFAULT_APPROVAL_RULE_ROWS } from './approval-rules.defaults';
export interface BookingContainerEvalInput {
containerTypeId: string;
@@ -238,6 +239,29 @@ export class RuleEngineService {
};
}
/**
* Ensure ITMLS default approval chains exist (container + bulk). Idempotent.
*/
async ensureDefaultApprovalRules(): Promise<void> {
for (const flag of [false, true] as const) {
const existing = await this.approvalRulesRepo.findChainForCargo(flag);
if (existing.length > 0) continue;
const rows = DEFAULT_APPROVAL_RULE_ROWS.filter(
(r) => r.requiresDirectorApproval === flag,
);
for (const row of rows) {
await this.approvalRulesRepo.create({
requiresDirectorApproval: row.requiresDirectorApproval,
stepOrder: row.stepOrder,
requiredRole: row.requiredRole,
actionLabel: row.actionLabel,
blocksRole: row.blocksRole,
});
}
}
}
/**
* Instantiate booking_approval_step rows from approval_rules by freight type.
*/
@@ -248,6 +272,8 @@ export class RuleEngineService {
cargoTypeId?: string | null;
},
): Promise<BookingApprovalStep[]> {
await this.ensureDefaultApprovalRules();
let requiresDirectorApproval = options.freightType === 'BULK';
if (options.cargoTypeId) {
@@ -264,6 +290,12 @@ export class RuleEngineService {
requiresDirectorApproval,
);
if (chain.length === 0) {
throw new BadRequestException(
`Approval chain could not be loaded for requiresDirectorApproval=${requiresDirectorApproval}.`,
);
}
const stepRepo = this.dataSource.getRepository(BookingApprovalStep);
const steps: BookingApprovalStep[] = [];
@@ -273,6 +305,7 @@ export class RuleEngineService {
approvalRuleId: rule.id,
stepOrder: rule.stepOrder,
requiredRole: rule.requiredRole,
blocksRole: rule.blocksRole ?? null,
status: 'PENDING',
});
steps.push(await stepRepo.save(step));