mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 12:28:21 +00:00
feat(import-operations): bulk Excel upload for yard-resident empty containers
Empties already sitting in an EDR yard but never entered in the system had to be typed one at a time. Adds a bulk path: parse the sheet in the browser (all-or-nothing, row-numbered errors), preview it, then POST one batch. The server rejects the batch if any container already has a non-COMPLETED return, so re-uploading the same sheet cannot duplicate boxes. No interchange notification fires — these are historical rows, not a live handover. Company is an Autocomplete over registered customers that also accepts a typed name, since a backfilled box may belong to a company that is not a customer yet. Exact name match sets customer_id; the name always lands in the new empty_container_returns.company_name. Also fixes the single Record Return modal, which collected Yard and Zone and then dropped them before the API call, and did not invalidate the returns list after a standalone return.
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
@@ -150,6 +153,14 @@ export class CreateEmptyContainerReturnDto {
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Owning company name — free text when the company is not a registered customer.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
companyName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
@@ -196,6 +207,16 @@ export class CreateEmptyContainerReturnDto {
|
||||
returnedBy?: 'EDR' | 'CUSTOMER';
|
||||
}
|
||||
|
||||
export class BulkCreateEmptyContainerReturnsDto {
|
||||
@ApiProperty({ type: [CreateEmptyContainerReturnDto] })
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ArrayMaxSize(1000)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateEmptyContainerReturnDto)
|
||||
returns!: CreateEmptyContainerReturnDto[];
|
||||
}
|
||||
|
||||
export class LoadEmptyContainerItemDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
|
||||
@@ -27,6 +27,15 @@ export class EmptyContainerReturn extends BaseEntity {
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
/**
|
||||
* Owning company as text. Set when the box was backfilled for a company that
|
||||
* is not (yet) a registered customer, so `customer_id` cannot carry it. When
|
||||
* a registered company IS picked, both are set — the name is the label the
|
||||
* list renders without a join.
|
||||
*/
|
||||
@Column({ name: 'company_name', type: 'varchar', length: 200, nullable: true })
|
||||
companyName?: string | null;
|
||||
|
||||
@Column({ name: 'return_date', type: 'timestamptz' })
|
||||
returnDate!: Date;
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BookingsService } from '../bookings/bookings.service';
|
||||
import {
|
||||
AssignCustomsRiskDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
BulkCreateEmptyContainerReturnsDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
LoadEmptyContainersOnTrainDto,
|
||||
@@ -125,6 +126,15 @@ export class ImportOperationsController {
|
||||
return this.service.createEmptyReturn(dto);
|
||||
}
|
||||
|
||||
@Post('empty-container-returns/bulk')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
summary: 'Bulk-record empties already in the yard but never entered in the system',
|
||||
})
|
||||
bulkCreateEmptyReturns(@Body() dto: BulkCreateEmptyContainerReturnsDto) {
|
||||
return this.service.bulkCreateEmptyReturns(dto);
|
||||
}
|
||||
|
||||
@Post('empty-container-returns/load-on-train')
|
||||
@BookingStaff(FREIGHT_PERMS.bookings.operations)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
|
||||
import { NotificationAudience, NotificationType } from '@edr/types';
|
||||
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
|
||||
@@ -10,6 +10,7 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { sendCompanyChannels } from '../notifications/notify-company.util';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
BulkCreateEmptyContainerReturnsDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
@@ -24,7 +25,10 @@ import {
|
||||
type DjiboutiIncidentType,
|
||||
} from './entities/djibouti-incident.entity';
|
||||
import { assertWagonLoad } from './empty-container-wagon.util';
|
||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||
import {
|
||||
EmptyContainerReturn,
|
||||
type EmptyContainerReturnStatus,
|
||||
} from './entities/empty-container-return.entity';
|
||||
import {
|
||||
ImportCustomsFinalization,
|
||||
type ImportCustomsDocumentType,
|
||||
@@ -174,6 +178,7 @@ export class ImportOperationsService {
|
||||
containerNumber: dto.containerNumber,
|
||||
bookingId: dto.bookingId ?? null,
|
||||
customerId: dto.customerId ?? null,
|
||||
companyName: dto.companyName ?? null,
|
||||
returnDate,
|
||||
containerSize: dto.containerSize ?? null,
|
||||
facility: dto.facility ?? null,
|
||||
@@ -199,6 +204,66 @@ export class ImportOperationsService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk backfill of empties already sitting in a yard but never recorded.
|
||||
* All-or-nothing: if any container number already has an open (not COMPLETED)
|
||||
* return, nothing is written — re-uploading the same sheet must not duplicate
|
||||
* boxes. No interchange notification is sent; these are historical rows, not
|
||||
* a live handover.
|
||||
*/
|
||||
async bulkCreateEmptyReturns(dto: BulkCreateEmptyContainerReturnsDto) {
|
||||
const numbers = dto.returns.map((r) => r.containerNumber.trim().toUpperCase());
|
||||
|
||||
const seen = new Set<string>();
|
||||
const dupInFile = numbers.filter((n) => (seen.has(n) ? true : (seen.add(n), false)));
|
||||
if (dupInFile.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Container number(s) repeated in the upload: ${[...new Set(dupInFile)].join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const existing = await this.emptyReturns.find({
|
||||
where: {
|
||||
containerNumber: In(numbers),
|
||||
status: Not('COMPLETED' as EmptyContainerReturnStatus),
|
||||
},
|
||||
select: { containerNumber: true },
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Already recorded as returned: ${existing.map((r) => r.containerNumber).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const rows = dto.returns.map((r, i) => {
|
||||
const returnDate = r.returnDate ? new Date(r.returnDate) : new Date();
|
||||
return this.emptyReturns.create({
|
||||
containerNumber: numbers[i],
|
||||
bookingId: r.bookingId ?? null,
|
||||
customerId: r.customerId ?? null,
|
||||
companyName: r.companyName ?? null,
|
||||
returnDate,
|
||||
containerSize: r.containerSize ?? null,
|
||||
facility: r.facility ?? null,
|
||||
yard: r.yard ?? null,
|
||||
zone: r.zone ?? null,
|
||||
condition: r.condition ?? null,
|
||||
handoverNote: r.handoverNote ?? null,
|
||||
performedBy: r.performedBy ?? null,
|
||||
returnedBy: r.returnedBy ?? null,
|
||||
statusHistory: [
|
||||
{
|
||||
status: 'RETURNED' as const,
|
||||
changedAt: returnDate.toISOString(),
|
||||
performedBy: r.performedBy ?? null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
return this.emptyReturns.save(rows);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load returned empties onto an export departure. A wagon takes ONE 40ft or
|
||||
* TWO 20ft — never a mix, never three. Empties already sitting on a wagon of
|
||||
|
||||
Reference in New Issue
Block a user