mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 19:30:57 +00:00
Merge pull request #1441 from Tria-plc/credit-invoice
feat(import-operations): bulk Excel upload for yard-resident empty co…
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Empties backfilled into the yard belong to a company that may not be a
|
||||
* registered customer yet, so `customer_id` cannot hold it. `company_name` is
|
||||
* the typed fallback, and the display label when the customer IS registered.
|
||||
*/
|
||||
export class EmptyContainerReturnCompanyName3790000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerReturnCompanyName3790000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
ADD COLUMN IF NOT EXISTS company_name varchar(200)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.empty_container_returns
|
||||
DROP COLUMN IF EXISTS company_name
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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(' | ');
|
||||
})()}
|
||||
</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>`
|
||||
: ''
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,354 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Alert,
|
||||
Anchor,
|
||||
Autocomplete,
|
||||
Badge,
|
||||
Button,
|
||||
FileInput,
|
||||
Group,
|
||||
List,
|
||||
Modal,
|
||||
ScrollArea,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { Upload } from "lucide-react";
|
||||
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useWarehouseYards, useWarehouseZones } from "@/hooks/useWarehouses";
|
||||
import { importOperationsService } from "@/services/importOperations.service";
|
||||
import { warehouseService } from "@/services/warehouse.service";
|
||||
import type { CreateEmptyContainerReturnPayload } from "@/types/importOperations";
|
||||
|
||||
import {
|
||||
downloadContainerReturnTemplate,
|
||||
parseContainerReturnExcel,
|
||||
type ParsedReturnRow,
|
||||
} from "./container-return-excel";
|
||||
import { useCompanyOptions } from "./useCompanyOptions";
|
||||
|
||||
interface BulkContainerReturnModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onUploaded: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill of empties physically in a yard but never entered in the system.
|
||||
* The sheet carries per-container detail; the fields above the file are the
|
||||
* defaults for every row whose cell is blank, so the common case is a sheet of
|
||||
* container numbers plus one warehouse picked here.
|
||||
*/
|
||||
export default function BulkContainerReturnModal({
|
||||
opened,
|
||||
onClose,
|
||||
onUploaded,
|
||||
}: BulkContainerReturnModalProps) {
|
||||
const { toast } = useToast();
|
||||
const companies = useCompanyOptions();
|
||||
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [rows, setRows] = useState<ParsedReturnRow[]>([]);
|
||||
const [parseErrors, setParseErrors] = useState<string[]>([]);
|
||||
const [parsing, setParsing] = useState(false);
|
||||
|
||||
const [company, setCompany] = useState("");
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [warehouseId, setWarehouseId] = useState<string | null>(null);
|
||||
const [yardId, setYardId] = useState<string | null>(null);
|
||||
const [zoneId, setZoneId] = useState<string | null>(null);
|
||||
const [returnDate, setReturnDate] = useState(new Date().toISOString().split("T")[0]);
|
||||
|
||||
const { data: warehousesResponse } = useQuery({
|
||||
queryKey: ["warehouses-list"],
|
||||
queryFn: () => warehouseService.list({}),
|
||||
});
|
||||
const warehouses = ((warehousesResponse as any)?.data ?? warehousesResponse ?? []) as any[];
|
||||
const { data: yards } = useWarehouseYards(warehouseId ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
useEffect(() => {
|
||||
setYardId(null);
|
||||
setZoneId(null);
|
||||
}, [warehouseId]);
|
||||
useEffect(() => setZoneId(null), [yardId]);
|
||||
|
||||
const warehouseOptions = Array.isArray(warehouses)
|
||||
? warehouses.map((wh) => ({ value: wh.id, label: wh.code ? `${wh.name} (${wh.code})` : wh.name }))
|
||||
: [];
|
||||
const yardOptions = (yards ?? [])
|
||||
.filter((y) => y.status === "ACTIVE")
|
||||
.map((y) => ({ value: y.id, label: `${y.name} (${y.code})` }));
|
||||
const zoneOptions = (zones ?? [])
|
||||
.filter((z) => z.status === "ACTIVE")
|
||||
.map((z) => ({ value: z.id, label: `${z.name} (${z.code})` }));
|
||||
|
||||
const defaults = useMemo(
|
||||
() => ({
|
||||
facility: warehouses.find((wh) => wh.id === warehouseId)?.name ?? "",
|
||||
yard: yards?.find((y) => y.id === yardId)?.name ?? "",
|
||||
zone: zones?.find((z) => z.id === zoneId)?.name ?? "",
|
||||
}),
|
||||
[warehouses, warehouseId, yards, yardId, zones, zoneId],
|
||||
);
|
||||
|
||||
const reset = () => {
|
||||
setFile(null);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
};
|
||||
|
||||
const handleFile = async (next: File | null) => {
|
||||
setFile(next);
|
||||
setRows([]);
|
||||
setParseErrors([]);
|
||||
if (!next) return;
|
||||
setParsing(true);
|
||||
const result = await parseContainerReturnExcel(next);
|
||||
setParsing(false);
|
||||
setRows(result.rows);
|
||||
setParseErrors(result.errors);
|
||||
};
|
||||
|
||||
// Row cell wins; the field above the file fills the blanks.
|
||||
const toPayload = (row: ParsedReturnRow): CreateEmptyContainerReturnPayload => {
|
||||
const companyName = row.companyName || company;
|
||||
return {
|
||||
containerNumber: row.containerNumber,
|
||||
containerSize: row.containerSize ?? undefined,
|
||||
companyName: companyName || undefined,
|
||||
customerId: companyName ? companies.resolveId(companyName) : undefined,
|
||||
returnedBy: row.returnedBy ?? returnedBy ?? undefined,
|
||||
returnDate: row.returnDate ?? new Date(returnDate).toISOString(),
|
||||
facility: row.facility || defaults.facility || undefined,
|
||||
yard: row.yard || defaults.yard || undefined,
|
||||
zone: row.zone || defaults.zone || undefined,
|
||||
condition: row.condition || undefined,
|
||||
handoverNote: row.handoverNote || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () => importOperationsService.bulkCreateEmptyReturns(rows.map(toPayload)),
|
||||
onSuccess: (created) => {
|
||||
toast({ title: `${created.length} container return${created.length === 1 ? "" : "s"} recorded` });
|
||||
reset();
|
||||
onUploaded();
|
||||
onClose();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Bulk upload failed",
|
||||
description: error?.response?.data?.message || error?.message,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Every row needs a warehouse from somewhere — the API stores facility as
|
||||
// free text, so a blank one would silently produce unplaceable containers.
|
||||
const missingFacility = rows.filter((r) => !r.facility && !defaults.facility).length;
|
||||
const missingReturnedBy = rows.filter((r) => !r.returnedBy && !returnedBy).length;
|
||||
const blockers = [
|
||||
missingFacility > 0 ? `${missingFacility} row(s) have no facility — pick a default warehouse.` : null,
|
||||
missingReturnedBy > 0 ? `${missingReturnedBy} row(s) have no "Returned By" — pick a default.` : null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
title="Bulk Upload Container Returns"
|
||||
size="xl"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
For empties already sitting in the yard but not yet on the system. Values below fill any
|
||||
blank cell in the sheet.{" "}
|
||||
<Anchor size="sm" onClick={() => downloadContainerReturnTemplate()}>
|
||||
Download template
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Autocomplete
|
||||
label="Company"
|
||||
description="Pick a registered customer, or type a company that is not on the system yet"
|
||||
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
|
||||
data={companies.names}
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
limit={20}
|
||||
/>
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
value={returnedBy}
|
||||
onChange={(v) => setReturnedBy(v as "EDR" | "CUSTOMER" | null)}
|
||||
data={[
|
||||
{ value: "EDR", label: "EDR Truck" },
|
||||
{ value: "CUSTOMER", label: "Customer Truck" },
|
||||
]}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<Select
|
||||
label="Warehouse"
|
||||
placeholder="Select warehouse"
|
||||
value={warehouseId}
|
||||
onChange={setWarehouseId}
|
||||
data={warehouseOptions}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Yard"
|
||||
placeholder={warehouseId ? "Select yard" : "Select warehouse first"}
|
||||
value={yardId}
|
||||
onChange={setYardId}
|
||||
data={yardOptions}
|
||||
disabled={!warehouseId}
|
||||
searchable
|
||||
/>
|
||||
<Select
|
||||
label="Zone"
|
||||
placeholder={yardId ? "Select zone" : "Select yard first"}
|
||||
value={zoneId}
|
||||
onChange={setZoneId}
|
||||
data={zoneOptions}
|
||||
disabled={!yardId}
|
||||
searchable
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Group grow align="flex-start">
|
||||
<FileInput
|
||||
label="Excel file"
|
||||
placeholder="Select .xlsx or .xls"
|
||||
accept=".xlsx,.xls"
|
||||
leftSection={<Upload size={16} />}
|
||||
value={file}
|
||||
onChange={(next) => void handleFile(next)}
|
||||
/>
|
||||
<div>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Returned Date (default)
|
||||
</Text>
|
||||
<input
|
||||
type="date"
|
||||
value={returnDate}
|
||||
onChange={(e) => setReturnDate(e.target.value)}
|
||||
style={{ padding: "8px", borderRadius: "4px", border: "1px solid #ced4da", width: "100%" }}
|
||||
/>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
{parsing && <Text size="sm">Reading file…</Text>}
|
||||
|
||||
{parseErrors.length > 0 && (
|
||||
<Alert color="red" title={`${parseErrors.length} problem(s) — nothing was imported`}>
|
||||
<ScrollArea.Autosize mah={200}>
|
||||
<List size="sm">
|
||||
{parseErrors.map((err) => (
|
||||
<List.Item key={err}>{err}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</ScrollArea.Autosize>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{blockers.length > 0 && (
|
||||
<Alert color="yellow" title="Fill these in before uploading">
|
||||
<List size="sm">
|
||||
{blockers.map((b) => (
|
||||
<List.Item key={b}>{b}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
Preview
|
||||
</Text>
|
||||
<Badge size="sm">{rows.length} containers</Badge>
|
||||
</Group>
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
<Table striped highlightOnHover withTableBorder>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Container</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Returned By</Table.Th>
|
||||
<Table.Th>Date</Table.Th>
|
||||
<Table.Th>Facility</Table.Th>
|
||||
<Table.Th>Yard</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((row) => {
|
||||
const payload = toPayload(row);
|
||||
return (
|
||||
<Table.Tr key={row.containerNumber}>
|
||||
<Table.Td>{payload.containerNumber}</Table.Td>
|
||||
<Table.Td>{payload.containerSize ? `${payload.containerSize} ft` : "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4} wrap="nowrap">
|
||||
<Text size="sm">{payload.companyName || "—"}</Text>
|
||||
{payload.companyName && !payload.customerId && (
|
||||
<Badge size="xs" color="orange" variant="light">
|
||||
New
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>{payload.returnedBy ?? "—"}</Table.Td>
|
||||
<Table.Td>
|
||||
{payload.returnDate
|
||||
? new Date(payload.returnDate).toLocaleDateString()
|
||||
: "—"}
|
||||
</Table.Td>
|
||||
<Table.Td>{payload.facility ?? "—"}</Table.Td>
|
||||
<Table.Td>{payload.yard ?? "—"}</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea.Autosize>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
reset();
|
||||
onClose();
|
||||
}}
|
||||
disabled={uploadMutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
disabled={rows.length === 0 || blockers.length > 0}
|
||||
loading={uploadMutation.isPending}
|
||||
>
|
||||
Upload {rows.length > 0 ? `${rows.length} containers` : ""}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
import { parseContainerReturnExcel } from "./container-return-excel";
|
||||
|
||||
/** Build an in-memory .xlsx and hand it back as a File, like the dropzone would. */
|
||||
function sheetFile(aoa: unknown[][]): File {
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), "Sheet1");
|
||||
const buf = XLSX.write(wb, { type: "array", bookType: "xlsx" }) as ArrayBuffer;
|
||||
return new File([buf], "returns.xlsx");
|
||||
}
|
||||
|
||||
const HEADERS = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Returned By",
|
||||
"Returned Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Condition",
|
||||
"Handover Note",
|
||||
];
|
||||
|
||||
describe("parseContainerReturnExcel", () => {
|
||||
it("parses a good sheet, normalizing size and returned-by", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([
|
||||
["Yard tally — August"], // title row above the header is ignored
|
||||
HEADERS,
|
||||
["temu1234567", "40ft", "Acme PLC", "EDR last mile", "2026-08-14", "Gelan", "A", "1", "", ""],
|
||||
["MSCU7654321", "20", "Other Trading", "Self haul", "2026-08-15", "Gelan", "", "", "Dented", "n"],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.rows).toHaveLength(2);
|
||||
expect(result.rows[0].containerNumber).toBe("TEMU1234567");
|
||||
expect(result.rows[0].containerSize).toBe("40");
|
||||
expect(result.rows[0].returnedBy).toBe("EDR");
|
||||
expect(result.rows[0].companyName).toBe("Acme PLC");
|
||||
expect(result.rows[0].returnDate?.startsWith("2026-08-14")).toBe(true);
|
||||
expect(result.rows[1].containerSize).toBe("20");
|
||||
expect(result.rows[1].returnedBy).toBe("CUSTOMER");
|
||||
});
|
||||
|
||||
it("rejects the whole file when a container number is invalid", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([HEADERS, ["NOTACONTAINER", "40", "Acme", "EDR", "", "", "", "", "", ""]]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors[0]).toContain("Row 2");
|
||||
});
|
||||
|
||||
it("rejects duplicate container numbers", async () => {
|
||||
const result = await parseContainerReturnExcel(
|
||||
sheetFile([
|
||||
HEADERS,
|
||||
["TEMU1234567", "40", "Acme", "EDR", "", "", "", "", "", ""],
|
||||
["temu1234567", "20", "Acme", "EDR", "", "", "", "", "", ""],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.rows).toEqual([]);
|
||||
expect(result.errors.some((e) => e.includes("appears 2 times"))).toBe(true);
|
||||
});
|
||||
|
||||
it("errors when there is no container-number column", async () => {
|
||||
const result = await parseContainerReturnExcel(sheetFile([["Company", "Yard"], ["Acme", "A"]]));
|
||||
expect(result.errors[0]).toContain("Container Number");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
import * as XLSX from "xlsx";
|
||||
|
||||
// Excel import for empties already sitting in an EDR yard that were never
|
||||
// entered in the system. One spreadsheet row per container. All-or-nothing —
|
||||
// any bad row rejects the whole file with row-numbered errors, so a partial
|
||||
// backfill can never silently drop boxes.
|
||||
|
||||
// ISO 6346: 4-letter prefix (owner code + category id) + 7 digits.
|
||||
const ISO_CONTAINER_NUMBER_REGEX = /^[A-Z]{4}\d{7}$/;
|
||||
|
||||
export interface ParsedReturnRow {
|
||||
containerNumber: string;
|
||||
containerSize: "20" | "40" | null;
|
||||
companyName: string;
|
||||
returnedBy: "EDR" | "CUSTOMER" | null;
|
||||
returnDate: string | null;
|
||||
facility: string;
|
||||
yard: string;
|
||||
zone: string;
|
||||
condition: string;
|
||||
handoverNote: string;
|
||||
}
|
||||
|
||||
export interface ContainerReturnExcelResult {
|
||||
rows: ParsedReturnRow[];
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
type ColumnKey =
|
||||
| "containerNumber"
|
||||
| "containerSize"
|
||||
| "companyName"
|
||||
| "returnedBy"
|
||||
| "returnDate"
|
||||
| "facility"
|
||||
| "yard"
|
||||
| "zone"
|
||||
| "condition"
|
||||
| "handoverNote";
|
||||
|
||||
/** Match a header cell to a known column, tolerant of casing/spacing/punctuation. */
|
||||
function headerKey(raw: string): ColumnKey | null {
|
||||
const h = raw.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (!h) return null;
|
||||
if (h.includes("size") || h.includes("type")) return "containerSize";
|
||||
if (h.includes("company") || h.includes("customer") || h.includes("consignee")) return "companyName";
|
||||
if (h.includes("returnedby") || h.includes("haul") || h.includes("truck")) return "returnedBy";
|
||||
if (h.includes("date")) return "returnDate";
|
||||
if (h.includes("facility") || h.includes("warehouse") || h.includes("terminal")) return "facility";
|
||||
if (h.includes("yard")) return "yard";
|
||||
if (h.includes("zone")) return "zone";
|
||||
if (h.includes("condition") || h.includes("damage")) return "condition";
|
||||
if (h.includes("note") || h.includes("remark")) return "handoverNote";
|
||||
// Least specific last, so "Container Size" is not eaten by "container".
|
||||
if (h.includes("container") || h.includes("number")) return "containerNumber";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "20", "20ft", "40 HC" … → '20' | '40' | null. */
|
||||
function normalizeSize(raw: string): "20" | "40" | null {
|
||||
const digits = raw.replace(/[^0-9]/g, "");
|
||||
if (digits.startsWith("20")) return "20";
|
||||
if (digits.startsWith("40") || digits.startsWith("45")) return "40";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** "EDR", "EDR last mile", "customer", "self haul" … */
|
||||
function normalizeReturnedBy(raw: string): "EDR" | "CUSTOMER" | null {
|
||||
const v = raw.toLowerCase();
|
||||
if (!v.trim()) return null;
|
||||
if (v.includes("edr")) return "EDR";
|
||||
if (v.includes("customer") || v.includes("self")) return "CUSTOMER";
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel dates arrive either as a serial number (raw cells) or as text. Returns
|
||||
* an ISO instant, or null when the cell is empty/unparseable.
|
||||
*/
|
||||
function normalizeDate(raw: string): string | null {
|
||||
const v = raw.trim();
|
||||
if (!v) return null;
|
||||
// Excel serial: days since 1899-12-30.
|
||||
if (/^\d{1,6}(\.\d+)?$/.test(v)) {
|
||||
const serial = Number(v);
|
||||
if (serial > 20000 && serial < 80000) {
|
||||
return new Date(Math.round((serial - 25569) * 86400000)).toISOString();
|
||||
}
|
||||
}
|
||||
const parsed = new Date(v);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded workbook into one row per empty container. Returns either
|
||||
* the full row set or the list of row-numbered problems — never both.
|
||||
*/
|
||||
export async function parseContainerReturnExcel(file: File): Promise<ContainerReturnExcelResult> {
|
||||
let sheet: XLSX.WorkSheet | undefined;
|
||||
try {
|
||||
const workbook = XLSX.read(await file.arrayBuffer(), { type: "array" });
|
||||
sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
} catch {
|
||||
return { rows: [], errors: ["Could not read the file — is it a valid Excel file?"] };
|
||||
}
|
||||
if (!sheet) return { rows: [], errors: ["The file has no sheets."] };
|
||||
|
||||
const grid = XLSX.utils.sheet_to_json<string[]>(sheet, { header: 1, raw: false, defval: "" });
|
||||
|
||||
// First row carrying a container-number column is the header; titles and
|
||||
// blank rows above it are ignored.
|
||||
let headerRowIdx = -1;
|
||||
let columns: Array<ColumnKey | null> = [];
|
||||
for (let i = 0; i < grid.length; i++) {
|
||||
const mapped = (grid[i] ?? []).map((c) => headerKey(String(c ?? "")));
|
||||
if (mapped.includes("containerNumber")) {
|
||||
headerRowIdx = i;
|
||||
columns = mapped;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (headerRowIdx < 0) {
|
||||
return {
|
||||
rows: [],
|
||||
errors: [
|
||||
'Could not find a "Container Number" column — download the template to see the expected format.',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const rows: ParsedReturnRow[] = [];
|
||||
const errors: string[] = [];
|
||||
const numberCounts = new Map<string, number>();
|
||||
|
||||
for (let i = headerRowIdx + 1; i < grid.length; i++) {
|
||||
const cells = grid[i] ?? [];
|
||||
if (cells.every((c) => String(c ?? "").trim() === "")) continue;
|
||||
const rowNo = i + 1; // 1-based, as shown in Excel
|
||||
|
||||
const cell = (key: ColumnKey) => {
|
||||
const idx = columns.indexOf(key);
|
||||
return idx >= 0 ? String(cells[idx] ?? "").trim() : "";
|
||||
};
|
||||
|
||||
const containerNumber = cell("containerNumber").toUpperCase().replace(/\s/g, "");
|
||||
if (!ISO_CONTAINER_NUMBER_REGEX.test(containerNumber)) {
|
||||
errors.push(
|
||||
`Row ${rowNo}: "${cell("containerNumber") || "—"}" is not a valid ISO container number (e.g. TEMU1234567).`,
|
||||
);
|
||||
} else {
|
||||
numberCounts.set(containerNumber, (numberCounts.get(containerNumber) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const sizeRaw = cell("containerSize");
|
||||
const containerSize = sizeRaw ? normalizeSize(sizeRaw) : null;
|
||||
if (sizeRaw && !containerSize) {
|
||||
errors.push(`Row ${rowNo}: container size "${sizeRaw}" is not 20 or 40.`);
|
||||
}
|
||||
|
||||
const returnedByRaw = cell("returnedBy");
|
||||
const returnedBy = normalizeReturnedBy(returnedByRaw);
|
||||
if (returnedByRaw && !returnedBy) {
|
||||
errors.push(`Row ${rowNo}: returned by "${returnedByRaw}" must be EDR or CUSTOMER.`);
|
||||
}
|
||||
|
||||
const dateRaw = cell("returnDate");
|
||||
const returnDate = normalizeDate(dateRaw);
|
||||
if (dateRaw && !returnDate) {
|
||||
errors.push(`Row ${rowNo}: returned date "${dateRaw}" is not a date.`);
|
||||
}
|
||||
|
||||
rows.push({
|
||||
containerNumber,
|
||||
containerSize,
|
||||
companyName: cell("companyName"),
|
||||
returnedBy,
|
||||
returnDate,
|
||||
facility: cell("facility"),
|
||||
yard: cell("yard"),
|
||||
zone: cell("zone"),
|
||||
condition: cell("condition"),
|
||||
handoverNote: cell("handoverNote"),
|
||||
});
|
||||
}
|
||||
|
||||
numberCounts.forEach((count, num) => {
|
||||
if (count > 1) {
|
||||
errors.push(`Container number ${num} appears ${count} times — numbers must be unique.`);
|
||||
}
|
||||
});
|
||||
|
||||
if (rows.length === 0 && errors.length === 0) {
|
||||
errors.push("The sheet has no container rows below the header.");
|
||||
}
|
||||
|
||||
return errors.length > 0 ? { rows: [], errors } : { rows, errors: [] };
|
||||
}
|
||||
|
||||
/** Download the import template with one filled sample row. */
|
||||
export function downloadContainerReturnTemplate() {
|
||||
const headers = [
|
||||
"Container Number",
|
||||
"Container Size",
|
||||
"Company",
|
||||
"Returned By",
|
||||
"Returned Date",
|
||||
"Facility",
|
||||
"Yard",
|
||||
"Zone",
|
||||
"Condition",
|
||||
"Handover Note",
|
||||
];
|
||||
const sample = [
|
||||
"TEMU1234567",
|
||||
"40",
|
||||
"Acme Import PLC",
|
||||
"CUSTOMER",
|
||||
new Date().toISOString().split("T")[0],
|
||||
"Gelan Multipurpose port",
|
||||
"Yard A",
|
||||
"Zone 1",
|
||||
"Sound",
|
||||
"Backfilled from yard tally sheet",
|
||||
];
|
||||
|
||||
const sheet = XLSX.utils.aoa_to_sheet([headers, sample]);
|
||||
sheet["!cols"] = headers.map((h) => ({ wch: Math.max(h.length + 2, 18) }));
|
||||
const workbook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, "Container Returns");
|
||||
XLSX.writeFile(workbook, "container-return-import-template.xlsx");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { customersService } from "@/services/customers.service";
|
||||
|
||||
/**
|
||||
* Registered customer companies, as Autocomplete options. The picker is an
|
||||
* Autocomplete rather than a Select on purpose: a company that is not on the
|
||||
* system yet is typed in, and only the name is kept.
|
||||
*/
|
||||
export function useCompanyOptions() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["companies-autocomplete"],
|
||||
queryFn: () => customersService.list({ page: 1, pageSize: 1000 }),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const companies = data?.items ?? [];
|
||||
|
||||
return {
|
||||
loading: isLoading,
|
||||
names: companies.map((c) => c.name),
|
||||
/** Exact (case-insensitive) name match → company id, else undefined. */
|
||||
resolveId: (name: string): string | undefined =>
|
||||
companies.find((c) => c.name.trim().toLowerCase() === name.trim().toLowerCase())?.id,
|
||||
};
|
||||
}
|
||||
@@ -791,6 +791,7 @@ export const URL_CONSTANTS = {
|
||||
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||
EMPTY_CONTAINER_RETURNS: "/import-operations/empty-container-returns",
|
||||
EMPTY_CONTAINER_RETURNS_BULK: "/import-operations/empty-container-returns/bulk",
|
||||
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||
`/import-operations/empty-container-returns/${id}/status`,
|
||||
EMPTY_CONTAINER_RETURNS_LOAD_ON_TRAIN:
|
||||
|
||||
@@ -18,8 +18,9 @@ import {
|
||||
Textarea,
|
||||
Select,
|
||||
Checkbox,
|
||||
Autocomplete,
|
||||
} from "@mantine/core";
|
||||
import { ChevronDown, ChevronRight, FileText, History } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, FileText, History, Upload } from "lucide-react";
|
||||
import { DataTable, type ColumnDef } from "@edr/ui-common";
|
||||
|
||||
import { PageContainer, PageHeader } from "@/components/page";
|
||||
@@ -27,6 +28,8 @@ import ListControls from "@/components/common/ListControls";
|
||||
import RuleEngineListFooter from "@/components/ruleEngine/RuleEngineListFooter";
|
||||
import { extractDownloadErrorMessage } from "@/components/warehouses/options";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import BulkContainerReturnModal from "@/components/warehouses/BulkContainerReturnModal";
|
||||
import { useCompanyOptions } from "@/components/warehouses/useCompanyOptions";
|
||||
import { OverviewHorizontalBarChart } from "@/components/overview/OverviewHorizontalBarChart";
|
||||
import { OverviewStackedBarChart } from "@/components/overview/OverviewStackedBarChart";
|
||||
import { useListControls, toDayString } from "@/hooks/useListControls";
|
||||
@@ -102,6 +105,7 @@ export default function ContainerReturnsPage() {
|
||||
const [filterType, setFilterType] = useState<ReturnType>("all");
|
||||
const [returnModalOpen, setReturnModalOpen] = useState(false);
|
||||
const [standaloneModalOpen, setStandaloneModalOpen] = useState(false);
|
||||
const [bulkModalOpen, setBulkModalOpen] = useState(false);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [historyRow, setHistoryRow] = useState<any | null>(null);
|
||||
const [allocateRow, setAllocateRow] = useState<EmptyContainerReturn | null>(null);
|
||||
@@ -287,11 +291,14 @@ export default function ContainerReturnsPage() {
|
||||
bookingId: string;
|
||||
customerId: string | null;
|
||||
returnType: "EDR" | "CUSTOMER";
|
||||
companyName?: string;
|
||||
containers: Array<{
|
||||
containerNumber: string;
|
||||
containerSize?: EmptyContainerSize;
|
||||
returnDate: string;
|
||||
warehouse: string;
|
||||
yard?: string;
|
||||
zone?: string;
|
||||
condition?: string;
|
||||
handoverNote?: string;
|
||||
}>;
|
||||
@@ -306,7 +313,10 @@ export default function ContainerReturnsPage() {
|
||||
returnDate: new Date(container.returnDate).toISOString(),
|
||||
bookingId: truck.bookingId,
|
||||
customerId: truck.customerId ?? undefined,
|
||||
companyName: truck.companyName,
|
||||
facility: container.warehouse,
|
||||
yard: container.yard,
|
||||
zone: container.zone,
|
||||
condition: container.condition,
|
||||
handoverNote: container.handoverNote,
|
||||
returnedBy: truck.returnType,
|
||||
@@ -319,7 +329,9 @@ export default function ContainerReturnsPage() {
|
||||
onSuccess: () => {
|
||||
toast({ title: "Container returns recorded" });
|
||||
qc.invalidateQueries({ queryKey: ["container-returns", bookingIds] });
|
||||
qc.invalidateQueries({ queryKey: ["empty-container-returns"] });
|
||||
setReturnModalOpen(false);
|
||||
setStandaloneModalOpen(false);
|
||||
setActiveKey(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -376,6 +388,11 @@ export default function ContainerReturnsPage() {
|
||||
header: "Booking Ref",
|
||||
cell: ({ row }) => (row.original.bookingId ? "Associated" : "—"),
|
||||
},
|
||||
{
|
||||
id: "company",
|
||||
header: "Company",
|
||||
cell: ({ row }) => row.original.companyName || "—",
|
||||
},
|
||||
{
|
||||
id: "returnedBy",
|
||||
header: "Returned By",
|
||||
@@ -510,9 +527,16 @@ export default function ContainerReturnsPage() {
|
||||
{ label: "Customer Self-Haul", value: "customer" },
|
||||
]}
|
||||
/>
|
||||
<Button onClick={() => setStandaloneModalOpen(true)}>
|
||||
Record Return
|
||||
</Button>
|
||||
<Group gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
leftSection={<Upload size={16} />}
|
||||
onClick={() => setBulkModalOpen(true)}
|
||||
>
|
||||
Bulk Upload
|
||||
</Button>
|
||||
<Button onClick={() => setStandaloneModalOpen(true)}>Record Return</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{returnedContainers.length > 0 && (
|
||||
@@ -688,6 +712,12 @@ export default function ContainerReturnsPage() {
|
||||
loading={createReturnsMutation.isPending}
|
||||
/>
|
||||
|
||||
<BulkContainerReturnModal
|
||||
opened={bulkModalOpen}
|
||||
onClose={() => setBulkModalOpen(false)}
|
||||
onUploaded={() => qc.invalidateQueries({ queryKey: ["empty-container-returns"] })}
|
||||
/>
|
||||
|
||||
<ExportTrainAllocationModal
|
||||
row={allocateRow}
|
||||
onClose={() => setAllocateRow(null)}
|
||||
@@ -991,6 +1021,7 @@ interface StandaloneReturnModalProps {
|
||||
|
||||
function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: StandaloneReturnModalProps) {
|
||||
const [containerNumber, setContainerNumber] = useState<string>("");
|
||||
const [company, setCompany] = useState<string>("");
|
||||
const [containerSize, setContainerSize] = useState<EmptyContainerSize | null>(null);
|
||||
const [returnedBy, setReturnedBy] = useState<"EDR" | "CUSTOMER" | null>(null);
|
||||
const [returnDate, setReturnDate] = useState<string>(new Date().toISOString().split("T")[0]);
|
||||
@@ -1009,6 +1040,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
|
||||
const warehouses = (warehousesResponse as any)?.data ?? warehousesResponse ?? [];
|
||||
|
||||
const companies = useCompanyOptions();
|
||||
const { data: yards } = useWarehouseYards(warehouse ?? undefined);
|
||||
const { data: zones } = useWarehouseZones(yardId ?? undefined);
|
||||
|
||||
@@ -1047,7 +1079,8 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
trucks: [
|
||||
{
|
||||
bookingId: null,
|
||||
customerId: null,
|
||||
customerId: company ? (companies.resolveId(company) ?? null) : null,
|
||||
companyName: company || undefined,
|
||||
returnType: returnedBy,
|
||||
containers: [
|
||||
{
|
||||
@@ -1066,6 +1099,7 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
});
|
||||
|
||||
setContainerNumber("");
|
||||
setCompany("");
|
||||
setContainerSize(null);
|
||||
setReturnedBy(null);
|
||||
setReturnDate(new Date().toISOString().split("T")[0]);
|
||||
@@ -1092,6 +1126,16 @@ function StandaloneReturnModal({ opened, onClose, onSubmit, loading }: Standalon
|
||||
required
|
||||
/>
|
||||
|
||||
<Autocomplete
|
||||
label="Company"
|
||||
description="Pick a registered customer, or type a company that is not on the system yet"
|
||||
placeholder={companies.loading ? "Loading companies…" : "Search or type a company"}
|
||||
data={companies.names}
|
||||
value={company}
|
||||
onChange={setCompany}
|
||||
limit={20}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Returned By"
|
||||
placeholder="Select truck type"
|
||||
|
||||
@@ -124,6 +124,17 @@ export const importOperationsService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/** Backfill empties already in the yard. All-or-nothing on the server. */
|
||||
bulkCreateEmptyReturns: async (
|
||||
returns: CreateEmptyContainerReturnPayload[],
|
||||
): Promise<EmptyContainerReturn[]> => {
|
||||
const response = await client.post<EmptyContainerReturn[]>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS_BULK,
|
||||
{ returns },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
loadEmptyContainersOnTrain: async (
|
||||
payload: LoadEmptyContainersOnTrainPayload,
|
||||
): Promise<EmptyContainerReturn[]> => {
|
||||
|
||||
@@ -93,6 +93,8 @@ export interface EmptyContainerReturn {
|
||||
containerNumber: string;
|
||||
bookingId: string | null;
|
||||
customerId: string | null;
|
||||
/** Owning company as text — set for backfilled boxes whose company is unregistered. */
|
||||
companyName: string | null;
|
||||
returnDate: string;
|
||||
facility: string | null;
|
||||
yard: string | null;
|
||||
@@ -122,6 +124,7 @@ export interface CreateEmptyContainerReturnPayload {
|
||||
containerSize?: EmptyContainerSize;
|
||||
bookingId?: string;
|
||||
customerId?: string;
|
||||
companyName?: string;
|
||||
returnDate?: string;
|
||||
facility?: string;
|
||||
yard?: string;
|
||||
|
||||
Reference in New Issue
Block a user