From 1db1467ea1173ec600c6cf2501b8a567663d7431 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Wed, 17 Jun 2026 21:45:22 +0000 Subject: [PATCH] automated loading and unloading --- .../1750000000003-AddWarehouseInspection.ts | 56 +++++++++ .../dto/create-inspection-report.dto.ts | 64 ++++++++++ .../warehouses/dto/unload-booking.dto.ts | 34 +++++ .../dto/update-inspection-report.dto.ts | 5 + .../warehouse-inspection-report.entity.ts | 77 ++++++++++++ .../warehouse-inspection.controller.ts | 64 ++++++++++ .../warehouse-inspection.repository.ts | 15 +++ .../warehouse-inspection.service.ts | 116 ++++++++++++++++++ 8 files changed, 431 insertions(+) create mode 100644 apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts create mode 100644 apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts diff --git a/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts b/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts new file mode 100644 index 000000000..ae5a1625a --- /dev/null +++ b/apps/edr-freight-api/src/migrations/1750000000003-AddWarehouseInspection.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm'; + +/** + * Batch 4.5 — warehouse inspection reports + inventory inspection status. + */ +export class AddWarehouseInspection1750000000003 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + // inventory.inspection_status + await queryRunner.addColumn( + 'freight.warehouse_inventory', + new TableColumn({ name: 'inspection_status', type: 'varchar', length: '20', isNullable: true }), + ); + + // warehouse_inspection_reports table + await queryRunner.createTable( + new Table({ + schema: 'freight', + name: 'warehouse_inspection_reports', + columns: [ + { name: 'id', type: 'uuid', isPrimary: true, default: 'uuid_generate_v4()' }, + { name: 'inventory_id', type: 'uuid' }, + { name: 'booking_id', type: 'uuid', isNullable: true }, + { name: 'customer_id', type: 'uuid', isNullable: true }, + { name: 'report_type', type: 'varchar', length: '32', default: "'INSPECTION'" }, + { name: 'inspection_status', type: 'varchar', length: '20', default: "'NEEDS_REVIEW'" }, + { name: 'has_damage', type: 'boolean', default: false }, + { name: 'damage_description', type: 'text', isNullable: true }, + { name: 'has_weight_loss', type: 'boolean', default: false }, + { name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, isNullable: true }, + { name: 'weight_loss_unit', type: 'varchar', length: '12', isNullable: true }, + { name: 'has_missing_items', type: 'boolean', default: false }, + { name: 'missing_items_description', type: 'text', isNullable: true }, + { name: 'remarks', type: 'text', isNullable: true }, + { name: 'inspected_by_id', type: 'uuid', isNullable: true }, + { name: 'inspected_at', type: 'timestamptz', isNullable: true }, + { name: 'created_at', type: 'timestamptz', default: 'now()' }, + { name: 'updated_at', type: 'timestamptz', default: 'now()' }, + { name: 'deleted_at', type: 'timestamptz', isNullable: true }, + ], + indices: [ + { name: 'idx_wir_inventory', columnNames: ['inventory_id'] }, + { name: 'idx_wir_booking', columnNames: ['booking_id'] }, + { name: 'idx_wir_status', columnNames: ['inspection_status'] }, + ], + }), + true, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('freight.warehouse_inspection_reports', true); + await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status'); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts new file mode 100644 index 000000000..cc1350796 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/create-inspection-report.dto.ts @@ -0,0 +1,64 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, IsUUID } from 'class-validator'; + +import { + INSPECTION_REPORT_TYPES, + INSPECTION_STATUSES, + InspectionReportType, + InspectionStatus, +} from '../entities/warehouse-inspection-report.entity'; + +export class CreateInspectionReportDto { + @ApiProperty({ enum: INSPECTION_REPORT_TYPES }) + @IsEnum(INSPECTION_REPORT_TYPES) + reportType!: InspectionReportType; + + @ApiProperty({ enum: INSPECTION_STATUSES }) + @IsEnum(INSPECTION_STATUSES) + inspectionStatus!: InspectionStatus; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasDamage?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + damageDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasWeightLoss?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + expectedWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + actualWeight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + hasMissingItems?: boolean; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + missingItemsDescription?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + remarks?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + inspectedById?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts new file mode 100644 index 000000000..3138285e2 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/unload-booking.dto.ts @@ -0,0 +1,34 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UnloadBookingDto { + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + facilityId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + warehouseId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + yardId?: string; + + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + zoneId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + unloadedAt?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts new file mode 100644 index 000000000..372008d3b --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/dto/update-inspection-report.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; + +import { CreateInspectionReportDto } from './create-inspection-report.dto'; + +export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {} diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts new file mode 100644 index 000000000..598b8d168 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inspection-report.entity.ts @@ -0,0 +1,77 @@ +import { BaseEntity } from '@edr/api-common'; +import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm'; + +import { WarehouseInventory } from './warehouse-inventory.entity'; + +export const INSPECTION_REPORT_TYPES = [ + 'INSPECTION', + 'DAMAGE', + 'WEIGHT_LOSS', + 'MISSING_ITEM', + 'GENERAL', +] as const; +export type InspectionReportType = (typeof INSPECTION_REPORT_TYPES)[number]; + +export const INSPECTION_STATUSES = ['PASSED', 'FAILED', 'NEEDS_REVIEW'] as const; +export type InspectionStatus = (typeof INSPECTION_STATUSES)[number]; + +@Entity({ schema: 'freight', name: 'warehouse_inspection_reports' }) +@Index(['inventoryId']) +@Index(['bookingId']) +@Index(['inspectionStatus']) +export class WarehouseInspectionReport extends BaseEntity { + @Column({ name: 'inventory_id', type: 'uuid' }) + inventoryId!: string; + + @ManyToOne(() => WarehouseInventory) + @JoinColumn({ name: 'inventory_id' }) + inventory?: WarehouseInventory; + + @Column({ name: 'booking_id', type: 'uuid', nullable: true }) + bookingId?: string | null; + + @Column({ name: 'customer_id', type: 'uuid', nullable: true }) + customerId?: string | null; + + @Column({ name: 'report_type', type: 'varchar', length: 32, default: 'INSPECTION' }) + reportType!: InspectionReportType; + + @Column({ name: 'inspection_status', type: 'varchar', length: 20, default: 'NEEDS_REVIEW' }) + inspectionStatus!: InspectionStatus; + + @Column({ name: 'has_damage', type: 'boolean', default: false }) + hasDamage!: boolean; + + @Column({ name: 'damage_description', type: 'text', nullable: true }) + damageDescription?: string | null; + + @Column({ name: 'has_weight_loss', type: 'boolean', default: false }) + hasWeightLoss!: boolean; + + @Column({ name: 'expected_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + expectedWeight?: number | null; + + @Column({ name: 'actual_weight', type: 'numeric', precision: 14, scale: 3, nullable: true }) + actualWeight?: number | null; + + @Column({ name: 'weight_loss', type: 'numeric', precision: 14, scale: 3, nullable: true }) + weightLoss?: number | null; + + @Column({ name: 'weight_loss_unit', type: 'varchar', length: 12, nullable: true }) + weightLossUnit?: string | null; + + @Column({ name: 'has_missing_items', type: 'boolean', default: false }) + hasMissingItems!: boolean; + + @Column({ name: 'missing_items_description', type: 'text', nullable: true }) + missingItemsDescription?: string | null; + + @Column({ name: 'remarks', type: 'text', nullable: true }) + remarks?: string | null; + + @Column({ name: 'inspected_by_id', type: 'uuid', nullable: true }) + inspectedById?: string | null; + + @Column({ name: 'inspected_at', type: 'timestamptz', nullable: true }) + inspectedAt?: Date | null; +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts new file mode 100644 index 000000000..533b0c6ae --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UploadedFiles, + UseInterceptors, +} from '@nestjs/common'; +import { AnyFilesInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger'; + +import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; +import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; +import { WarehouseInspectionService } from './warehouse-inspection.service'; + +@ApiTags('warehouse-inspection') +@ApiBearerAuth() +@Controller() +export class WarehouseInspectionController { + constructor(private readonly inspectionService: WarehouseInspectionService) {} + + @Post('warehouse-inventory/:inventoryId/inspection-reports') + @ApiOperation({ summary: 'Create an inspection / damage report for an inventory item' }) + create( + @Param('inventoryId', ParseUUIDPipe) inventoryId: string, + @Body() dto: CreateInspectionReportDto, + ) { + return this.inspectionService.create(inventoryId, dto); + } + + @Get('warehouse-inventory/:inventoryId/inspection-reports') + @ApiOperation({ summary: 'List inspection reports for an inventory item' }) + listByInventory(@Param('inventoryId', ParseUUIDPipe) inventoryId: string) { + return this.inspectionService.findByInventory(inventoryId); + } + + @Get('warehouse-inspection-reports/:id') + @ApiOperation({ summary: 'Get an inspection report (with attachments)' }) + async findOne(@Param('id', ParseUUIDPipe) id: string) { + const report = await this.inspectionService.findById(id); + const attachments = await this.inspectionService.listAttachments(id); + return { ...report, attachments }; + } + + @Patch('warehouse-inspection-reports/:id') + @ApiOperation({ summary: 'Update an inspection report' }) + update(@Param('id', ParseUUIDPipe) id: string, @Body() dto: UpdateInspectionReportDto) { + return this.inspectionService.update(id, dto); + } + + @Post('warehouse-inspection-reports/:id/attachments') + @UseInterceptors(AnyFilesInterceptor()) + @ApiConsumes('multipart/form-data') + @ApiOperation({ summary: 'Upload inspection images / documents' }) + addAttachments( + @Param('id', ParseUUIDPipe) id: string, + @UploadedFiles() files: Express.Multer.File[], + ) { + return this.inspectionService.addAttachments(id, files); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts new file mode 100644 index 000000000..4cd92e846 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.repository.ts @@ -0,0 +1,15 @@ +import { BaseRepository } from '@edr/api-common'; +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; + +@Injectable() +export class WarehouseInspectionRepository extends BaseRepository { + constructor( + @InjectRepository(WarehouseInspectionReport) repository: Repository, + ) { + super(repository); + } +} diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts new file mode 100644 index 000000000..9d1d0f148 --- /dev/null +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inspection.service.ts @@ -0,0 +1,116 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +import { FilesService } from '../files/files.service'; +import { CreateInspectionReportDto } from './dto/create-inspection-report.dto'; +import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto'; +import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; +import { WarehouseInventory } from './entities/warehouse-inventory.entity'; +import { WarehouseInspectionRepository } from './warehouse-inspection.repository'; + +const INSPECTION_RESOURCE = 'warehouse-inspection-report'; + +@Injectable() +export class WarehouseInspectionService { + constructor( + private readonly dataSource: DataSource, + private readonly inspectionRepository: WarehouseInspectionRepository, + private readonly filesService: FilesService, + ) {} + + /** Create an inspection report for an inventory item and sync its inspectionStatus. */ + async create(inventoryId: string, dto: CreateInspectionReportDto): Promise { + const inventoryRepo = this.dataSource.getRepository(WarehouseInventory); + const inventory = await inventoryRepo.findOne({ where: { id: inventoryId } }); + if (!inventory) { + throw new NotFoundException(`Inventory item ${inventoryId} not found`); + } + + const expected = dto.expectedWeight ?? null; + const actual = dto.actualWeight ?? null; + const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : null; + + const report = await this.inspectionRepository.create({ + inventoryId, + bookingId: inventory.bookingId ?? null, + reportType: dto.reportType, + inspectionStatus: dto.inspectionStatus, + hasDamage: dto.hasDamage ?? false, + damageDescription: dto.damageDescription ?? null, + hasWeightLoss: dto.hasWeightLoss ?? false, + expectedWeight: expected, + actualWeight: actual, + weightLoss, + weightLossUnit: weightLoss !== null ? 'kg' : null, + hasMissingItems: dto.hasMissingItems ?? false, + missingItemsDescription: dto.missingItemsDescription ?? null, + remarks: dto.remarks ?? null, + inspectedById: dto.inspectedById ?? null, + inspectedAt: new Date(), + }); + + // Mirror the latest outcome onto the inventory item so loading rules can read it. + await inventoryRepo.update(inventoryId, { + inspectionStatus: dto.inspectionStatus, + inspectedAt: new Date(), + }); + + return report; + } + + async findByInventory(inventoryId: string): Promise { + return this.inspectionRepository.findAll({ + where: { inventoryId }, + order: { createdAt: 'DESC' }, + }); + } + + async findById(id: string): Promise { + const report = await this.inspectionRepository.findById(id); + if (!report) { + throw new NotFoundException(`Inspection report ${id} not found`); + } + return report; + } + + async update(id: string, dto: UpdateInspectionReportDto): Promise { + const report = await this.findById(id); + + const expected = dto.expectedWeight ?? report.expectedWeight ?? null; + const actual = dto.actualWeight ?? report.actualWeight ?? null; + const weightLoss = expected !== null && actual !== null ? Math.max(0, expected - actual) : report.weightLoss ?? null; + + await this.inspectionRepository.update(id, { + ...(dto.reportType ? { reportType: dto.reportType } : {}), + ...(dto.inspectionStatus ? { inspectionStatus: dto.inspectionStatus } : {}), + ...(dto.hasDamage !== undefined ? { hasDamage: dto.hasDamage } : {}), + ...(dto.damageDescription !== undefined ? { damageDescription: dto.damageDescription } : {}), + ...(dto.hasWeightLoss !== undefined ? { hasWeightLoss: dto.hasWeightLoss } : {}), + expectedWeight: expected, + actualWeight: actual, + weightLoss, + ...(dto.hasMissingItems !== undefined ? { hasMissingItems: dto.hasMissingItems } : {}), + ...(dto.missingItemsDescription !== undefined ? { missingItemsDescription: dto.missingItemsDescription } : {}), + ...(dto.remarks !== undefined ? { remarks: dto.remarks } : {}), + }); + + if (dto.inspectionStatus) { + await this.dataSource + .getRepository(WarehouseInventory) + .update(report.inventoryId, { inspectionStatus: dto.inspectionStatus }); + } + + return this.findById(id); + } + + /** Attach uploaded images/documents to a report, reusing the shared Files (MinIO) module. */ + async addAttachments(reportId: string, files: Express.Multer.File[]) { + await this.findById(reportId); + if (!files?.length) return []; + return this.filesService.uploadMany(reportId, INSPECTION_RESOURCE, files); + } + + listAttachments(reportId: string) { + return this.filesService.findByResource(reportId, INSPECTION_RESOURCE); + } +}