automated loading and unloading

This commit is contained in:
Hagernesh
2026-06-17 21:45:22 +00:00
parent f528b75737
commit 1db1467ea1
8 changed files with 431 additions and 0 deletions

View File

@@ -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<void> {
// 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<void> {
await queryRunner.dropTable('freight.warehouse_inspection_reports', true);
await queryRunner.dropColumn('freight.warehouse_inventory', 'inspection_status');
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,5 @@
import { PartialType } from '@nestjs/swagger';
import { CreateInspectionReportDto } from './create-inspection-report.dto';
export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {}

View File

@@ -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;
}

View File

@@ -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);
}
}

View File

@@ -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<WarehouseInspectionReport> {
constructor(
@InjectRepository(WarehouseInspectionReport) repository: Repository<WarehouseInspectionReport>,
) {
super(repository);
}
}

View File

@@ -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<WarehouseInspectionReport> {
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<WarehouseInspectionReport[]> {
return this.inspectionRepository.findAll({
where: { inventoryId },
order: { createdAt: 'DESC' },
});
}
async findById(id: string): Promise<WarehouseInspectionReport> {
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<WarehouseInspectionReport> {
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);
}
}