mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
implement contract booking functionality and MinIO setup
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
|
||||
import { CreateBookingDto } from "./create-booking.dto";
|
||||
|
||||
export class UpdateBookingDto extends PartialType(CreateBookingDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
2
apps/edr-freight-api/src/modules/minio/index.ts
Normal file
2
apps/edr-freight-api/src/modules/minio/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./minio.module";
|
||||
export * from "./minio.service";
|
||||
10
apps/edr-freight-api/src/modules/minio/minio.config.ts
Normal file
10
apps/edr-freight-api/src/modules/minio/minio.config.ts
Normal 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",
|
||||
}));
|
||||
11
apps/edr-freight-api/src/modules/minio/minio.module.ts
Normal file
11
apps/edr-freight-api/src/modules/minio/minio.module.ts
Normal 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 {}
|
||||
65
apps/edr-freight-api/src/modules/minio/minio.service.ts
Normal file
65
apps/edr-freight-api/src/modules/minio/minio.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
45
apps/edr-freight-api/test-minio-upload.js
Normal file
45
apps/edr-freight-api/test-minio-upload.js
Normal 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
13
pnpm-lock.yaml
generated
@@ -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
|
||||
@@ -3781,6 +3787,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==}
|
||||
|
||||
@@ -15183,6 +15192,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':
|
||||
|
||||
Reference in New Issue
Block a user