mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
automated loading and unloading
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
|
||||
import { CreateInspectionReportDto } from './create-inspection-report.dto';
|
||||
|
||||
export class UpdateInspectionReportDto extends PartialType(CreateInspectionReportDto) {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user