Merge pull request #33 from Tria-plc/freight_feature/booking/minio

Freight feature/booking/minio
This commit is contained in:
yaschalew10
2026-05-23 09:54:35 +03:00
committed by GitHub
16 changed files with 1231 additions and 40 deletions

View File

@@ -29,6 +29,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^17.4.2",
"minio": "7.1.3",
"pg": "^8.13.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
@@ -45,6 +46,7 @@
"@types/amqplib": "^0.10.8",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.13",
"@types/multer": "^2.1.0",
"@types/node": "^20.14.0",
"@types/supertest": "^6.0.2",
"jest": "^29.7.0",

View File

@@ -6,42 +6,154 @@ import {
HttpCode,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UploadedFiles,
UseInterceptors,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { AnyFilesInterceptor } from "@nestjs/platform-express";
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
import { BookingsService } from "./bookings.service";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import { UpdateStatusDto } from "./dto/update-status.dto";
@ApiTags("bookings")
@Controller("bookings")
export class BookingsController {
constructor(private readonly bookingsService: BookingsService) {}
// ── 1. Create booking (multipart/form-data) ──────────────────────────
@Post()
@ApiOperation({ summary: "Create a new freight booking" })
create(@Body() dto: CreateBookingDto) {
return this.bookingsService.create(dto);
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Create a new freight booking",
description:
"Accepts all booking fields as form fields + dynamic file keys (e.g. passport, license). " +
"Auto-enables consolidation when containerType=20FT and odd quantity.",
})
@ApiBody({
description:
"Booking form data. Attach files with any field name (e.g. passport, tin_certificate). " +
"File metadata is stored in the documents JSONB column.",
type: CreateBookingDto,
})
create(
@Body() dto: CreateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
console.log('[BookingsController] Files received:', files?.length, files?.map(f => ({ fieldname: f.fieldname, originalname: f.originalname, size: f.size, mimetype: f.mimetype })));
return this.bookingsService.create(dto, files ?? []);
}
// ── 2. Update draft booking (multipart/form-data) ─────────────────────
@Patch(":id")
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes("multipart/form-data")
@ApiOperation({
summary: "Update a draft booking",
description: "Only DRAFT bookings can be updated. New files are merged into existing documents.",
})
@ApiBody({ type: UpdateBookingDto })
update(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateBookingDto,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.bookingsService.update(id, dto, files ?? []);
}
// ── 3. List bookings (paginated + filtered) ───────────────────────────
@Get()
@ApiOperation({ summary: "List freight bookings (paginated)" })
@ApiOperation({
summary: "List freight bookings (paginated)",
description:
"Filter by status, customerId, contractType, serviceType, tradeDirection, " +
"paymentCurrency, freightType, containerType, allowConsolidation, consolidationPaired. " +
"Sort by createdAt or priorityScore.",
})
findAll(@Query() filter: FilterBookingDto) {
return this.bookingsService.findAll(filter);
}
// ── 5. Lookup by reference (must be before :id to avoid conflict) ─────
@Get("by-reference/:reference")
@ApiOperation({
summary: "Get a freight booking by reference number",
description: "Lookup booking by its human-readable reference string.",
})
findByReference(@Param("reference") reference: string) {
return this.bookingsService.findByReference(reference);
}
// ── 4. Get single booking by ID ───────────────────────────────────────
@Get(":id")
@ApiOperation({ summary: "Get a freight booking by ID" })
findOne(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.findById(id);
}
// ── 6. Soft-delete (DRAFT only) ───────────────────────────────────────
@Delete(":id")
@HttpCode(204)
@ApiOperation({ summary: "Soft-delete a freight booking" })
@ApiOperation({
summary: "Soft-delete a freight booking",
description: "Only DRAFT bookings can be deleted.",
})
remove(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.remove(id);
}
// ── 7. Unified status transition ──────────────────────────────────────
@Patch(":id/status")
@ApiOperation({
summary: "Transition booking status",
description:
"Unified endpoint for all status transitions. Actions: " +
"SUBMIT, APPROVE_STAFF, APPROVE_DIRECTOR, APPROVE_CEO, REJECT, CANCEL, ACTIVATE, EXPIRE. " +
"Approval routing: Standard → LINE_STAFF → DIRECTOR → SIGNED. " +
"Bulk/high-volume → DIRECTOR → CEO → SIGNED.",
})
updateStatus(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateStatusDto,
) {
return this.bookingsService.updateStatus(id, dto);
}
// ── 8. Request or auto-pair consolidation ─────────────────────────────
@Post(":id/consolidation")
@ApiOperation({
summary: "Request freight consolidation",
description:
"Searches for a compatible 20FT partner (same origin, destination, tradeDirection). " +
"If a partner is found, both bookings are paired. If not, the booking enters the consolidation queue.",
})
requestConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.requestConsolidation(id);
}
// ── 9. Remove consolidation pairing ───────────────────────────────────
@Delete(":id/consolidation")
@ApiOperation({
summary: "Remove consolidation pairing",
description: "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
})
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.removeConsolidation(id);
}
// ── 10. Get consolidation details ─────────────────────────────────────
@Get(":id/consolidation")
@ApiOperation({
summary: "Get consolidation details",
description: "Returns partner booking details and split billing information.",
})
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
return this.bookingsService.getConsolidationDetails(id);
}
}

View File

@@ -1,13 +1,14 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { MinioModule } from "../minio/minio.module";
import { BookingsController } from "./bookings.controller";
import { BookingsRepository } from "./bookings.repository";
import { BookingsService } from "./bookings.service";
import { Booking } from "./entities/booking.entity";
@Module({
imports: [TypeOrmModule.forFeature([Booking])],
imports: [TypeOrmModule.forFeature([Booking]), MinioModule],
controllers: [BookingsController],
providers: [BookingsService, BookingsRepository],
exports: [BookingsService],

View File

@@ -1,7 +1,7 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { In, IsNull, Not, Repository } from "typeorm";
import { Booking } from "./entities/booking.entity";
@@ -18,4 +18,45 @@ export class BookingsRepository extends BaseRepository<Booking> {
findByReference(reference: string): Promise<Booking | null> {
return this.repository.findOne({ where: { reference } });
}
/** Find a compatible consolidation partner for the given booking. */
async findConsolidationPartner(booking: Booking): Promise<Booking | null> {
return this.repository.findOne({
where: {
allowConsolidation: true,
containerType: "20FT",
originStation: booking.originStation,
destinationStation: booking.destinationStation,
tradeDirection: booking.tradeDirection,
consolidationPartnerId: IsNull(),
status: In(["DRAFT", "PENDING_CONSOLIDATION"]),
id: Not(booking.id),
},
order: { createdAt: "ASC" },
});
}
/** Pair two bookings for consolidation. */
async pairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: partnerId,
status: "CONSOLIDATED",
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: bookingId,
status: "CONSOLIDATED",
} as never);
}
/** Un-pair a consolidation. Returns both booking IDs. */
async unpairConsolidation(bookingId: string, partnerId: string): Promise<void> {
await this.repository.update(bookingId, {
consolidationPartnerId: null,
status: "PENDING_CONSOLIDATION",
} as never);
await this.repository.update(partnerId, {
consolidationPartnerId: null,
status: "PENDING_CONSOLIDATION",
} as never);
}
}

View File

@@ -1,20 +1,219 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { IsNull, Not } from "typeorm";
import { MinioService } from "../minio/minio.service";
import { BookingsRepository } from "./bookings.repository";
import { CreateBookingDto } from "./dto/create-booking.dto";
import { FilterBookingDto } from "./dto/filter-booking.dto";
import { UpdateBookingDto } from "./dto/update-booking.dto";
import { UpdateStatusDto } from "./dto/update-status.dto";
import { Booking } from "./entities/booking.entity";
/** Weight thresholds (tons) that trigger overweight surcharge alerts. */
const WEIGHT_LIMITS = {
IMPORT_20FT: 20,
EXPORT_20FT: 25,
ANY_40FT: 32.5,
} as const;
/** Bookings above this total VGM are considered high-volume. */
const HIGH_VOLUME_THRESHOLD_TONS = 500;
@Injectable()
export class BookingsService {
constructor(private readonly bookingsRepository: BookingsRepository) {}
constructor(
private readonly bookingsRepository: BookingsRepository,
private readonly minioService: MinioService,
) {}
// ── helpers ──────────────────────────────────────────────────────────
/** Resolve auto-consolidation flag. */
private resolveConsolidation(
containerType: string,
containerQuantity: number,
explicit?: boolean,
): boolean {
if (explicit === false) return false;
if (containerType === "20FT" && containerQuantity % 2 !== 0) return true;
return explicit ?? false;
}
/** Calculate priority score based on currency and service type. */
private calculatePriorityScore(currency: string, serviceType: string): number {
let score = 0;
if (currency === "USD") score += 100;
if (serviceType === "RAIL_AND_FORWARDING") score += 50;
else if (serviceType === "RAIL_ONLY") score += 25;
return score;
}
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
private calculateWagonCount(
containerType: string,
containerQuantity: number,
): number {
if (containerType === "40FT") return containerQuantity;
return Math.ceil(containerQuantity / 2);
}
/** Check per-container weight limit and return a warning if exceeded. */
private checkOverweight(
containerType: string,
vgmPerUnit: number,
tradeDirection: string,
): string | null {
if (containerType === "40FT" && vgmPerUnit > WEIGHT_LIMITS.ANY_40FT) {
return `40FT container VGM ${vgmPerUnit}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`;
}
if (containerType === "20FT") {
const limit =
tradeDirection === "IMPORT"
? WEIGHT_LIMITS.IMPORT_20FT
: WEIGHT_LIMITS.EXPORT_20FT;
if (vgmPerUnit > limit) {
return `20FT ${tradeDirection} container VGM ${vgmPerUnit}t exceeds limit of ${limit}t`;
}
}
return null;
}
/** Build file metadata from uploaded files and upload to MinIO. */
private async buildDocuments(
bookingId: string,
files: Express.Multer.File[],
): Promise<Record<string, { originalName: string; size: number; mimeType: string; url: string }>> {
console.log('[BookingsService] buildDocuments called with bookingId:', bookingId, 'files count:', files.length);
const docs: Record<string, { originalName: string; size: number; mimeType: string; url: string }> = {};
for (const file of files) {
console.log('[BookingsService] Processing file:', file.fieldname, file.originalname, 'size:', file.size);
const objectName = `bookings/${bookingId}/${Date.now()}_${file.originalname}`;
console.log('[BookingsService] Uploading to MinIO:', objectName);
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
console.log('[BookingsService] Upload successful, URL:', url);
docs[file.fieldname] = {
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
url,
};
}
console.log('[BookingsService] All files processed, docs:', docs);
return docs;
}
// ── CRUD ─────────────────────────────────────────────────────────────
/** Create a new freight booking. */
async create(dto: CreateBookingDto): Promise<Booking> {
return this.bookingsRepository.create({
async create(
dto: CreateBookingDto,
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const warnings: string[] = [];
const allowConsolidation = this.resolveConsolidation(
dto.containerType,
dto.containerQuantity,
dto.allowConsolidation,
);
const priorityScore = this.calculatePriorityScore(
dto.paymentCurrency,
dto.serviceType,
);
const overweightWarning = this.checkOverweight(
dto.containerType,
dto.containerVgmPerUnit,
dto.tradeDirection,
);
if (overweightWarning) warnings.push(overweightWarning);
const wagonCount = this.calculateWagonCount(
dto.containerType,
dto.containerQuantity,
);
warnings.push(`Estimated wagons required: ${wagonCount}`);
const booking = await this.bookingsRepository.create({
...dto,
scheduledDate: new Date(dto.scheduledDate),
startDate: dto.startDate ? new Date(dto.startDate) : undefined,
endDate: dto.endDate ? new Date(dto.endDate) : undefined,
status: "DRAFT",
allowConsolidation,
priorityScore,
documents: null,
});
// Upload files to MinIO and update booking with documents
if (files.length > 0) {
try {
const documents = await this.buildDocuments(booking.id, files);
await this.bookingsRepository.update(booking.id, { documents });
booking.documents = documents;
} catch (err) {
console.error('[BookingsService] File upload failed, booking still created:', err);
warnings.push('File upload failed — booking was created without attached files.');
}
}
return { booking, warnings };
}
/** Update a draft booking. */
async update(
id: string,
dto: UpdateBookingDto,
files: Express.Multer.File[],
): Promise<{ booking: Booking; warnings: string[] }> {
const existing = await this.findById(id);
if (existing.status !== "DRAFT") {
throw new BadRequestException("Only DRAFT bookings can be updated");
}
const warnings: string[] = [];
const updates: Record<string, unknown> = { ...dto };
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
if (dto.startDate) updates.startDate = new Date(dto.startDate);
if (dto.endDate) updates.endDate = new Date(dto.endDate);
// Re-evaluate consolidation if container fields changed
const containerType = dto.containerType ?? existing.containerType;
const containerQuantity = dto.containerQuantity ?? existing.containerQuantity;
updates.allowConsolidation = this.resolveConsolidation(
containerType,
containerQuantity,
dto.allowConsolidation,
);
// Recalculate priority
const currency = dto.paymentCurrency ?? existing.paymentCurrency;
const serviceType = dto.serviceType ?? existing.serviceType;
updates.priorityScore = this.calculatePriorityScore(currency, serviceType);
// Overweight check
const vgm = dto.containerVgmPerUnit ?? existing.containerVgmPerUnit;
const direction = dto.tradeDirection ?? existing.tradeDirection;
const ow = this.checkOverweight(containerType, vgm, direction);
if (ow) warnings.push(ow);
// Merge documents - upload new files to MinIO
if (files.length > 0) {
const newDocs = await this.buildDocuments(id, files);
updates.documents = { ...(existing.documents ?? {}), ...newDocs };
}
const booking = await this.bookingsRepository.update(id, updates);
if (!booking) throw new NotFoundException(`Booking ${id} not found`);
return { booking, warnings };
}
/** Return a paginated list of bookings matching the filter. */
@@ -23,14 +222,31 @@ export class BookingsService {
): Promise<{ items: Booking[]; total: number }> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 20;
const where: Record<string, unknown> = {};
if (filter.status) where.status = filter.status;
if (filter.customerId) where.customerId = filter.customerId;
if (filter.contractType) where.contractType = filter.contractType;
if (filter.serviceType) where.serviceType = filter.serviceType;
if (filter.tradeDirection) where.tradeDirection = filter.tradeDirection;
if (filter.paymentCurrency) where.paymentCurrency = filter.paymentCurrency;
if (filter.freightType) where.freightType = filter.freightType;
if (filter.containerType) where.containerType = filter.containerType;
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: {
...(filter.status ? { status: filter.status } : {}),
...(filter.customerId ? { customerId: filter.customerId } : {}),
},
where,
skip: (page - 1) * pageSize,
take: pageSize,
order: { createdAt: "DESC" },
order: { [sortField]: sortDir },
});
return { items, total };
}
@@ -44,9 +260,286 @@ export class BookingsService {
return booking;
}
/** Soft-delete a booking. */
/** Find booking by reference. */
async findByReference(reference: string): Promise<Booking> {
const booking = await this.bookingsRepository.findByReference(reference);
if (!booking) {
throw new NotFoundException(`Booking with reference "${reference}" not found`);
}
return booking;
}
/** Soft-delete a booking (DRAFT only). */
async remove(id: string): Promise<void> {
await this.findById(id);
const booking = await this.findById(id);
if (booking.status !== "DRAFT") {
throw new BadRequestException("Only DRAFT bookings can be deleted");
}
await this.bookingsRepository.softDelete(id);
}
// ── status workflow ──────────────────────────────────────────────────
/** Unified status transition handler. */
async updateStatus(id: string, dto: UpdateStatusDto): Promise<Booking> {
const booking = await this.findById(id);
const { action, actorId, reason } = dto;
switch (action) {
case "SUBMIT":
return this.handleSubmit(booking);
case "APPROVE_STAFF":
return this.handleApproveStaff(booking, actorId);
case "APPROVE_DIRECTOR":
return this.handleApproveDirector(booking, actorId);
case "APPROVE_CEO":
return this.handleApproveCeo(booking, actorId);
case "REJECT":
return this.handleReject(booking, actorId, reason);
case "CANCEL":
return this.handleCancel(booking, actorId, reason);
case "ACTIVATE":
return this.handleActivate(booking);
case "EXPIRE":
return this.handleExpire(booking);
default:
throw new BadRequestException(`Unknown action: ${action}`);
}
}
/** SUBMIT: DRAFT → PENDING_LINE_STAFF or PENDING_DIRECTOR (bulk). */
private async handleSubmit(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["DRAFT"]);
const isBulk =
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS;
const nextStatus = isBulk ? "PENDING_DIRECTOR" : "PENDING_LINE_STAFF";
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,
} as never);
return updated!;
}
/** APPROVE_STAFF: PENDING_LINE_STAFF → APPROVED_PENDING_SIGNATURE. */
private async handleApproveStaff(
booking: Booking,
actorId?: string,
): Promise<Booking> {
this.assertStatus(booking, ["PENDING_LINE_STAFF"]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_STAFF");
// Line staff cannot approve bulk
if (
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS
) {
throw new BadRequestException(
"Line staff cannot approve bulk or high-volume bookings",
);
}
const updated = await this.bookingsRepository.update(booking.id, {
status: "APPROVED_PENDING_SIGNATURE",
approvedByStaffId: actorId,
approvedByStaffAt: new Date(),
} as never);
return updated!;
}
/** APPROVE_DIRECTOR: APPROVED_PENDING_SIGNATURE|PENDING_DIRECTOR → SIGNED or PENDING_CEO. */
private async handleApproveDirector(
booking: Booking,
actorId?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"APPROVED_PENDING_SIGNATURE",
"PENDING_DIRECTOR",
]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_DIRECTOR");
const isBulk =
booking.freightType === "BULK" ||
booking.cargoTotalWeightVgm > HIGH_VOLUME_THRESHOLD_TONS;
const nextStatus = isBulk ? "PENDING_CEO" : "SIGNED";
const updated = await this.bookingsRepository.update(booking.id, {
status: nextStatus,
signedByDirectorId: actorId,
signedByDirectorAt: new Date(),
} as never);
return updated!;
}
/** APPROVE_CEO: PENDING_CEO → SIGNED. */
private async handleApproveCeo(
booking: Booking,
actorId?: string,
): Promise<Booking> {
this.assertStatus(booking, ["PENDING_CEO"]);
if (!actorId)
throw new BadRequestException("actorId is required for APPROVE_CEO");
const updated = await this.bookingsRepository.update(booking.id, {
status: "SIGNED",
signedByCeoId: actorId,
signedByCeoAt: new Date(),
} as never);
return updated!;
}
/** REJECT: PENDING_* → CANCELLED. */
private async handleReject(
booking: Booking,
actorId?: string,
reason?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
]);
if (!actorId || !reason)
throw new BadRequestException("actorId and reason are required for REJECT");
const updated = await this.bookingsRepository.update(booking.id, {
status: "CANCELLED",
} as never);
return updated!;
}
/** CANCEL: DRAFT|PENDING_* → CANCELLED. */
private async handleCancel(
booking: Booking,
_actorId?: string,
reason?: string,
): Promise<Booking> {
this.assertStatus(booking, [
"DRAFT",
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
]);
if (!reason)
throw new BadRequestException("reason is required for CANCEL");
const updated = await this.bookingsRepository.update(booking.id, {
status: "CANCELLED",
} as never);
return updated!;
}
/** ACTIVATE: SIGNED → ACTIVE. */
private async handleActivate(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["SIGNED"]);
const updated = await this.bookingsRepository.update(booking.id, {
status: "ACTIVE",
startDate: booking.startDate ?? new Date(),
} as never);
return updated!;
}
/** EXPIRE: ACTIVE → EXPIRED. */
private async handleExpire(booking: Booking): Promise<Booking> {
this.assertStatus(booking, ["ACTIVE"]);
const updated = await this.bookingsRepository.update(booking.id, {
status: "EXPIRED",
endDate: new Date(),
} as never);
return updated!;
}
/** Guard: ensure current status is one of the allowed values. */
private assertStatus(booking: Booking, allowed: string[]): void {
if (!allowed.includes(booking.status)) {
throw new ConflictException(
`Cannot perform this action on a booking with status "${booking.status}". Allowed: ${allowed.join(", ")}`,
);
}
}
// ── consolidation ────────────────────────────────────────────────────
/** Request consolidation — auto-pair if a partner exists, else queue. */
async requestConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking | null;
paired: boolean;
}> {
const booking = await this.findById(id);
if (!booking.allowConsolidation) {
throw new BadRequestException("Booking is not eligible for consolidation");
}
if (booking.containerType !== "20FT") {
throw new BadRequestException("Only 20FT containers can be consolidated");
}
if (booking.containerQuantity % 2 === 0) {
throw new BadRequestException(
"Only odd-quantity 20FT bookings need consolidation",
);
}
if (booking.consolidationPartnerId) {
throw new ConflictException("Booking is already paired for consolidation");
}
const partner =
await this.bookingsRepository.findConsolidationPartner(booking);
if (partner) {
await this.bookingsRepository.pairConsolidation(booking.id, partner.id);
const updated = await this.findById(id);
const updatedPartner = await this.findById(partner.id);
return { booking: updated, partner: updatedPartner, paired: true };
}
// No partner found — enter queue
await this.bookingsRepository.update(booking.id, {
status: "PENDING_CONSOLIDATION",
} as never);
const updated = await this.findById(id);
return { booking: updated, partner: null, paired: false };
}
/** Remove consolidation pairing. */
async removeConsolidation(id: string): Promise<{
booking: Booking;
partner: Booking;
}> {
const booking = await this.findById(id);
if (!booking.consolidationPartnerId) {
throw new BadRequestException("Booking has no consolidation partner");
}
const partnerId = booking.consolidationPartnerId;
await this.bookingsRepository.unpairConsolidation(id, partnerId);
const updated = await this.findById(id);
const updatedPartner = await this.findById(partnerId);
return { booking: updated, partner: updatedPartner };
}
/** Get consolidation details for a booking. */
async getConsolidationDetails(id: string): Promise<{
booking: Booking;
partner: Booking | null;
splitBilling: { bookingShare: number; partnerShare: number } | null;
}> {
const booking = await this.findById(id);
if (!booking.consolidationPartnerId) {
return { booking, partner: null, splitBilling: null };
}
const partner = await this.findById(booking.consolidationPartnerId);
const splitBilling = {
bookingShare: booking.totalAmount,
partnerShare: partner.totalAmount,
};
return { booking, partner, splitBilling };
}
}

View File

@@ -1,7 +1,10 @@
import { Freight } from "@edr/types";
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { Transform } from "class-transformer";
import {
IsBoolean,
IsDateString,
IsEnum,
IsIn,
IsInt,
IsNumber,
IsOptional,
IsString,
@@ -9,25 +12,194 @@ import {
Min,
} from "class-validator";
const BOOKING_STATUSES = [
"DRAFT",
"PENDING_LINE_STAFF",
"PENDING_DIRECTOR",
"PENDING_CEO",
"APPROVED_PENDING_SIGNATURE",
"SIGNED",
"ACTIVE",
"EXPIRED",
"CANCELLED",
"PENDING_CONSOLIDATION",
"CONSOLIDATED",
] as const;
const CONTRACT_TYPES = ["NEW", "RENEWAL"] as const;
const SERVICE_TYPES = ["RAIL_ONLY", "RAIL_AND_FORWARDING"] as const;
const EQUIPMENT_RETURNS = ["WITH_RETURN", "WITHOUT_RETURN"] as const;
const FREIGHT_TYPES = ["BULK", "BREAK_BULK"] as const;
const TRADE_DIRECTIONS = ["IMPORT", "EXPORT"] as const;
const PAYMENT_CURRENCIES = ["ETB", "USD"] as const;
const PAYMENT_STATUSES = ["PENDING", "PAID", "OVERDUE", "CANCELLED", "REFUNDED"] as const;
const CONTAINER_TYPES = ["20FT", "40FT"] as const;
export {
BOOKING_STATUSES,
CONTRACT_TYPES,
SERVICE_TYPES,
EQUIPMENT_RETURNS,
FREIGHT_TYPES,
TRADE_DIRECTIONS,
PAYMENT_CURRENCIES,
PAYMENT_STATUSES,
CONTAINER_TYPES,
};
export class CreateBookingDto {
// ── core ─────────────────────────────────────────────────────────────
@ApiProperty({ description: "Unique booking reference" })
@IsString()
@Transform(({ value }) => (typeof value === "string" ? value.trim() : value))
reference!: string;
@ApiProperty({ format: "uuid" })
@IsUUID()
customerId!: string;
@ApiPropertyOptional({ format: "uuid" })
@IsOptional()
@IsUUID()
trainId?: string;
@ApiProperty({ example: "2026-06-15T00:00:00.000Z" })
@IsDateString()
scheduledDate!: string;
@ApiProperty({ minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
totalAmount!: number;
@ApiPropertyOptional({ enum: PAYMENT_STATUSES, default: "PENDING" })
@IsOptional()
@IsEnum(Freight.BookingStatus)
status?: Freight.BookingStatus;
@IsIn([...PAYMENT_STATUSES])
paymentStatus?: string;
// ── contract ─────────────────────────────────────────────────────────
@ApiProperty({ enum: CONTRACT_TYPES })
@IsIn([...CONTRACT_TYPES])
contractType!: string;
@ApiPropertyOptional({ format: "uuid", description: "For RENEWAL — previous contract/booking ID" })
@IsOptional()
@IsUUID()
@Transform(({ value }) => (value === "" || value == null ? undefined : value))
previousContractId?: string;
@ApiProperty({ enum: SERVICE_TYPES })
@IsIn([...SERVICE_TYPES])
serviceType!: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
firstMileEnabled?: boolean;
@ApiPropertyOptional({ description: "Required when firstMileEnabled is true" })
@IsOptional()
@IsString()
firstMilePickupAddress?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
lastMileEnabled?: boolean;
@ApiPropertyOptional({ description: "Required when lastMileEnabled is true" })
@IsOptional()
@IsString()
lastMileDeliveryAddress?: string;
@ApiProperty({ enum: EQUIPMENT_RETURNS })
@IsIn([...EQUIPMENT_RETURNS])
equipmentReturn!: string;
@ApiProperty({ description: "Origin station name or code" })
@IsString()
originStation!: string;
@ApiProperty({ description: "Destination station name or code" })
@IsString()
destinationStation!: string;
@ApiProperty({ description: "Total cargo weight in VGM tons", minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
cargoTotalWeightVgm!: number;
@ApiProperty({ enum: FREIGHT_TYPES })
@IsIn([...FREIGHT_TYPES])
freightType!: string;
@ApiPropertyOptional({ description: "Coffee, Beans, Machinery, Ro-Ro, etc." })
@IsOptional()
@IsString()
freightSubtype?: string;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isHazardous?: boolean;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
isRefrigerated?: boolean;
@ApiProperty({ enum: TRADE_DIRECTIONS })
@IsIn([...TRADE_DIRECTIONS])
tradeDirection!: string;
@ApiProperty({ enum: PAYMENT_CURRENCIES })
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency!: string;
@ApiPropertyOptional({ example: "2026-06-15" })
@IsOptional()
@IsDateString()
startDate?: string;
@ApiPropertyOptional({ example: "2027-06-15" })
@IsOptional()
@IsDateString()
endDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
financialTerms?: string;
// ── container ────────────────────────────────────────────────────────
@ApiProperty({ enum: CONTAINER_TYPES })
@IsIn([...CONTAINER_TYPES])
containerType!: string;
@ApiProperty({ minimum: 1 })
@IsInt()
@Min(1)
@Transform(({ value }) => Number(value))
containerQuantity!: number;
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
@IsNumber()
@Min(0)
@Transform(({ value }) => Number(value))
containerVgmPerUnit!: number;
@ApiPropertyOptional({
default: false,
description: "Auto-set to true when containerType=20FT and odd quantity. User may override.",
})
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
allowConsolidation?: boolean;
}

View File

@@ -1,22 +1,87 @@
import { Freight } from "@edr/types";
import { Type } from "class-transformer";
import { IsEnum, IsInt, IsOptional, IsUUID, Min } from "class-validator";
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class-validator";
import {
BOOKING_STATUSES,
CONTRACT_TYPES,
CONTAINER_TYPES,
FREIGHT_TYPES,
PAYMENT_CURRENCIES,
SERVICE_TYPES,
TRADE_DIRECTIONS,
} from "./create-booking.dto";
export class FilterBookingDto {
@ApiPropertyOptional({ enum: BOOKING_STATUSES })
@IsOptional()
@IsEnum(Freight.BookingStatus)
status?: Freight.BookingStatus;
@IsIn([...BOOKING_STATUSES])
status?: string;
@ApiPropertyOptional({ format: "uuid" })
@IsOptional()
@IsUUID()
customerId?: string;
@ApiPropertyOptional({ enum: CONTRACT_TYPES })
@IsOptional()
@IsIn([...CONTRACT_TYPES])
contractType?: string;
@ApiPropertyOptional({ enum: SERVICE_TYPES })
@IsOptional()
@IsIn([...SERVICE_TYPES])
serviceType?: string;
@ApiPropertyOptional({ enum: TRADE_DIRECTIONS })
@IsOptional()
@IsIn([...TRADE_DIRECTIONS])
tradeDirection?: string;
@ApiPropertyOptional({ enum: PAYMENT_CURRENCIES })
@IsOptional()
@IsIn([...PAYMENT_CURRENCIES])
paymentCurrency?: string;
@ApiPropertyOptional({ enum: FREIGHT_TYPES })
@IsOptional()
@IsIn([...FREIGHT_TYPES])
freightType?: string;
@ApiPropertyOptional({ enum: CONTAINER_TYPES })
@IsOptional()
@IsIn([...CONTAINER_TYPES])
containerType?: string;
@ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" })
@IsOptional()
@IsBoolean()
@Transform(({ value }) => value === "true" || value === true)
allowConsolidation?: boolean;
@ApiPropertyOptional({ description: "Filter by consolidation partner presence (true = paired, false = unpaired)" })
@IsOptional()
@IsString()
consolidationPaired?: string;
@ApiPropertyOptional({ enum: ["createdAt", "priorityScore"], default: "createdAt" })
@IsOptional()
@IsIn(["createdAt", "priorityScore"])
sortBy?: string;
@ApiPropertyOptional({ enum: ["ASC", "DESC"], default: "DESC" })
@IsOptional()
@IsIn(["ASC", "DESC"])
sortOrder?: "ASC" | "DESC";
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number = 1;
@ApiPropertyOptional({ default: 20, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/swagger";
import { CreateBookingDto } from "./create-booking.dto";
export class UpdateBookingDto extends PartialType(CreateBookingDto) {}

View File

@@ -0,0 +1,42 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsIn, IsOptional, IsString, IsUUID } from "class-validator";
const STATUS_ACTIONS = [
"SUBMIT",
"APPROVE_STAFF",
"APPROVE_DIRECTOR",
"APPROVE_CEO",
"REJECT",
"CANCEL",
"ACTIVATE",
"EXPIRE",
] as const;
export { STATUS_ACTIONS };
export class UpdateStatusDto {
@ApiProperty({
enum: STATUS_ACTIONS,
description:
"SUBMIT — send to approval queue | " +
"APPROVE_STAFF — line-staff approval | " +
"APPROVE_DIRECTOR — director approval / signature | " +
"APPROVE_CEO — CEO final signature | " +
"REJECT — reject at any pending stage | " +
"CANCEL — customer / admin cancellation | " +
"ACTIVATE — activate a signed booking | " +
"EXPIRE — mark an active booking as expired",
})
@IsIn([...STATUS_ACTIONS])
action!: string;
@ApiPropertyOptional({ format: "uuid", description: "Actor performing the action (staff/director/CEO)" })
@IsOptional()
@IsUUID()
actorId?: string;
@ApiPropertyOptional({ description: "Required for REJECT and CANCEL actions" })
@IsOptional()
@IsString()
reason?: string;
}

View File

@@ -1,9 +1,9 @@
import { BaseEntity } from "@edr/api-common";
import { Freight } from "@edr/types";
import { Column, Entity } from "typeorm";
@Entity({ name: "bookings" })
export class Booking extends BaseEntity {
// ── core ───────────────────────────────────────────────────────────────
@Column({ name: "reference", type: "varchar", length: 64, unique: true })
reference!: string;
@@ -13,13 +13,8 @@ export class Booking extends BaseEntity {
@Column({ name: "train_id", type: "uuid", nullable: true })
trainId?: string | null;
@Column({
name: "status",
type: "enum",
enum: Freight.BookingStatus,
default: Freight.BookingStatus.Draft,
})
status!: Freight.BookingStatus;
@Column({ name: "status", type: "varchar", length: 40, default: "DRAFT" })
status!: string;
@Column({ name: "scheduled_date", type: "timestamptz" })
scheduledDate!: Date;
@@ -35,9 +30,126 @@ export class Booking extends BaseEntity {
@Column({
name: "payment_status",
type: "enum",
enum: Freight.PaymentStatus,
default: Freight.PaymentStatus.Pending,
type: "varchar",
length: 20,
default: "PENDING",
})
paymentStatus!: Freight.PaymentStatus;
paymentStatus!: string;
// ── contract ───────────────────────────────────────────────────────────
@Column({ name: "contract_type", type: "varchar", length: 20 })
contractType!: string;
@Column({ name: "previous_contract_id", type: "uuid", nullable: true })
previousContractId?: string | null;
@Column({ name: "service_type", type: "varchar", length: 30 })
serviceType!: string;
@Column({ name: "first_mile_enabled", type: "boolean", default: false })
firstMileEnabled!: boolean;
@Column({ name: "first_mile_pickup_address", type: "text", nullable: true })
firstMilePickupAddress?: string | null;
@Column({ name: "last_mile_enabled", type: "boolean", default: false })
lastMileEnabled!: boolean;
@Column({ name: "last_mile_delivery_address", type: "text", nullable: true })
lastMileDeliveryAddress?: string | null;
@Column({ name: "equipment_return", type: "varchar", length: 20 })
equipmentReturn!: string;
@Column({ name: "origin_station", type: "varchar", length: 255 })
originStation!: string;
@Column({ name: "destination_station", type: "varchar", length: 255 })
destinationStation!: string;
@Column({
name: "cargo_total_weight_vgm",
type: "numeric",
precision: 12,
scale: 3,
})
cargoTotalWeightVgm!: number;
@Column({ name: "freight_type", type: "varchar", length: 20 })
freightType!: string;
@Column({ name: "freight_subtype", type: "varchar", length: 100, nullable: true })
freightSubtype?: string | null;
@Column({ name: "is_hazardous", type: "boolean", default: false })
isHazardous!: boolean;
@Column({ name: "is_refrigerated", type: "boolean", default: false })
isRefrigerated!: boolean;
@Column({ name: "trade_direction", type: "varchar", length: 10 })
tradeDirection!: string;
@Column({ name: "payment_currency", type: "varchar", length: 5 })
paymentCurrency!: string;
@Column({ name: "start_date", type: "date", nullable: true })
startDate?: Date | null;
@Column({ name: "end_date", type: "date", nullable: true })
endDate?: Date | null;
@Column({ name: "financial_terms", type: "text", nullable: true })
financialTerms?: string | null;
@Column({ name: "version_number", type: "int", default: 1 })
versionNumber!: number;
// ── container ──────────────────────────────────────────────────────────
@Column({ name: "container_type", type: "varchar", length: 10 })
containerType!: string;
@Column({ name: "container_quantity", type: "int" })
containerQuantity!: number;
@Column({
name: "container_vgm_per_unit",
type: "numeric",
precision: 10,
scale: 3,
})
containerVgmPerUnit!: number;
// ── approval ───────────────────────────────────────────────────────────
@Column({ name: "approved_by_staff_id", type: "uuid", nullable: true })
approvedByStaffId?: string | null;
@Column({ name: "approved_by_staff_at", type: "timestamptz", nullable: true })
approvedByStaffAt?: Date | null;
@Column({ name: "signed_by_director_id", type: "uuid", nullable: true })
signedByDirectorId?: string | null;
@Column({ name: "signed_by_director_at", type: "timestamptz", nullable: true })
signedByDirectorAt?: Date | null;
@Column({ name: "signed_by_ceo_id", type: "uuid", nullable: true })
signedByCeoId?: string | null;
@Column({ name: "signed_by_ceo_at", type: "timestamptz", nullable: true })
signedByCeoAt?: Date | null;
@Column({ name: "priority_score", type: "int", default: 0 })
priorityScore!: number;
// ── consolidation ──────────────────────────────────────────────────────
@Column({ name: "allow_consolidation", type: "boolean", default: false })
allowConsolidation!: boolean;
@Column({ name: "consolidation_partner_id", type: "uuid", nullable: true })
consolidationPartnerId?: string | null;
// ── documents (JSONB) ──────────────────────────────────────────────────
@Column({ name: "documents", type: "jsonb", nullable: true })
documents?: Record<string, { originalName: string; size: number; mimeType: string; url?: string }> | null;
}

View File

@@ -0,0 +1,2 @@
export * from "./minio.module";
export * from "./minio.service";

View File

@@ -0,0 +1,10 @@
import { registerAs } from "@nestjs/config";
export const minioConfig = registerAs("minio", () => ({
endPoint: process.env.MINIO_ENDPOINT || "minio-dev.smart.aaca.gov.et",
port: parseInt(process.env.MINIO_PORT || "443", 10),
useSSL: process.env.MINIO_USE_SSL !== "false",
accessKey: process.env.MINIO_ACCESS_KEY || "",
secretKey: process.env.MINIO_SECRET_KEY || "",
bucket: process.env.MINIO_BUCKET || "fhc",
}));

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { minioConfig } from "./minio.config";
import { MinioService } from "./minio.service";
@Module({
imports: [ConfigModule.forFeature(minioConfig)],
providers: [MinioService],
exports: [MinioService],
})
export class MinioModule {}

View File

@@ -0,0 +1,65 @@
import { Inject, Injectable, Logger } from "@nestjs/common";
import { ConfigType } from "@nestjs/config";
import { Client } from "minio";
import { minioConfig } from "./minio.config";
@Injectable()
export class MinioService {
private readonly client: Client;
private readonly logger = new Logger(MinioService.name);
private readonly bucket: string;
constructor(
@Inject(minioConfig.KEY)
private readonly config: ConfigType<typeof minioConfig>,
) {
console.log('[MinioService] Configuration loaded:', {
endPoint: config.endPoint,
port: config.port,
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey ? '***HIDDEN***' : 'EMPTY',
bucket: config.bucket,
});
this.bucket = config.bucket;
this.client = new Client({
endPoint: config.endPoint,
port: config.port,
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
});
}
async uploadFile(
objectName: string,
buffer: Buffer,
contentType: string,
): Promise<string> {
try {
await this.client.putObject(this.bucket, objectName, buffer, buffer.length, {
"Content-Type": contentType,
});
this.logger.log(`File uploaded successfully: ${objectName}`);
return this.getPublicUrl(objectName);
} catch (error) {
this.logger.error(`Failed to upload file ${objectName}:`, error);
throw error;
}
}
getPublicUrl(objectName: string): string {
const protocol = this.config.useSSL ? "https" : "http";
return `${protocol}://${this.config.endPoint}:${this.config.port}/${this.bucket}/${objectName}`;
}
async deleteFile(objectName: string): Promise<void> {
try {
await this.client.removeObject(this.bucket, objectName);
this.logger.log(`File deleted successfully: ${objectName}`);
} catch (error) {
this.logger.error(`Failed to delete file ${objectName}:`, error);
throw error;
}
}
}

View File

@@ -0,0 +1,45 @@
const { Client } = require('minio');
const config = {
endPoint: 'minio-dev.smart.aaca.gov.et',
port: 443,
useSSL: true,
accessKey: 'f2f22b0ea929cebd5567ed0c71ec351b',
secretKey: 'xxHnjRsb90suQZZdOtEcXJXls4nj0A2anMetb1kY',
bucket: 'fhc',
};
const filePath = '/home/marshal/Desktop/EDR/bash/download.jpeg';
const objectName = `test-upload-${Date.now()}.jpeg`;
console.log('Testing MinIO upload...');
console.log('Endpoint:', `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}`);
console.log('Bucket:', config.bucket);
console.log('File:', filePath);
console.log('Object:', objectName);
console.log('');
const client = new Client(config);
const fs = require('fs');
try {
const fileBuffer = fs.readFileSync(filePath);
console.log('File size:', fileBuffer.length, 'bytes');
client.putObject(config.bucket, objectName, fileBuffer, fileBuffer.length, { 'Content-Type': 'image/jpeg' })
.then(() => {
const url = `${config.useSSL ? 'https' : 'http'}://${config.endPoint}:${config.port}/${config.bucket}/${objectName}`;
console.log('✓ Upload successful!');
console.log('URL:', url);
})
.catch(err => {
console.error('✗ Upload failed:', err.message);
if (err.code === 'InvalidAccessKeyId') {
console.error('The access key does not exist on the MinIO server.');
console.error('Contact your MinIO administrator for valid credentials.');
}
});
} catch (err) {
console.error('Error reading file:', err.message);
}

13
pnpm-lock.yaml generated
View File

@@ -86,6 +86,9 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
minio:
specifier: 7.1.3
version: 7.1.3
pg:
specifier: ^8.13.0
version: 8.20.0
@@ -123,6 +126,9 @@ importers:
'@types/jest':
specifier: ^29.5.13
version: 29.5.14
'@types/multer':
specifier: ^2.1.0
version: 2.1.0
'@types/node':
specifier: ^20.14.0
version: 20.19.41
@@ -3807,6 +3813,9 @@ packages:
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
'@types/multer@2.1.0':
resolution: {integrity: sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==}
'@types/node@14.18.63':
resolution: {integrity: sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==}
@@ -13829,6 +13838,10 @@ snapshots:
'@types/ms@2.1.0': {}
'@types/multer@2.1.0':
dependencies:
'@types/express': 5.0.6
'@types/node@14.18.63': {}
'@types/node@20.19.41':