mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Empties had no way onto a departure: the return record could name a train but nothing seated it on a wagon. Export schedules now expose a loading action that packs selected returns onto free wagons at one 40ft or two 20ft each, enforced both in the picker and in the API (existing empties on the schedule count against their wagon). Adds container_size, train_schedule_id and wagon_sequence_no to freight.empty_container_returns.
281 lines
11 KiB
TypeScript
281 lines
11 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { In, Repository } from 'typeorm';
|
|
|
|
import {
|
|
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 } 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 {
|
|
constructor(
|
|
@InjectRepository(DjiboutiIncident)
|
|
private readonly incidents: Repository<DjiboutiIncident>,
|
|
@InjectRepository(ImportCustomsFinalization)
|
|
private readonly customs: Repository<ImportCustomsFinalization>,
|
|
@InjectRepository(EmptyContainerReturn)
|
|
private readonly emptyReturns: Repository<EmptyContainerReturn>,
|
|
) {}
|
|
|
|
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 });
|
|
}
|
|
|
|
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
|
|
const returnDate = dto.returnDate ? new Date(dto.returnDate) : new Date();
|
|
return this.emptyReturns.save(
|
|
this.emptyReturns.create({
|
|
containerNumber: dto.containerNumber,
|
|
bookingId: dto.bookingId ?? null,
|
|
customerId: dto.customerId ?? 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 },
|
|
],
|
|
}),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 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');
|
|
}
|
|
}
|
|
}
|