Files
edr-platform/apps/edr-freight-api/src/modules/import-operations/import-operations.service.ts
Hagernesh 3f4fd8648a 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.
2026-08-28 11:38:19 +00:00

536 lines
21 KiB
TypeScript

import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Not, Repository } from 'typeorm';
import { NotificationAudience, NotificationType } from '@edr/types';
import { LogoSettingsService } from '../logo-settings/logo-settings.service';
import { logoImageCss, logoMarkup } from '../billing/documents/logo-markup.util';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
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,
LoadEmptyContainersOnTrainDto,
RecordDeclarationDto,
AssignCustomsRiskDto,
UpdateEmptyContainerReturnStatusDto,
UploadImportCustomsDocumentDto,
} from './dto/import-operations.dto';
import {
DjiboutiIncident,
type DjiboutiIncidentType,
} from './entities/djibouti-incident.entity';
import { assertWagonLoad } from './empty-container-wagon.util';
import {
EmptyContainerReturn,
type EmptyContainerReturnStatus,
} from './entities/empty-container-return.entity';
import {
ImportCustomsFinalization,
type ImportCustomsDocumentType,
} from './entities/import-customs-finalization.entity';
const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
'SEAL_BROKEN',
'CONTAINER_OPENED',
'CONTAINER_DAMAGED',
'FLUID_LEAKING',
];
@Injectable()
export class ImportOperationsService {
private readonly logger = new Logger(ImportOperationsService.name);
constructor(
@InjectRepository(DjiboutiIncident)
private readonly incidents: Repository<DjiboutiIncident>,
@InjectRepository(ImportCustomsFinalization)
private readonly customs: Repository<ImportCustomsFinalization>,
@InjectRepository(EmptyContainerReturn)
private readonly emptyReturns: Repository<EmptyContainerReturn>,
private readonly pdfDocuments: WarehouseReleaseDocumentService,
private readonly logoSettings: LogoSettingsService,
private readonly inbox: NotificationInboxService,
private readonly notifications: NotificationsService,
) {}
listIncidents(bookingId?: string) {
return this.incidents.find({
where: bookingId ? { bookingId } : {},
order: { reportedAt: 'DESC', createdAt: 'DESC' } as never,
});
}
async createIncident(dto: CreateDjiboutiIncidentDto) {
const photos = dto.photos ?? [];
if (DAMAGE_INCIDENTS.includes(dto.incidentType) && photos.length === 0) {
throw new BadRequestException('Photos are required for damage-related Djibouti incidents');
}
const incident = await this.incidents.save(
this.incidents.create({
bookingId: dto.bookingId,
containerNumber: dto.containerNumber ?? null,
cargoId: dto.cargoId ?? null,
facility: dto.facility ?? null,
station: dto.station ?? null,
incidentType: dto.incidentType,
description: dto.description,
photos,
reportedBy: dto.reportedBy ?? null,
reportedAt: dto.reportedAt ? new Date(dto.reportedAt) : new Date(),
}),
);
console.log(
`[NOTIFY] Djibouti incident ${incident.incidentType} for booking ${incident.bookingId}; notify Global Logistics Ethiopia and customer.`,
);
console.log(
`[MOVEMENT] Attach incident ${incident.id} to booking ${incident.bookingId} movement history.`,
);
return incident;
}
async getCustoms(bookingId: string) {
return this.getOrCreateCustoms(bookingId);
}
async uploadCustomsDocument(bookingId: string, dto: UploadImportCustomsDocumentDto) {
const row = await this.getOrCreateCustoms(bookingId);
const documents = { ...(row.documents ?? {}), [dto.documentType]: dto.fileId };
await this.customs.update(row.id, {
documents,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
return this.getCustoms(bookingId);
}
async recordDeclaration(bookingId: string, dto: RecordDeclarationDto) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
declarationSerialNumber: dto.declarationSerialNumber,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
return this.getCustoms(bookingId);
}
async notifyDutiesTaxes(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
dutiesTaxesNotifiedAt: row.dutiesTaxesNotifiedAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
console.log(`[NOTIFY] Duties and taxes notification sent for booking ${bookingId}.`);
return this.getCustoms(bookingId);
}
async markDutiesTaxesPaid(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
this.assertDocument(row, 'CUSTOMER_PAYMENT_SLIP', 'Customer payment slip is required before marking duties and taxes paid');
await this.customs.update(row.id, {
dutiesTaxesPaidAt: row.dutiesTaxesPaidAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
return this.getCustoms(bookingId);
}
async assignRisk(bookingId: string, dto: AssignCustomsRiskDto) {
const row = await this.getOrCreateCustoms(bookingId);
await this.customs.update(row.id, {
customsRisk: dto.risk,
performedBy: dto.performedBy ?? row.performedBy ?? null,
});
console.log(`[NOTIFY] Customs risk ${dto.risk} assigned for booking ${bookingId}; notify customer.`);
return this.getCustoms(bookingId);
}
async markReleasePermitted(bookingId: string, dto: ImportOperationActionDto = {}) {
const row = await this.getOrCreateCustoms(bookingId);
this.assertReleaseReady(row);
await this.customs.update(row.id, {
importReleasePermittedAt: row.importReleasePermittedAt ?? new Date(),
completedAt: row.completedAt ?? new Date(),
performedBy: dto.performedBy ?? row.performedBy ?? null,
notes: dto.notes ?? row.notes ?? null,
});
console.log(`[NOTIFY] Import release permitted for booking ${bookingId}; notify customer.`);
return this.getCustoms(bookingId);
}
listEmptyReturns() {
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
}
listEmptyReturnsForBooking(bookingId: string) {
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
}
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
const saved = await this.emptyReturns.save(
this.emptyReturns.create({
containerNumber: dto.containerNumber,
bookingId: dto.bookingId ?? null,
customerId: dto.customerId ?? null,
companyName: dto.companyName ?? null,
returnDate,
containerSize: dto.containerSize ?? null,
facility: dto.facility ?? null,
yard: dto.yard ?? null,
zone: dto.zone ?? null,
condition: dto.condition ?? null,
handoverNote: dto.handoverNote ?? null,
performedBy: dto.performedBy ?? null,
returnedBy: dto.returnedBy ?? null,
statusHistory: [
{ status: 'RETURNED', changedAt: returnDate.toISOString(), performedBy: dto.performedBy ?? null },
],
}),
);
// RETURNED is the physical interchange itself — the customer's/trucker's
// custody of the box ends here, EDR's begins. The receipt exists from this
// point on, so tell the customer now, not at some later internal status.
// Standalone returns (no booking) have no company to notify.
if (saved.bookingId) {
await this.notifyEquipmentInterchangeReady(saved);
}
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
* the same schedule count against that wagon, so incremental loads cannot
* quietly double-book a slot.
*
* ponytail: does not check the wagon is free of cargo bookings — the loading
* UI picks only unallocated wagons from the schedule's plan. Cross-check here
* if empties ever get loaded from another client.
*/
async loadEmptyReturnsOnTrain(dto: LoadEmptyContainersOnTrainDto) {
const ids = dto.items.map((item) => item.id);
const rows = await this.emptyReturns.find({ where: { id: In(ids) } });
const missing = ids.filter((id) => !rows.some((row) => row.id === id));
if (missing.length) {
throw new NotFoundException(`Empty container return(s) not found: ${missing.join(', ')}`);
}
const alreadyOnTrain = await this.emptyReturns.find({
where: { trainScheduleId: dto.trainScheduleId },
});
const byWagon = new Map<number, string[]>();
for (const row of alreadyOnTrain) {
if (row.wagonSequenceNo == null || ids.includes(row.id)) continue;
byWagon.set(row.wagonSequenceNo, [
...(byWagon.get(row.wagonSequenceNo) ?? []),
row.containerSize ?? '40',
]);
}
for (const item of dto.items) {
byWagon.set(item.wagonSequenceNo, [
...(byWagon.get(item.wagonSequenceNo) ?? []),
item.containerSize,
]);
}
assertWagonLoad(byWagon);
const changedAt = new Date().toISOString();
for (const item of dto.items) {
const row = rows.find((candidate) => candidate.id === item.id)!;
await this.emptyReturns.update(item.id, {
status: 'WAGON_ALLOCATED',
containerSize: item.containerSize,
trainScheduleId: dto.trainScheduleId,
wagonSequenceNo: item.wagonSequenceNo,
wagonAllocationReference: dto.trainNumber ?? dto.trainScheduleId,
performedBy: dto.performedBy ?? row.performedBy ?? null,
statusHistory: [
...(row.statusHistory ?? []),
{ status: 'WAGON_ALLOCATED' as const, changedAt, performedBy: dto.performedBy ?? null },
],
});
}
return this.emptyReturns.find({ where: { trainScheduleId: dto.trainScheduleId } });
}
async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {
throw new NotFoundException(`Empty container return ${id} not found`);
}
await this.emptyReturns.update(id, {
status: dto.status,
wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null,
handoverNote: dto.handoverNote ?? row.handoverNote ?? null,
performedBy: dto.performedBy ?? row.performedBy ?? null,
statusHistory: [
...(row.statusHistory ?? []),
{ status: dto.status, changedAt: new Date().toISOString(), performedBy: dto.performedBy ?? row.performedBy ?? null },
],
});
return this.emptyReturns.findOneOrFail({ where: { id } });
}
private async notifyEquipmentInterchangeReady(row: EmptyContainerReturn): Promise<void> {
try {
const [booking]: Array<{ companyId: string | null; reference: string }> =
await this.emptyReturns.manager.query(
`SELECT company_id AS "companyId", reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[row.bookingId],
);
if (!booking?.companyId) return;
const body = `Container ${row.containerNumber} was handed over${
row.facility ? ` at ${row.facility}` : ''
}. Your equipment interchange receipt for booking ${booking.reference} is ready to download from the portal.`;
await this.inbox.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.DOCUMENT_ACTION,
title: 'Equipment interchange receipt ready',
body,
link: `/bookings/${row.bookingId}`,
data: { bookingId: row.bookingId, emptyContainerReturnId: row.id },
});
await sendCompanyChannels(this.emptyReturns.manager.connection, this.notifications, booking.companyId, body);
} catch (err) {
this.logger.warn(
`Failed to notify equipment interchange ready for return ${row.id}: ${(err as Error).message}`,
);
}
}
async getEmptyReturnOrThrow(id: string): Promise<EmptyContainerReturn> {
const row = await this.emptyReturns.findOne({ where: { id } });
if (!row) {
throw new NotFoundException(`Empty container return ${id} not found`);
}
return row;
}
/**
* Equipment Interchange Receipt — container number/size, exact return
* timestamp, depot, condition, and the carrier/booking reference that ties
* the box back to its bill of lading. Handed to the customer to download.
*/
async equipmentInterchangeDocument(
row: EmptyContainerReturn,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = row.bookingId
? ((
await this.emptyReturns.manager.query(
`SELECT b.reference, c.name AS company_name
FROM freight.bookings b
LEFT JOIN freight.companies c ON c.id = b.company_id
WHERE b.id = $1`,
[row.bookingId],
)
)[0] as { reference: string; company_name: string | null } | undefined)
: undefined;
const html = this.buildEquipmentInterchangeHtml(row, booking, {
logoImageUrl: await this.logoSettings.getLogoImageUrl(),
});
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Equipment interchange receipt');
return {
filename: `equipment-interchange-${row.containerNumber || row.id.slice(0, 8)}.pdf`,
buffer,
};
}
private buildEquipmentInterchangeHtml(
row: EmptyContainerReturn,
booking: { reference: string; company_name: string | null } | undefined,
opts: { logoImageUrl?: string | null },
): string {
const esc = (value: unknown) =>
String(value ?? '-')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const dateTime = (value: unknown) =>
value ? new Date(value as string | Date).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : '-';
const carrier =
row.returnedBy === 'EDR'
? 'EDR Last Mile'
: row.returnedBy === 'CUSTOMER'
? 'Customer Self-Haul'
: '-';
const rows: Array<[string, string]> = [
['Container Number', row.containerNumber],
['Container Size', row.containerSize ? `${row.containerSize}ft` : 'Not recorded'],
['Date & Time of Return', dateTime(row.returnDate)],
['Depot / Location', [row.facility, row.yard, row.zone].filter(Boolean).join(' — ') || '-'],
['Condition Status', row.condition || 'Good — no exceptions noted'],
['Carrier', carrier],
['Booking / BOL Reference', booking?.reference || 'Standalone — no booking'],
['Shipping Line / Customer', booking?.company_name || '-'],
['Current Status', row.status.replace(/_/g, ' ')],
['Handover Note', row.handoverNote || '-'],
];
const rowsHtml = rows
.map(
([label, value]) =>
`<tr><th>${esc(label)}</th><td>${esc(value)}</td></tr>`,
)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Equipment Interchange Receipt</title>
<style>
@page { size: A4; margin: 14mm; }
* { box-sizing: border-box; }
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
.top { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0f766e; padding-bottom: 12px; gap: 24px; }
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
h1 { margin: 6px 0 0; font-size: 22px; line-height: 1.1; }
.meta { text-align: right; font-size: 11px; color: #475569; }
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
${logoImageCss()}
table { width: 100%; border-collapse: collapse; margin-top: 20px; }
th, td { border: 1px solid #cbd5e1; padding: 8px 10px; font-size: 11.5px; text-align: left; vertical-align: top; }
th { width: 220px; background: #f8fafc; color: #475569; font-weight: 700; }
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 10.5px; color: #134e4a; }
.signatures { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; margin-top: 40px; }
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 40px; }
</style>
</head>
<body>
<div class="top">
<div>
${logoMarkup(opts.logoImageUrl)}
<div class="brand">Ethio-Djibouti Railway S.C.</div>
<h1>Equipment Interchange Receipt</h1>
</div>
<div class="meta">
Receipt No.
<strong>${esc(`EIR-${row.id.slice(0, 8).toUpperCase()}`)}</strong>
Generated: ${esc(new Date().toLocaleString('en-GB'))}
</div>
</div>
<table>
<tbody>
${rowsHtml}
</tbody>
</table>
<div class="notice">
This receipt confirms the physical interchange of the equipment described above at the
depot/location and time stated. Both parties should verify the container number, size,
and condition recorded here before signing.
</div>
<div class="signatures">
<div class="line">Depot officer name / signature / date</div>
<div class="line">Customer or driver name / signature / date</div>
</div>
</body>
</html>`;
}
private async getOrCreateCustoms(bookingId: string) {
const existing = await this.customs.findOne({ where: { bookingId } });
if (existing) return existing;
return this.customs.save(this.customs.create({ bookingId, documents: {} }));
}
private assertDocument(
row: ImportCustomsFinalization,
type: ImportCustomsDocumentType,
message: string,
) {
if (!row.documents?.[type]) {
throw new BadRequestException(message);
}
}
private assertReleaseReady(row: ImportCustomsFinalization) {
this.assertDocument(row, 'T1_CLOSURE_PROOF', 'T1 closure proof is required before import release');
this.assertDocument(row, 'IMPORT_RELEASE_PERMIT', 'Import release permit upload is required before release is permitted');
if (!row.declarationSerialNumber?.trim()) {
throw new BadRequestException('Declaration serial number is required before import release');
}
if (!row.customsRisk) {
throw new BadRequestException('Customs risk must be assigned before import release');
}
if (!row.dutiesTaxesPaidAt) {
throw new BadRequestException('Duties and taxes must be paid before import release');
}
}
}