mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
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.
536 lines
21 KiB
TypeScript
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, '&')
|
|
.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');
|
|
}
|
|
}
|
|
}
|