mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 13:40:57 +00:00
Merge branch 'freight/develop' into freight/feature/bootstrap_backoffice
This commit is contained in:
@@ -13,7 +13,13 @@ import {
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { AnyFilesInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
|
||||
import { BookingsService } from "./bookings.service";
|
||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||
@@ -23,8 +29,9 @@ import { UpdateStatusDto } from "./dto/update-status.dto";
|
||||
|
||||
@ApiTags("bookings")
|
||||
@Controller("bookings")
|
||||
@ApiBearerAuth()
|
||||
export class BookingsController {
|
||||
constructor(private readonly bookingsService: BookingsService) {}
|
||||
constructor(private readonly bookingsService: BookingsService) { }
|
||||
|
||||
// ── 1. Create booking (multipart/form-data) ──────────────────────────
|
||||
@Post()
|
||||
@@ -39,14 +46,23 @@ export class BookingsController {
|
||||
@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.",
|
||||
"Each uploaded file is saved as a row in the files table (resource=bookings).",
|
||||
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 })));
|
||||
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 ?? []);
|
||||
}
|
||||
|
||||
@@ -56,7 +72,8 @@ export class BookingsController {
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({
|
||||
summary: "Update a draft booking",
|
||||
description: "Only DRAFT bookings can be updated. New files are merged into existing documents.",
|
||||
description:
|
||||
"Only DRAFT bookings can be updated. New files are merged into existing documents.",
|
||||
})
|
||||
@ApiBody({ type: UpdateBookingDto })
|
||||
update(
|
||||
@@ -141,7 +158,8 @@ export class BookingsController {
|
||||
@Delete(":id/consolidation")
|
||||
@ApiOperation({
|
||||
summary: "Remove consolidation pairing",
|
||||
description: "Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
|
||||
description:
|
||||
"Unpairs both bookings and returns them to PENDING_CONSOLIDATION status.",
|
||||
})
|
||||
removeConsolidation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.removeConsolidation(id);
|
||||
@@ -151,9 +169,11 @@ export class BookingsController {
|
||||
@Get(":id/consolidation")
|
||||
@ApiOperation({
|
||||
summary: "Get consolidation details",
|
||||
description: "Returns partner booking details and split billing information.",
|
||||
description:
|
||||
"Returns partner booking details and split billing information.",
|
||||
})
|
||||
getConsolidationDetails(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.bookingsService.getConsolidationDetails(id);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { FilesModule } from "../files/files.module";
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { BookingsController } from "./bookings.controller";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
@@ -8,7 +9,7 @@ import { BookingsService } from "./bookings.service";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Booking]), MinioModule],
|
||||
imports: [TypeOrmModule.forFeature([Booking]), FilesModule, MinioModule],
|
||||
controllers: [BookingsController],
|
||||
providers: [BookingsService, BookingsRepository],
|
||||
exports: [BookingsService],
|
||||
|
||||
@@ -4,6 +4,7 @@ import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { In, IsNull, Not, Repository } from "typeorm";
|
||||
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
|
||||
@Injectable()
|
||||
export class BookingsRepository extends BaseRepository<Booking> {
|
||||
@@ -19,12 +20,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
return this.repository.findOne({ where: { reference } });
|
||||
}
|
||||
|
||||
/** Find a booking by reference with associated files (polymorphic join). */
|
||||
async findByReferenceWithFiles(reference: string): Promise<Booking | null> {
|
||||
const booking = await this.repository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.reference = :reference", { reference })
|
||||
.leftJoinAndMapMany(
|
||||
"booking.files",
|
||||
FileRecord,
|
||||
"file",
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings'"
|
||||
)
|
||||
.getOne();
|
||||
return booking ?? null;
|
||||
}
|
||||
|
||||
/** Find a booking by ID with associated files (polymorphic join). */
|
||||
async findByIdWithFiles(id: string): Promise<Booking | null> {
|
||||
const booking = await this.repository
|
||||
.createQueryBuilder("booking")
|
||||
.where("booking.id = :id", { id })
|
||||
.leftJoinAndMapMany(
|
||||
"booking.files",
|
||||
FileRecord,
|
||||
"file",
|
||||
"file.resource_id = booking.id AND file.resource = 'bookings'"
|
||||
)
|
||||
.getOne();
|
||||
return booking ?? null;
|
||||
}
|
||||
|
||||
/** 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",
|
||||
// Check if containers JSONB contains at least one 20FT entry with odd qty
|
||||
containers: Not(IsNull()),
|
||||
originStation: booking.originStation,
|
||||
destinationStation: booking.destinationStation,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { IsNull, Not } from "typeorm";
|
||||
|
||||
import { FilesService } from "../files/files.service";
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import { CreateBookingDto } from "./dto/create-booking.dto";
|
||||
@@ -13,6 +14,7 @@ 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";
|
||||
import { FileRecord } from "../files/entities/file.entity";
|
||||
|
||||
/** Weight thresholds (tons) that trigger overweight surcharge alerts. */
|
||||
const WEIGHT_LIMITS = {
|
||||
@@ -28,6 +30,7 @@ const HIGH_VOLUME_THRESHOLD_TONS = 500;
|
||||
export class BookingsService {
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly minioService: MinioService,
|
||||
) {}
|
||||
|
||||
@@ -35,12 +38,16 @@ export class BookingsService {
|
||||
|
||||
/** Resolve auto-consolidation flag. */
|
||||
private resolveConsolidation(
|
||||
containerType: string,
|
||||
containerQuantity: number,
|
||||
containers: Array<{ type: string; qty: number }> | undefined | null,
|
||||
explicit?: boolean,
|
||||
): boolean {
|
||||
if (explicit === false) return false;
|
||||
if (containerType === "20FT" && containerQuantity % 2 !== 0) return true;
|
||||
if (!containers || containers.length === 0) return explicit ?? false;
|
||||
// Auto-enable if any 20FT container has odd quantity
|
||||
const needsConsolidation = containers.some(
|
||||
(c) => c.type === "20FT" && c.qty % 2 !== 0
|
||||
);
|
||||
if (needsConsolidation) return true;
|
||||
return explicit ?? false;
|
||||
}
|
||||
|
||||
@@ -55,57 +62,44 @@ export class BookingsService {
|
||||
|
||||
/** Calculate required wagons: each 40ft = 1 wagon, each pair of 20ft = 1 wagon. */
|
||||
private calculateWagonCount(
|
||||
containerType: string,
|
||||
containerQuantity: number,
|
||||
containers: Array<{ type: string; qty: number }>,
|
||||
): number {
|
||||
if (containerType === "40FT") return containerQuantity;
|
||||
return Math.ceil(containerQuantity / 2);
|
||||
return containers.reduce((total, container) => {
|
||||
if (container.type === "40FT") {
|
||||
return total + container.qty;
|
||||
}
|
||||
// 20FT: 1 wagon per 2 containers (rounded up)
|
||||
return total + Math.ceil(container.qty / 2);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/** Check per-container weight limit and return a warning if exceeded. */
|
||||
/** Check per-container weight limits and return warnings if exceeded. */
|
||||
private checkOverweight(
|
||||
containerType: string,
|
||||
vgmPerUnit: number,
|
||||
containers: Array<{ type: string; vgm: 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`;
|
||||
): string[] {
|
||||
const warnings: string[] = [];
|
||||
for (const container of containers) {
|
||||
if (container.type === "40FT" && container.vgm > WEIGHT_LIMITS.ANY_40FT) {
|
||||
warnings.push(
|
||||
`40FT container VGM ${container.vgm}t exceeds limit of ${WEIGHT_LIMITS.ANY_40FT}t`
|
||||
);
|
||||
}
|
||||
if (container.type === "20FT") {
|
||||
const limit =
|
||||
tradeDirection === "IMPORT"
|
||||
? WEIGHT_LIMITS.IMPORT_20FT
|
||||
: WEIGHT_LIMITS.EXPORT_20FT;
|
||||
if (container.vgm > limit) {
|
||||
warnings.push(
|
||||
`20FT ${tradeDirection} container VGM ${container.vgm}t exceeds limit of ${limit}t`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/** 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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -117,8 +111,7 @@ export class BookingsService {
|
||||
const warnings: string[] = [];
|
||||
|
||||
const allowConsolidation = this.resolveConsolidation(
|
||||
dto.containerType,
|
||||
dto.containerQuantity,
|
||||
dto.containers,
|
||||
dto.allowConsolidation,
|
||||
);
|
||||
|
||||
@@ -127,17 +120,13 @@ export class BookingsService {
|
||||
dto.serviceType,
|
||||
);
|
||||
|
||||
const overweightWarning = this.checkOverweight(
|
||||
dto.containerType,
|
||||
dto.containerVgmPerUnit,
|
||||
const overweightWarnings = this.checkOverweight(
|
||||
dto.containers,
|
||||
dto.tradeDirection,
|
||||
);
|
||||
if (overweightWarning) warnings.push(overweightWarning);
|
||||
warnings.push(...overweightWarnings);
|
||||
|
||||
const wagonCount = this.calculateWagonCount(
|
||||
dto.containerType,
|
||||
dto.containerQuantity,
|
||||
);
|
||||
const wagonCount = this.calculateWagonCount(dto.containers);
|
||||
warnings.push(`Estimated wagons required: ${wagonCount}`);
|
||||
|
||||
const booking = await this.bookingsRepository.create({
|
||||
@@ -148,15 +137,11 @@ export class BookingsService {
|
||||
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;
|
||||
await this.filesService.uploadMany(booking.id, "bookings", files);
|
||||
} catch (err) {
|
||||
console.error('[BookingsService] File upload failed, booking still created:', err);
|
||||
warnings.push('File upload failed — booking was created without attached files.');
|
||||
@@ -184,12 +169,10 @@ export class BookingsService {
|
||||
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;
|
||||
// Re-evaluate consolidation if containers changed
|
||||
const containers = dto.containers ?? existing.containers ?? [];
|
||||
updates.allowConsolidation = this.resolveConsolidation(
|
||||
containerType,
|
||||
containerQuantity,
|
||||
containers,
|
||||
dto.allowConsolidation,
|
||||
);
|
||||
|
||||
@@ -199,15 +182,12 @@ export class BookingsService {
|
||||
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);
|
||||
const overweightWarnings = this.checkOverweight(containers, direction);
|
||||
warnings.push(...overweightWarnings);
|
||||
|
||||
// Merge documents - upload new files to MinIO
|
||||
if (files.length > 0) {
|
||||
const newDocs = await this.buildDocuments(id, files);
|
||||
updates.documents = { ...(existing.documents ?? {}), ...newDocs };
|
||||
await this.filesService.uploadMany(id, "bookings", files);
|
||||
}
|
||||
|
||||
const booking = await this.bookingsRepository.update(id, updates);
|
||||
@@ -231,7 +211,6 @@ export class BookingsService {
|
||||
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")
|
||||
@@ -251,21 +230,51 @@ export class BookingsService {
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/** Get a single booking by ID, throwing if not found. */
|
||||
/** Get a single booking by ID with files, throwing if not found. */
|
||||
async findById(id: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findById(id);
|
||||
const booking = await this.bookingsRepository.findByIdWithFiles(id);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking ${id} not found`);
|
||||
}
|
||||
|
||||
// Add signed URLs for files (5-minute expiration)
|
||||
if (booking.files && booking.files.length > 0) {
|
||||
booking.files = await Promise.all(
|
||||
booking.files.map(async (file: FileRecord) => {
|
||||
const objectName = this.extractObjectName(file.url);
|
||||
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||
return { ...file, signedUrl };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
/** Find booking by reference. */
|
||||
/** Extract object name from Minio URL. */
|
||||
private extractObjectName(url: string): string {
|
||||
const parts = url.split("/");
|
||||
return parts.slice(4).join("/");
|
||||
}
|
||||
|
||||
/** Find booking by reference with files. */
|
||||
async findByReference(reference: string): Promise<Booking> {
|
||||
const booking = await this.bookingsRepository.findByReference(reference);
|
||||
const booking = await this.bookingsRepository.findByReferenceWithFiles(reference);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with reference "${reference}" not found`);
|
||||
}
|
||||
|
||||
// Add signed URLs for files (5-minute expiration)
|
||||
if (booking.files && booking.files.length > 0) {
|
||||
booking.files = await Promise.all(
|
||||
booking.files.map(async (file: FileRecord) => {
|
||||
const objectName = this.extractObjectName(file.url);
|
||||
const signedUrl = await this.minioService.getSignedUrl(objectName, 300);
|
||||
return { ...file, signedUrl };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
@@ -474,14 +483,18 @@ export class BookingsService {
|
||||
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) {
|
||||
|
||||
// Check if any 20FT container has odd quantity
|
||||
const hasOdd20FT = booking.containers?.some(
|
||||
(c) => c.type === "20FT" && c.qty % 2 !== 0
|
||||
) ?? false;
|
||||
|
||||
if (!hasOdd20FT) {
|
||||
throw new BadRequestException(
|
||||
"Only odd-quantity 20FT bookings need consolidation",
|
||||
"Only bookings with odd-quantity 20FT containers need consolidation",
|
||||
);
|
||||
}
|
||||
|
||||
if (booking.consolidationPartnerId) {
|
||||
throw new ConflictException("Booking is already paired for consolidation");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Transform } from "class-transformer";
|
||||
import { Transform, Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
IsString,
|
||||
IsUUID,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
const BOOKING_STATUSES = [
|
||||
@@ -47,6 +49,24 @@ export {
|
||||
CONTAINER_TYPES,
|
||||
};
|
||||
|
||||
export class ContainerItem {
|
||||
@ApiProperty({ enum: CONTAINER_TYPES, description: "Container type (20FT or 40FT)" })
|
||||
@IsIn([...CONTAINER_TYPES])
|
||||
type!: string;
|
||||
|
||||
@ApiProperty({ description: "Quantity of containers", minimum: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Transform(({ value }) => Number(value))
|
||||
qty!: number;
|
||||
|
||||
@ApiProperty({ description: "VGM per container in tons", minimum: 0 })
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Transform(({ value }) => Number(value))
|
||||
vgm!: number;
|
||||
}
|
||||
|
||||
export class CreateBookingDto {
|
||||
// ── core ─────────────────────────────────────────────────────────────
|
||||
@ApiProperty({ description: "Unique booking reference" })
|
||||
@@ -177,26 +197,16 @@ export class CreateBookingDto {
|
||||
@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;
|
||||
// ── containers ────────────────────────────────────────────────────────
|
||||
@ApiProperty({ type: [ContainerItem], description: "Array of container specifications" })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ContainerItem)
|
||||
containers!: ContainerItem[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: "Auto-set to true when containerType=20FT and odd quantity. User may override.",
|
||||
description: "Auto-set to true when any 20FT container has odd quantity. User may override.",
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Min } from "class
|
||||
import {
|
||||
BOOKING_STATUSES,
|
||||
CONTRACT_TYPES,
|
||||
CONTAINER_TYPES,
|
||||
FREIGHT_TYPES,
|
||||
PAYMENT_CURRENCIES,
|
||||
SERVICE_TYPES,
|
||||
@@ -48,11 +47,6 @@ export class FilterBookingDto {
|
||||
@IsIn([...FREIGHT_TYPES])
|
||||
freightType?: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: CONTAINER_TYPES })
|
||||
@IsOptional()
|
||||
@IsIn([...CONTAINER_TYPES])
|
||||
containerType?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: "Filter consolidation-eligible bookings" })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
import { Column, Entity, OneToMany } from "typeorm";
|
||||
import { FileRecord } from "../../files/entities/file.entity";
|
||||
|
||||
@Entity({ schema:"freight",name: "bookings" })
|
||||
export class Booking extends BaseEntity {
|
||||
@@ -105,20 +106,9 @@ export class Booking extends BaseEntity {
|
||||
@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;
|
||||
// ── containers ─────────────────────────────────────────────────────────
|
||||
@Column({ name: "containers", type: "jsonb", nullable: true })
|
||||
containers!: Array<{ type: string; qty: number; vgm: number }> | null;
|
||||
|
||||
// ── approval ───────────────────────────────────────────────────────────
|
||||
@Column({ name: "approved_by_staff_id", type: "uuid", nullable: true })
|
||||
@@ -149,7 +139,10 @@ export class Booking extends BaseEntity {
|
||||
@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;
|
||||
// ── files ────────────────────────────────────────────────────────────
|
||||
@OneToMany(() => FileRecord, (file) => file.resourceId, {
|
||||
createForeignKeyConstraints: false,
|
||||
})
|
||||
files?: FileRecord[];
|
||||
|
||||
}
|
||||
|
||||
@@ -16,6 +16,71 @@ import {
|
||||
IDropdownSettingsRepository,
|
||||
} from "./interfaces/dropdown-settings.repository.interface";
|
||||
|
||||
const STATIONS_TER_CODE = "stations_ter";
|
||||
|
||||
const DEFAULT_STATION_OPTIONS: CreateDropdownOptionDto[] = [
|
||||
{
|
||||
value: "inside_addis_ababa",
|
||||
label: "Addis Ababa",
|
||||
note: "Inside country",
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
value: "inside_adama",
|
||||
label: "Adama",
|
||||
note: "Inside country",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
value: "inside_mojo",
|
||||
label: "Mojo",
|
||||
note: "Inside country",
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
value: "inside_awash",
|
||||
label: "Awash",
|
||||
note: "Inside country",
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
value: "inside_mieso",
|
||||
label: "Mieso",
|
||||
note: "Inside country",
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
value: "inside_dire_dawa",
|
||||
label: "Dire Dawa",
|
||||
note: "Inside country",
|
||||
order: 6,
|
||||
},
|
||||
{
|
||||
value: "outside_ali_sabieh",
|
||||
label: "Ali Sabieh",
|
||||
note: "Outside country",
|
||||
order: 7,
|
||||
},
|
||||
{
|
||||
value: "outside_holhol",
|
||||
label: "Holhol",
|
||||
note: "Outside country",
|
||||
order: 8,
|
||||
},
|
||||
{
|
||||
value: "outside_djibouti_city",
|
||||
label: "Djibouti City",
|
||||
note: "Outside country",
|
||||
order: 9,
|
||||
},
|
||||
{
|
||||
value: "outside_doraleh_terminal",
|
||||
label: "Doraleh Terminal",
|
||||
note: "Outside country",
|
||||
order: 10,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class DropdownSettingsService {
|
||||
constructor(
|
||||
@@ -62,6 +127,34 @@ export class DropdownSettingsService {
|
||||
return this.getById(setting.id);
|
||||
}
|
||||
|
||||
async seedDefaultStations(): Promise<void> {
|
||||
const existing = await this.repository.findByCode(STATIONS_TER_CODE);
|
||||
|
||||
if (!existing) {
|
||||
await this.create({
|
||||
code: STATIONS_TER_CODE,
|
||||
label: "Stations TER",
|
||||
description:
|
||||
"Temporary freight station list used by booking origin and destination yards.",
|
||||
multiple: false,
|
||||
meta: {
|
||||
searchable: true,
|
||||
clearable: true,
|
||||
version: "temporary",
|
||||
},
|
||||
children: DEFAULT_STATION_OPTIONS,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if ((existing.children?.length ?? 0) === 0) {
|
||||
await this.repository.replaceOptions(
|
||||
existing.id,
|
||||
DEFAULT_STATION_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateDropdownSettingDto,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity } from "typeorm";
|
||||
|
||||
@Entity({ schema: "freight", name: "files" })
|
||||
export class FileRecord extends BaseEntity {
|
||||
@Column({ name: "resource_id", type: "uuid" })
|
||||
resourceId!: string;
|
||||
|
||||
@Column({ name: "resource", type: "varchar", length: 100 })
|
||||
resource!: string;
|
||||
|
||||
@Column({ name: "code", type: "varchar", length: 100 })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: "name", type: "varchar", length: 500 })
|
||||
name!: string;
|
||||
|
||||
@Column({ name: "url", type: "text" })
|
||||
url!: string;
|
||||
|
||||
@Column({ name: "size", type: "integer" })
|
||||
size!: number;
|
||||
|
||||
@Column({ name: "mime_type", type: "varchar", length: 255 })
|
||||
mimeType!: string;
|
||||
}
|
||||
28
apps/edr-freight-api/src/modules/files/files.controller.ts
Normal file
28
apps/edr-freight-api/src/modules/files/files.controller.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Controller, Get, Param, ParseUUIDPipe, Res } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import { Response } from "express";
|
||||
|
||||
import { FilesService } from "./files.service";
|
||||
|
||||
@ApiTags("files")
|
||||
@Controller("files")
|
||||
export class FilesController {
|
||||
constructor(private readonly filesService: FilesService) {}
|
||||
|
||||
@Get(":fileId")
|
||||
@ApiOperation({
|
||||
summary: "Download a file by ID",
|
||||
description:
|
||||
"Global endpoint — streams any uploaded file directly from MinIO by its UUID. " +
|
||||
"No resource context (e.g. booking ID) required.",
|
||||
})
|
||||
async download(
|
||||
@Param("fileId", ParseUUIDPipe) fileId: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, record } = await this.filesService.streamById(fileId);
|
||||
res.setHeader("Content-Type", record.mimeType);
|
||||
res.setHeader("Content-Disposition", `attachment; filename="${record.name}"`);
|
||||
stream.pipe(res);
|
||||
}
|
||||
}
|
||||
16
apps/edr-freight-api/src/modules/files/files.module.ts
Normal file
16
apps/edr-freight-api/src/modules/files/files.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { FilesController } from "./files.controller";
|
||||
import { FilesRepository } from "./files.repository";
|
||||
import { FilesService } from "./files.service";
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FileRecord]), MinioModule],
|
||||
controllers: [FilesController],
|
||||
providers: [FilesService, FilesRepository],
|
||||
exports: [FilesService],
|
||||
})
|
||||
export class FilesModule {}
|
||||
28
apps/edr-freight-api/src/modules/files/files.repository.ts
Normal file
28
apps/edr-freight-api/src/modules/files/files.repository.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
|
||||
@Injectable()
|
||||
export class FilesRepository extends BaseRepository<FileRecord> {
|
||||
constructor(
|
||||
@InjectRepository(FileRecord)
|
||||
repository: Repository<FileRecord>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||
return this.repository.find({ where: { resourceId, resource } });
|
||||
}
|
||||
|
||||
findByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<FileRecord | null> {
|
||||
return this.repository.findOne({ where: { resourceId, resource, code } });
|
||||
}
|
||||
}
|
||||
84
apps/edr-freight-api/src/modules/files/files.service.ts
Normal file
84
apps/edr-freight-api/src/modules/files/files.service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { Readable } from "stream";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { FilesRepository } from "./files.repository";
|
||||
import { FileRecord } from "./entities/file.entity";
|
||||
|
||||
export interface CreateFileInput {
|
||||
resourceId: string;
|
||||
resource: string;
|
||||
code: string;
|
||||
file: Express.Multer.File;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
private readonly filesRepository: FilesRepository,
|
||||
private readonly minioService: MinioService,
|
||||
) {}
|
||||
|
||||
async upload(input: CreateFileInput): Promise<FileRecord> {
|
||||
const { resourceId, resource, code, file } = input;
|
||||
const objectName = `${resource}/${resourceId}/${Date.now()}_${file.originalname}`;
|
||||
const url = await this.minioService.uploadFile(objectName, file.buffer, file.mimetype);
|
||||
|
||||
return this.filesRepository.create({
|
||||
resourceId,
|
||||
resource,
|
||||
code,
|
||||
name: file.originalname,
|
||||
url,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
}
|
||||
|
||||
async uploadMany(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
files: Express.Multer.File[],
|
||||
): Promise<FileRecord[]> {
|
||||
return Promise.all(
|
||||
files.map((file) =>
|
||||
this.upload({ resourceId, resource, code: file.fieldname, file }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findById(id);
|
||||
if (!record) throw new NotFoundException(`File ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
|
||||
findByResource(resourceId: string, resource: string): Promise<FileRecord[]> {
|
||||
return this.filesRepository.findByResource(resourceId, resource);
|
||||
}
|
||||
|
||||
async findByCode(
|
||||
resourceId: string,
|
||||
resource: string,
|
||||
code: string,
|
||||
): Promise<FileRecord> {
|
||||
const record = await this.filesRepository.findByCode(resourceId, resource, code);
|
||||
if (!record)
|
||||
throw new NotFoundException(
|
||||
`File with code "${code}" not found for ${resource} ${resourceId}`,
|
||||
);
|
||||
return record;
|
||||
}
|
||||
|
||||
async streamById(id: string): Promise<{ stream: Readable; record: FileRecord }> {
|
||||
const record = await this.findById(id);
|
||||
const objectName = this.extractObjectName(record.url);
|
||||
const stream = await this.minioService.getFileStream(objectName);
|
||||
return { stream, record };
|
||||
}
|
||||
|
||||
private extractObjectName(url: string): string {
|
||||
const parts = url.split("/");
|
||||
return parts.slice(4).join("/");
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Inject, Injectable, Logger } from "@nestjs/common";
|
||||
import { ConfigType } from "@nestjs/config";
|
||||
import { Client } from "minio";
|
||||
import { Readable } from "stream";
|
||||
import { minioConfig } from "./minio.config";
|
||||
|
||||
@Injectable()
|
||||
@@ -62,4 +63,22 @@ export class MinioService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getFileStream(objectName: string): Promise<Readable> {
|
||||
try {
|
||||
return this.client.getObject(this.bucket, objectName);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get file ${objectName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSignedUrl(objectName: string, expirySeconds: number = 300): Promise<string> {
|
||||
try {
|
||||
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to generate signed URL for ${objectName}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user