mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-03 17:53:39 +00:00
A booking sold WITHOUT the return service had no way to send its empties back: the containers were the customer's problem and nothing in the system priced, billed or planned the movement. Adds the request flow end to end. The customer opens the booking, says how many containers are coming back and types each number; the request lands in a new backoffice queue (Empty Return Requests). Approval prices it off the same live WITH_RETURN route rate the rule engine bills when the service IS bought up front — per container, converted to birr, and overridable by the reviewer — and issues the invoice there and then. Payment settles through the normal invoice path, whose `empty_return_request.invoice.paid` event moves the request to PAID; the customer then books the return date and the truck. Scheduled requests surface on Container Returns as Planned Empty Returns, where confirming the arrival records the containers through the existing empty-container-return flow — which in turn closes the request once its last container is in. Container freight only, never a booking that already ships WITH_RETURN, and only from IN_TRANSIT onward: the empty comes back after delivery, so the option has to outlive ARRIVED. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
628 lines
25 KiB
TypeScript
628 lines
25 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 { EmptyReturnRequestsService } from '../empty-return-requests/empty-return-requests.service';
|
|
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 {
|
|
assembleEmptyReturnBookings,
|
|
EMPTY_RETURN_CLOSED_BOOKING_STATUSES,
|
|
WITH_RETURN_EQUIPMENT_VALUES,
|
|
type EmptyReturnBookingRow,
|
|
type EmptyReturnBookingUnitRow,
|
|
} from './empty-return-bookings.util';
|
|
import {
|
|
EmptyContainerReturn,
|
|
type EmptyContainerReturnListItem,
|
|
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,
|
|
private readonly emptyReturnRequests: EmptyReturnRequestsService,
|
|
) {}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Every empty return, booking-linked and standalone alike, in one list. The
|
|
* booking reference and the owning company are joined in so the table can
|
|
* show which booking a box came back on without a second round trip — a
|
|
* standalone row simply has neither, and falls back to the typed
|
|
* `company_name`.
|
|
*/
|
|
listEmptyReturns(): Promise<EmptyContainerReturnListItem[]> {
|
|
return this.emptyReturns.manager.query(`
|
|
SELECT
|
|
r.id,
|
|
r.container_number AS "containerNumber",
|
|
r.booking_id AS "bookingId",
|
|
b.reference AS "bookingReference",
|
|
r.customer_id AS "customerId",
|
|
COALESCE(r.company_name, c.name) AS "companyName",
|
|
r.return_date AS "returnDate",
|
|
r.facility,
|
|
r.yard,
|
|
r.zone,
|
|
r.condition,
|
|
r.handover_note AS "handoverNote",
|
|
r.status,
|
|
r.wagon_allocation_reference AS "wagonAllocationReference",
|
|
r.container_size AS "containerSize",
|
|
r.train_schedule_id AS "trainScheduleId",
|
|
r.wagon_sequence_no AS "wagonSequenceNo",
|
|
r.performed_by AS "performedBy",
|
|
r.returned_by AS "returnedBy",
|
|
r.status_history AS "statusHistory",
|
|
r.created_at AS "createdAt"
|
|
FROM freight.empty_container_returns r
|
|
LEFT JOIN freight.bookings b ON b.id = r.booking_id
|
|
LEFT JOIN freight.companies c ON c.id = b.company_id
|
|
WHERE r.deleted_at IS NULL
|
|
ORDER BY r.created_at DESC
|
|
`);
|
|
}
|
|
|
|
listEmptyReturnsForBooking(bookingId: string) {
|
|
return this.emptyReturns.find({ where: { bookingId }, order: { createdAt: 'DESC' } as never });
|
|
}
|
|
|
|
/**
|
|
* Bookings that ship WITH empty-container return and still owe empties, each
|
|
* with the containers that are to be returned — the ones the booking flagged
|
|
* `is_return`, carrying the empty return already recorded against each, if
|
|
* any.
|
|
*/
|
|
async listEmptyReturnBookings(): Promise<EmptyReturnBookingRow[]> {
|
|
const units: EmptyReturnBookingUnitRow[] = await this.emptyReturns.manager.query(
|
|
`SELECT b.id AS "bookingId",
|
|
b.reference AS "bookingReference",
|
|
b.status AS "bookingStatus",
|
|
b.equipment_return AS "equipmentReturn",
|
|
b.company_id AS "customerId",
|
|
c.name AS "companyName",
|
|
u.id AS "unitId",
|
|
u.container_number AS "containerNumber",
|
|
COALESCE(bc.container_size, ct.code) AS "containerSize",
|
|
ct.label AS "containerType",
|
|
r.id AS "returnId",
|
|
r.status AS "returnStatus"
|
|
FROM freight.booking_container_units u
|
|
JOIN freight.booking_container bc ON bc.id = u.booking_container_id AND bc.deleted_at IS NULL
|
|
JOIN freight.bookings b ON b.id = bc.booking_id AND b.deleted_at IS NULL
|
|
LEFT JOIN freight.companies c ON c.id = b.company_id
|
|
LEFT JOIN freight.container_types ct ON ct.id = bc.container_type_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT er.id, er.status
|
|
FROM freight.empty_container_returns er
|
|
WHERE er.deleted_at IS NULL
|
|
AND er.booking_id = b.id
|
|
AND upper(er.container_number) = upper(u.container_number)
|
|
ORDER BY er.created_at DESC
|
|
LIMIT 1
|
|
) r ON TRUE
|
|
WHERE u.deleted_at IS NULL
|
|
AND u.is_return = true
|
|
AND b.equipment_return = ANY($1)
|
|
AND b.status <> ALL($2)
|
|
ORDER BY b.created_at DESC, u.sort_order ASC`,
|
|
[WITH_RETURN_EQUIPMENT_VALUES, EMPTY_RETURN_CLOSED_BOOKING_STATUSES],
|
|
);
|
|
|
|
return assembleEmptyReturnBookings(units);
|
|
}
|
|
|
|
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);
|
|
// Closes the customer's scheduled request once its last container is in.
|
|
await this.emptyReturnRequests.settleScheduledForBooking(saved.bookingId);
|
|
}
|
|
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
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');
|
|
}
|
|
}
|
|
}
|