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:
Hagernesh
2026-08-28 11:31:50 +00:00
parent 132374ed03
commit 3f4fd8648a
16 changed files with 1075 additions and 44 deletions

View File

@@ -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()

View File

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

View File

@@ -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({

View File

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

View File

@@ -895,6 +895,28 @@ export class TrainSchedulingController {
return res.send(buffer);
}
@Get("schedules/:id/marshalling/stops")
@TrainSchedulingView()
@ApiOperation({ summary: "Corridor stops with a logged consist change, in order (Marshalling 2, 3, 4…)" })
marshallingStops(@Param("id", ParseUUIDPipe) id: string) {
return this.trainSchedulingService.marshallingStops(id);
}
@Get("schedules/:id/marshalling/document/:stopIndex")
@TrainSchedulingView()
@ApiOperation({ summary: "Download the numbered marshalling PDF for one corridor stop" })
async marshallingDocumentAt(
@Param("id", ParseUUIDPipe) id: string,
@Param("stopIndex", ParseIntPipe) stopIndex: number,
@Res() res: Response,
) {
const { filename, buffer } = await this.trainSchedulingService.marshallingDocumentAt(id, stopIndex);
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
res.setHeader("Content-Length", buffer.length);
return res.send(buffer);
}
// ---- batch / booking-window staff actions ----
@Post("schedules/:id/run-batch")

View File

@@ -1209,7 +1209,7 @@ describe('TrainSchedulingService', () => {
expect(html).toContain('<span>To load en route</span><strong>1 containers</strong>');
});
it('prints coupled/switched wagons logged at this stop, and omits the box when there are none', () => {
it('prints the consist-changes table for this stop, and omits it when there are none', () => {
const schedule = {
id: 'schedule-1',
trainNumber: '8302',
@@ -1223,16 +1223,21 @@ describe('TrainSchedulingService', () => {
const withChanges = build(schedule, {
consistChangesAtStop: [
{ action: 'ADD', wagonNumber: 'W-1002' },
{ action: 'SWITCH', wagonNumber: 'W-0501 → W-1003' },
{ wagonNumber: 'W-1002', event: 'Coupled', containerNumbers: 'EMPTY WAGON' },
{ wagonNumber: 'W-1005', event: 'Coupled', containerNumbers: 'CONT-004, CONT-005' },
{ wagonNumber: 'W-0501 → W-1003', event: 'Switched', containerNumbers: 'CONT-011' },
],
});
expect(withChanges).toContain('Consist changed at this stop');
expect(withChanges).toContain('Coupled: W-1002');
expect(withChanges).toContain('Uncoupled — replaced: W-0501 → W-1003');
expect(withChanges).toContain('Consist Changed At This Stop');
expect(withChanges).toContain('<td>W-1002</td>');
expect(withChanges).toContain('<td>Coupled</td>');
expect(withChanges).toContain('<td>EMPTY WAGON</td>');
expect(withChanges).toContain('<td>CONT-004, CONT-005</td>');
expect(withChanges).toContain('<td>W-0501 → W-1003</td>');
expect(withChanges).toContain('<td>Switched</td>');
const withoutChanges = build(schedule, {});
expect(withoutChanges).not.toContain('Consist changed at this stop');
expect(withoutChanges).not.toContain('Consist Changed At This Stop');
});
it('lists loaded empty containers by number and states they are empty', () => {

View File

@@ -3617,7 +3617,83 @@ export class TrainSchedulingService {
return { wagons, unassignedBookings };
}
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
/**
* Every corridor stop where the consist actually changed for this schedule
* (coupled, uncoupled, or switched — any flavor), in the order the train
* reached them. Origin is never in this list — it's always its own doc (the
* plain import/export load list), so numbering here starts at 2. A stop with
* only a routine checkpoint and no consist change never gets a row, which is
* the point: "Marshalling 2, 3, 4…" tracks events, not raw stop count.
*/
async marshallingStops(
scheduleId: string,
): Promise<Array<{ stopIndex: number; yardId: string; yardLabel: string; firstOccurredAt: string }>> {
const rows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId },
order: { occurredAt: 'ASC' },
});
const firstSeenAt = new Map<string, Date>();
for (const row of rows) {
if (!row.yardId || firstSeenAt.has(row.yardId)) continue;
firstSeenAt.set(row.yardId, row.occurredAt);
}
const orderedYardIds = [...firstSeenAt.entries()]
.sort((a, b) => a[1].getTime() - b[1].getTime())
.map(([yardId]) => yardId);
const labels = await this.yardLabelsById(orderedYardIds);
return orderedYardIds.map((yardId, i) => ({
stopIndex: i + 2,
yardId,
yardLabel: labels.get(yardId) ?? yardId,
firstOccurredAt: firstSeenAt.get(yardId)!.toISOString(),
}));
}
/**
* The coupled/uncoupled/switched rows for one stop, in the locked table
* shape (wagon, event, containers). "EMPTY WAGON" replaces the container
* list rather than a blank cell — the column always exists so a loaded and
* an empty coupling read as the same table, not two different layouts.
* Cargo for ADD/REMOVE rows is read off the schedule's OWN slot allocations
* for that physical wagon: an ADD is a leg slot boarding already loaded (see
* stampSlotLegs) or an empty couple (plannedWagonCouples) with none; a
* REMOVE is a slot alighting with its cargo, or an empty trim. A SWITCH row
* carries the incoming wagon's id — the slot's cargo already rides it.
*/
private consistChangesAt(
schedule: TrainSchedule,
logRows: ScheduleWagonAdjustmentLog[],
): Array<{ wagonNumber: string; event: 'Coupled' | 'Uncoupled' | 'Switched'; containerNumbers: string }> {
const slotByPhysicalWagonId = new Map(
(schedule.trainSet?.wagons ?? [])
.filter((wagon) => wagon.physicalWagonId)
.map((wagon) => [wagon.physicalWagonId as string, wagon]),
);
return logRows.map((row) => {
const slot = slotByPhysicalWagonId.get(row.wagonId);
const containerNumbers = (slot?.allocations ?? [])
.flatMap((allocation) => allocation.containerItems ?? [])
.map((item) => item.containerNumber)
.filter(Boolean)
.join(', ');
return {
wagonNumber: row.wagonNumber,
event: row.action === 'ADD' ? 'Coupled' : row.action === 'REMOVE' ? 'Uncoupled' : 'Switched',
containerNumbers: containerNumbers || 'EMPTY WAGON',
};
});
}
/**
* The numbered marshalling document for one corridor stop (see
* marshallingStops — stopIndex 2+, origin is its own separate doc).
* ponytail: the wagon table always shows the CURRENT on-board state, not a
* point-in-time reconstruction of what stood on the train at that past
* stop — a full historical snapshot is a much bigger feature nobody has
* asked for. What's stop-specific is the consist-changes table below it,
* which IS scoped to that stop's own logged events.
*/
async marshallingDocumentAt(scheduleId: string, stopIndex: number): Promise<{ filename: string; buffer: Buffer }> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
@@ -3627,24 +3703,66 @@ export class TrainSchedulingService {
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
const stops = await this.marshallingStops(scheduleId);
const stop = stops.find((s) => s.stopIndex === stopIndex);
if (!stop) {
throw new NotFoundException(
`No marshalling document at stop ${stopIndex} for this schedule — nothing coupled/uncoupled there, or the stop doesn't exist`,
);
}
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
const logRows = await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId, yardId: stop.yardId },
order: { occurredAt: 'ASC' },
});
const html = this.buildExportLoadListHtml(schedule, {
title: `Intercity Marshalling Document / Load List (Marshalling ${stopIndex})`,
positionLabel: `At ${stop.yardLabel}`,
wagons,
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
consistChangesAtStop: this.consistChangesAt(schedule, logRows),
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, `Marshalling ${stopIndex}`);
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `marshalling-${stopIndex}-${this.safeDocumentName(reference)}.pdf`,
buffer,
};
}
/**
* Back-compat alias: the single "current" intercity doc (Marshalling 2) the
* old one-document-per-schedule UI calls. Resolves to the LATEST stop with
* a logged consist change; falls back to the current-position doc with no
* changes table when nothing has coupled/uncoupled yet (e.g. right after
* dispatch, before any mid-corridor stop).
*/
async intercityMarshallingDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
const stops = await this.marshallingStops(scheduleId);
const latest = stops[stops.length - 1];
if (latest) {
return this.marshallingDocumentAt(scheduleId, latest.stopIndex);
}
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.status !== 'DISPATCHED' && schedule.status !== 'ARRIVED') {
throw new BadRequestException(
'Intercity marshalling document applies only to dispatched or arrived trains',
);
}
const checkpoints = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
const last = checkpoints[checkpoints.length - 1];
const positionLabel = last
? `After ${last.yard?.label ?? last.yard?.code ?? 'checkpoint'}`
: `At ${schedule.originStation?.label ?? schedule.originStation?.code ?? 'origin'} — no checkpoint recorded`;
const { wagons, unassignedBookings } = this.intercityOnBoardView(schedule);
// Couples/switches logged AT THIS STOP — what staff standing here actually
// just did to the consist. Bare trims (REMOVE, no replacement) are left
// out: nothing new to point staff at for those. Origin adjustments (a
// different yard) don't show up on this stop's document.
const consistChangesAtStop = last
? await this.dataSource.getRepository(ScheduleWagonAdjustmentLog).find({
where: { trainScheduleId: scheduleId, yardId: last.yardId, action: In(['ADD', 'SWITCH']) },
order: { occurredAt: 'DESC' },
})
: [];
const html = this.buildExportLoadListHtml(schedule, {
title: 'Intercity Marshalling Document / Load List (Marshalling 2)',
positionLabel,
@@ -3652,7 +3770,6 @@ export class TrainSchedulingService {
unassignedBookings,
emptyContainers: await this.loadedEmptyContainers(scheduleId),
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
consistChangesAtStop,
});
// Styled table-aware fallback (marshalling grid) — see importLoadListDocument.
const buffer = await this.pdfDocuments.renderTabularDocument(html, 'Intercity marshalling / load list');
@@ -3722,10 +3839,14 @@ export class TrainSchedulingService {
// Slots that couple to the train downstream (slot id → board yard label).
// Their cargo renders as TO LOAD AT and stays out of the loaded tallies.
pendingBoardYardLabelBySlot?: Map<string, string>;
// Intercity (Marshalling 2) only: couples/switches logged at the stop
// this document is printed at (see ScheduleWagonAdjustmentLog). Origin
// import/export docs never pass this, so they render no such box.
consistChangesAtStop?: ScheduleWagonAdjustmentLog[];
// Numbered marshalling docs only (see marshallingDocumentAt /
// consistChangesAt) — couples/uncouples/switches logged at THIS stop.
// Origin import/export docs never pass this, so they render no such box.
consistChangesAtStop?: Array<{
wagonNumber: string;
event: 'Coupled' | 'Uncoupled' | 'Switched';
containerNumbers: string;
}>;
},
): string {
const esc = (value: unknown) =>
@@ -3890,6 +4011,7 @@ export class TrainSchedulingService {
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
.tile strong { font-size: 11px; }
${logoImageCss()}
h2 { margin: 16px 0 6px; font-size: 12px; color: #0f766e; text-transform: uppercase; letter-spacing: .05em; }
table { width: 100%; border-collapse: collapse; }
th { background: #f8fafc; color: #475569; text-align: left; }
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
@@ -3938,19 +4060,27 @@ export class TrainSchedulingService {
${
opts?.consistChangesAtStop?.length
? `<div class="notice">
<b>Consist changed at this stop:</b>
${(() => {
const coupled = opts.consistChangesAtStop.filter((row) => row.action === 'ADD');
const switched = opts.consistChangesAtStop.filter((row) => row.action === 'SWITCH');
return [
coupled.length ? `Coupled: ${esc(coupled.map((row) => row.wagonNumber).join(', '))}` : '',
switched.length ? `Uncoupled — replaced: ${esc(switched.map((row) => row.wagonNumber).join(', '))}` : '',
]
.filter(Boolean)
.join(' &nbsp;|&nbsp; ');
})()}
</div>`
? `<h2>Consist Changed At This Stop</h2>
<table>
<thead>
<tr>
<th>Wagon No</th>
<th>Event</th>
<th>Container No</th>
</tr>
</thead>
<tbody>
${opts.consistChangesAtStop
.map(
(row) => `<tr>
<td>${esc(row.wagonNumber)}</td>
<td>${esc(row.event)}</td>
<td>${esc(row.containerNumbers)}</td>
</tr>`,
)
.join('')}
</tbody>
</table>`
: ''
}