mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 09:00:57 +00:00
735 lines
27 KiB
TypeScript
735 lines
27 KiB
TypeScript
import {
|
||
BadRequestException,
|
||
ConflictException,
|
||
Injectable,
|
||
NotFoundException,
|
||
} from '@nestjs/common';
|
||
import { DataSource, IsNull } from 'typeorm';
|
||
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
||
|
||
import { BillingService } from '../billing/billing.service';
|
||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||
import { FilesService } from '../files/files.service';
|
||
import { Booking } from '../bookings/entities/booking.entity';
|
||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||
import { ImportDjiboutiOperation } from '../train-scheduling/entities/import-djibouti-operation.entity';
|
||
import {
|
||
ClearanceIncident,
|
||
IncidentType,
|
||
} from './entities/clearance-incident.entity';
|
||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||
import {
|
||
persistExportTransportUploads,
|
||
persistT1TransportUploads,
|
||
} from './phased-clearance.util';
|
||
|
||
/**
|
||
* Maps a GL post-booking document `code` to the milestone it auto-completes when
|
||
* uploaded (doc §11.3/§12.2 — doc-triggered milestones). Uploading the document
|
||
* marks the milestone done so the timeline advances without a separate click.
|
||
*/
|
||
const DOC_CODE_TO_MILESTONE: Record<string, string> = {
|
||
release_order: 'RELEASE_ORDER_SECURED', // export — GL DJ
|
||
delivery_order: 'DO_COLLECTED', // import — GL DJ
|
||
// t1_transport_document intentionally NOT doc-triggered: T1_CLOSED completes only
|
||
// when GL Ethiopia accepts the T1 set after the train arrives (closeT1).
|
||
import_release: 'IMPORT_RELEASE_GRANTED', // import — GL ET
|
||
full_in_interchange: 'OFFLOADED', // export — GL DJ
|
||
final_declaration: 'IMPORT_PROCESS_COMPLETED', // import — GL ET
|
||
export_transport_document: 'EXPORT_TRANSPORT_ISSUED', // export — GL ET post-allocation
|
||
};
|
||
|
||
/**
|
||
* Operational Global Logistics actions that hang off a shipment booking after GL
|
||
* creates it: station routing, damage/incident reporting, and the phased GL
|
||
* document uploads (Release Order, Delivery Order, T1, etc.) that advance
|
||
* doc-triggered milestones. See docs/new-doc.md §11–§13, gap matrix #14/#16/#18.
|
||
*/
|
||
@Injectable()
|
||
export class GlOperationsService {
|
||
constructor(
|
||
private readonly dataSource: DataSource,
|
||
private readonly filesService: FilesService,
|
||
private readonly milestoneService: ClearanceMilestoneService,
|
||
private readonly billingService: BillingService,
|
||
private readonly notifier: BookingLifecycleNotifierService,
|
||
) {}
|
||
|
||
private get bookings() {
|
||
return this.dataSource.getRepository(Booking);
|
||
}
|
||
|
||
private get incidents() {
|
||
return this.dataSource.getRepository(ClearanceIncident);
|
||
}
|
||
|
||
private async getBooking(bookingId: string): Promise<Booking> {
|
||
// company is loaded so customer notifications have a phone/email to target.
|
||
const booking = await this.bookings.findOne({
|
||
where: { id: bookingId },
|
||
relations: { company: true },
|
||
});
|
||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||
return booking;
|
||
}
|
||
|
||
/**
|
||
* Route a shipment to an origin station and (optionally) bind a GL staff user
|
||
* to it (GL US-02). Setting both moves the shipment to that station's queue.
|
||
*/
|
||
async assignStation(
|
||
bookingId: string,
|
||
input: { stationYardId: string; staffId?: string },
|
||
): Promise<Booking> {
|
||
const booking = await this.getBooking(bookingId);
|
||
booking.glStationYardId = input.stationYardId;
|
||
if (input.staffId) {
|
||
booking.glAssignedStaffId = input.staffId;
|
||
booking.glAssignedAt = new Date();
|
||
}
|
||
return this.bookings.save(booking);
|
||
}
|
||
|
||
/** Log a cargo exception (seal broken, container damaged, etc.) with photos. */
|
||
async reportIncident(
|
||
bookingId: string,
|
||
input: {
|
||
incidentType: IncidentType;
|
||
description: string;
|
||
files: Express.Multer.File[];
|
||
},
|
||
userId?: string,
|
||
): Promise<ClearanceIncident> {
|
||
await this.getBooking(bookingId);
|
||
if (!input.description?.trim()) {
|
||
throw new BadRequestException('A description is required for an incident report.');
|
||
}
|
||
const photoFileIds: string[] = [];
|
||
for (const file of input.files ?? []) {
|
||
const record = await this.filesService.upload({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: 'incident_photo',
|
||
file,
|
||
});
|
||
photoFileIds.push(record.id);
|
||
}
|
||
const incident = this.incidents.create({
|
||
bookingId,
|
||
incidentType: input.incidentType,
|
||
description: input.description.trim(),
|
||
photoFileIds,
|
||
reportedByUserId: userId ?? null,
|
||
reportedAt: new Date(),
|
||
});
|
||
return this.incidents.save(incident);
|
||
}
|
||
|
||
async listIncidents(bookingId: string): Promise<ClearanceIncident[]> {
|
||
return this.incidents.find({
|
||
where: { bookingId },
|
||
order: { reportedAt: 'DESC' },
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Customer uploads the duty/tax payment slip after GL advised the amount. The
|
||
* slip attaches to the booking and doc-triggers DUTY_TAX_PAID (§11.3 #7).
|
||
*/
|
||
async uploadDutySlip(
|
||
bookingId: string,
|
||
file: Express.Multer.File,
|
||
): Promise<{ milestoneCompleted: boolean }> {
|
||
await this.getBooking(bookingId);
|
||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||
await this.filesService.upsertByCode({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: 'duty_tax_receipt',
|
||
file,
|
||
});
|
||
await this.milestoneService.completeByDocTrigger({ bookingId }, 'DUTY_TAX_PAID');
|
||
return { milestoneCompleted: true };
|
||
}
|
||
|
||
/**
|
||
* GL uploads a post-booking operational document (DO, RO, T1, import release,
|
||
* interchange…). The file attaches to the booking; if the code maps to a
|
||
* doc-triggered milestone, that milestone auto-completes.
|
||
*/
|
||
async uploadDocuments(
|
||
bookingId: string,
|
||
files: Express.Multer.File[],
|
||
): Promise<{ uploaded: number; completedMilestones: string[] }> {
|
||
await this.getBooking(bookingId);
|
||
if (!files?.length) throw new BadRequestException('No documents uploaded');
|
||
|
||
const completedMilestones: string[] = [];
|
||
for (const file of files) {
|
||
await this.filesService.upsertByCode({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: file.fieldname,
|
||
file,
|
||
});
|
||
const milestoneCode = DOC_CODE_TO_MILESTONE[file.fieldname];
|
||
if (milestoneCode) {
|
||
await this.milestoneService.completeByDocTrigger({ bookingId }, milestoneCode);
|
||
completedMilestones.push(milestoneCode);
|
||
}
|
||
}
|
||
return { uploaded: files.length, completedMilestones };
|
||
}
|
||
|
||
/** Wagon-allocation + train-schedule actuals for a booking (both directions). */
|
||
async trainState(bookingId: string): Promise<Freight.ClearanceTrainState> {
|
||
const booking = await this.getBooking(bookingId);
|
||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||
|
||
const wagonMilestone = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
|
||
const wagonAllocated =
|
||
wagonMilestone?.status === 'COMPLETED' ||
|
||
booking.schedulingStatus === 'SCHEDULED' ||
|
||
booking.schedulingStatus === 'DISPATCHED' ||
|
||
Boolean(booking.trainScheduleId);
|
||
|
||
let schedule: TrainSchedule | null = null;
|
||
if (booking.trainScheduleId) {
|
||
schedule = await this.dataSource
|
||
.getRepository(TrainSchedule)
|
||
.findOne({ where: { id: booking.trainScheduleId } });
|
||
}
|
||
|
||
// Per-booking journey first: a booking rides only its own leg, so ITS
|
||
// loaded/arrived timestamps gate clearance — a Dire→Djibouti booking that
|
||
// unloaded at its own destination clears while the train keeps rolling,
|
||
// and a booking still on board does NOT clear just because the train
|
||
// arrived. The schedule actuals remain only as fallback for legacy
|
||
// in-flight bookings that predate per-booking load/unload (no loadedAt).
|
||
const departedAt = booking.loadedAt ?? schedule?.actualDepartureAt ?? null;
|
||
const arrivedAt =
|
||
booking.arrivedAt ??
|
||
(booking.loadedAt ? null : (schedule?.actualArrivalAt ?? null));
|
||
|
||
return {
|
||
scheduleId: schedule?.id ?? null,
|
||
wagonAllocated,
|
||
departedAt: departedAt ? new Date(departedAt).toISOString() : null,
|
||
arrivedAt: arrivedAt ? new Date(arrivedAt).toISOString() : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Gate pass status for a booking, sourced from the train schedule's Djibouti
|
||
* gate-pass operation (secured via the train-scheduling "Save as Secured"
|
||
* action) rather than a clearance milestone. For EXPORT bookings this also
|
||
* backfills the arrival-chain milestones once secured, same as the retired
|
||
* clearance-side grant action used to.
|
||
*/
|
||
async gatepassForBooking(
|
||
bookingId: string,
|
||
): Promise<{ granted: boolean; grantedAt: string | null }> {
|
||
const train = await this.trainState(bookingId);
|
||
if (!train.scheduleId) return { granted: false, grantedAt: null };
|
||
const operation = await this.dataSource
|
||
.getRepository(ImportDjiboutiOperation)
|
||
.findOne({ where: { trainScheduleId: train.scheduleId } });
|
||
const grantedAt = operation?.gatepassGrantedAt
|
||
? new Date(operation.gatepassGrantedAt).toISOString()
|
||
: null;
|
||
|
||
if (grantedAt) {
|
||
const booking = await this.getBooking(bookingId);
|
||
if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') {
|
||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
||
if (byCode.get(code)?.status === 'PENDING') {
|
||
await this.milestoneService.completeForBooking(bookingId, code);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return { granted: Boolean(grantedAt), grantedAt };
|
||
}
|
||
|
||
/**
|
||
* T1 transit-document lifecycle state for an import shipment booking. The
|
||
* gate pass (secured on the train schedule after wagon allocation) opens the
|
||
* upload window; train departure locks it; train arrival lets GL Ethiopia
|
||
* close (accept) the T1 set.
|
||
*/
|
||
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
|
||
const train = await this.trainState(bookingId);
|
||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||
|
||
const closedMilestone = milestones.find(
|
||
(m) => m.milestoneCode === 'T1_CLOSED' && m.status === 'COMPLETED',
|
||
);
|
||
|
||
return {
|
||
bookingId,
|
||
wagonAllocated: train.wagonAllocated,
|
||
trainDepartedAt: train.departedAt,
|
||
trainArrivedAt: train.arrivedAt,
|
||
closed: Boolean(closedMilestone),
|
||
closedAt: closedMilestone?.triggeredAt
|
||
? new Date(closedMilestone.triggeredAt).toISOString()
|
||
: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
|
||
* is secured on the train schedule (which itself follows wagon allocation).
|
||
* Replaces the previous batch; locked only once GL Ethiopia closes the T1.
|
||
*/
|
||
async uploadT1Documents(
|
||
bookingId: string,
|
||
files: Express.Multer.File[],
|
||
): Promise<{ uploaded: number }> {
|
||
const booking = await this.getBooking(bookingId);
|
||
if (booking.tradeDirection !== 'IMPORT') {
|
||
throw new BadRequestException('T1 transport documents apply to import shipments only.');
|
||
}
|
||
|
||
const state = await this.t1State(bookingId);
|
||
if (!state.wagonAllocated) {
|
||
throw new BadRequestException(
|
||
'Wagons must be allocated before T1 transport documents can be uploaded.',
|
||
);
|
||
}
|
||
const gatepass = await this.gatepassForBooking(bookingId);
|
||
if (!gatepass.granted) {
|
||
throw new BadRequestException(
|
||
'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.',
|
||
);
|
||
}
|
||
if (state.closed) {
|
||
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
|
||
}
|
||
// Departure no longer locks T1 docs — GL DJ may replace them any time until
|
||
// GL Ethiopia closes/accepts the T1.
|
||
|
||
await persistT1TransportUploads(this.filesService, bookingId, files);
|
||
return { uploaded: files.length };
|
||
}
|
||
|
||
/**
|
||
* Close (accept) the T1/transport document set.
|
||
* Import: GL Ethiopia closes once the train has arrived (T1 files required).
|
||
* Export: GL Djibouti closes once the train arrives at Djibouti (transport
|
||
* document required).
|
||
*/
|
||
async closeT1(
|
||
bookingId: string,
|
||
userId?: string,
|
||
): Promise<Freight.ClearanceT1State> {
|
||
const booking = await this.getBooking(bookingId);
|
||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||
|
||
const state = await this.t1State(bookingId);
|
||
if (state.closed) return state;
|
||
|
||
if (tradeDirection === 'IMPORT') {
|
||
if (!state.trainArrivedAt) {
|
||
throw new BadRequestException(
|
||
'The train has not arrived yet — T1 can be closed only after arrival.',
|
||
);
|
||
}
|
||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||
const hasT1 = files.some((f) => isT1TransportFileCode(f.code));
|
||
if (!hasT1) {
|
||
throw new BadRequestException(
|
||
'No T1 transport documents on file — GL Djibouti must upload them first.',
|
||
);
|
||
}
|
||
} else {
|
||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||
const done = (code: string) =>
|
||
milestones.find((m) => m.milestoneCode === code)?.status === 'COMPLETED';
|
||
if (!done('EXPORT_TRANSPORT_ISSUED')) {
|
||
throw new BadRequestException(
|
||
'The transport document must be uploaded before T1 can be closed.',
|
||
);
|
||
}
|
||
if (!state.trainArrivedAt) {
|
||
throw new BadRequestException(
|
||
'The train has not arrived at Djibouti yet — T1 can be closed only after arrival.',
|
||
);
|
||
}
|
||
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
||
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
||
}
|
||
|
||
await this.milestoneService.completeForBooking(bookingId, 'T1_CLOSED', userId);
|
||
return this.t1State(bookingId);
|
||
}
|
||
|
||
/** Milestones GL DJ implicitly confirms when granting an export gate pass. */
|
||
private static readonly EXPORT_ARRIVAL_CHAIN = [
|
||
'CARGO_ARRIVED',
|
||
'READY_FOR_LOADING',
|
||
'LOADED',
|
||
'DEPARTED_TO_DJIBOUTI',
|
||
'ARRIVED_AT_DJIBOUTI',
|
||
];
|
||
|
||
|
||
/**
|
||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||
* attached invoice document. The customer pays offline and attaches a slip;
|
||
* GL (ET or DJ) then confirms to settle it.
|
||
*/
|
||
async createFinalInvoice(
|
||
bookingId: string,
|
||
input: { amount: number; currency: string; description?: string },
|
||
file: Express.Multer.File,
|
||
userId?: string,
|
||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||
const booking = await this.getBooking(bookingId);
|
||
if (!booking.customsClearingEnabled) {
|
||
throw new BadRequestException('Final invoice applies to customs bookings only.');
|
||
}
|
||
if (!(input.amount > 0)) {
|
||
throw new BadRequestException('Invoice amount must be greater than zero.');
|
||
}
|
||
if (!file) throw new BadRequestException('Attach the invoice document.');
|
||
|
||
// Invoiceable once cargo is offloaded, or — for export, where OFFLOADED is a
|
||
// DJ doc milestone that may never be recorded — once the Djibouti gate pass
|
||
// is secured. The invoice itself stays optional; nothing forces GL DJ to send one.
|
||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||
const offloaded = milestones.find(
|
||
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
|
||
);
|
||
if (!offloaded) {
|
||
const gatepass = await this.gatepassForBooking(bookingId);
|
||
if (!gatepass.granted) {
|
||
throw new BadRequestException(
|
||
'Cargo must be offloaded (or the gate pass secured) before the final invoice can be raised.',
|
||
);
|
||
}
|
||
}
|
||
|
||
const existing = await this.billingService.findInvoice(
|
||
Freight.InvoiceSource.Booking,
|
||
bookingId,
|
||
GL_FINAL_INVOICE_TYPE,
|
||
);
|
||
if (
|
||
existing &&
|
||
existing.status !== Freight.InvoiceStatus.Cancelled &&
|
||
existing.status !== Freight.InvoiceStatus.Expired
|
||
) {
|
||
throw new ConflictException('A final invoice already exists for this shipment.');
|
||
}
|
||
|
||
const description = input.description?.trim() || 'Post-offload charges (Djibouti)';
|
||
await this.billingService.generateInvoice({
|
||
source: Freight.InvoiceSource.Booking,
|
||
sourceId: bookingId,
|
||
type: GL_FINAL_INVOICE_TYPE,
|
||
companyId: booking.companyId,
|
||
companyProfileId: booking.companyProfileId,
|
||
currency: input.currency,
|
||
lines: [
|
||
{
|
||
chargeType: GL_FINAL_INVOICE_TYPE,
|
||
description,
|
||
quantity: 1,
|
||
unitRate: input.amount,
|
||
amount: input.amount,
|
||
},
|
||
],
|
||
status: Freight.InvoiceStatus.Issued,
|
||
});
|
||
|
||
await this.filesService.upsertByCode({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: 'final_invoice',
|
||
file,
|
||
});
|
||
|
||
// Export clearance is administratively done once the final invoice goes out.
|
||
await this.dataSource
|
||
.getRepository(ContractClearanceCycle)
|
||
.update({ bookingId, completedAt: IsNull() }, { completedAt: new Date() });
|
||
|
||
void userId;
|
||
const summary = await this.finalInvoiceSummary(bookingId);
|
||
if (!summary) throw new NotFoundException('Final invoice could not be created.');
|
||
this.notifier.finalInvoiceCreated(booking, input.amount, input.currency);
|
||
return summary;
|
||
}
|
||
|
||
/** Customer attaches the payment slip for the final invoice. */
|
||
async uploadFinalInvoiceSlip(
|
||
bookingId: string,
|
||
file: Express.Multer.File,
|
||
): Promise<{ uploaded: boolean }> {
|
||
const booking = await this.getBooking(bookingId);
|
||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||
|
||
const invoice = await this.billingService.findInvoice(
|
||
Freight.InvoiceSource.Booking,
|
||
bookingId,
|
||
GL_FINAL_INVOICE_TYPE,
|
||
);
|
||
if (!invoice) {
|
||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||
}
|
||
if (invoice.status === Freight.InvoiceStatus.Paid) {
|
||
throw new BadRequestException('The final invoice is already paid.');
|
||
}
|
||
if (
|
||
invoice.status === Freight.InvoiceStatus.Cancelled ||
|
||
invoice.status === Freight.InvoiceStatus.Expired
|
||
) {
|
||
throw new BadRequestException('The final invoice is no longer payable.');
|
||
}
|
||
|
||
await this.filesService.upsertByCode({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: 'final_invoice_slip',
|
||
file,
|
||
});
|
||
this.notifier.dutySlipUploadedToStaff(booking, 'final');
|
||
return { uploaded: true };
|
||
}
|
||
|
||
/** GL (ET or DJ) confirms the customer's slip — settles the final invoice. */
|
||
async confirmFinalInvoicePaid(
|
||
bookingId: string,
|
||
userId?: string,
|
||
): Promise<Freight.ClearanceFinalInvoiceSummary> {
|
||
const booking = await this.getBooking(bookingId);
|
||
const invoice = await this.billingService.findInvoice(
|
||
Freight.InvoiceSource.Booking,
|
||
bookingId,
|
||
GL_FINAL_INVOICE_TYPE,
|
||
);
|
||
if (!invoice) {
|
||
throw new BadRequestException('No final invoice has been issued for this shipment.');
|
||
}
|
||
if (invoice.status !== Freight.InvoiceStatus.Paid) {
|
||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||
if (!files.some((f) => f.code === 'final_invoice_slip')) {
|
||
throw new BadRequestException(
|
||
'The customer has not attached a payment slip yet.',
|
||
);
|
||
}
|
||
await this.billingService.markInvoiceAsPaid(invoice.id);
|
||
this.notifier.finalInvoicePaid(booking);
|
||
}
|
||
|
||
void userId;
|
||
const summary = await this.finalInvoiceSummary(bookingId);
|
||
if (!summary) throw new NotFoundException('Final invoice not found.');
|
||
return summary;
|
||
}
|
||
|
||
/**
|
||
* GL ET advises (or skips) the post-arrival additional duty/tax round (import).
|
||
* Customer then attaches a slip; SECOND_DUTY_PAID completes on that upload.
|
||
*/
|
||
async adviseSecondDuty(
|
||
bookingId: string,
|
||
input: {
|
||
dutyRequired: boolean;
|
||
amount?: number;
|
||
currency?: string;
|
||
declarationSerial?: string;
|
||
},
|
||
attachment?: Express.Multer.File,
|
||
userId?: string,
|
||
): Promise<{ advised: boolean; skipped: boolean }> {
|
||
const booking = await this.getBooking(bookingId);
|
||
if (!booking.customsClearingEnabled) {
|
||
throw new BadRequestException('Additional duty applies to customs bookings only.');
|
||
}
|
||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||
if (tradeDirection !== 'IMPORT') {
|
||
throw new BadRequestException('Additional duty applies to import shipments only.');
|
||
}
|
||
|
||
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_ADVISED', tradeDirection);
|
||
await this.milestoneService.ensureForBooking(bookingId, 'SECOND_DUTY_PAID', tradeDirection);
|
||
|
||
if (!input.dutyRequired) {
|
||
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_ADVISED');
|
||
await this.milestoneService.skipForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||
return { advised: false, skipped: true };
|
||
}
|
||
|
||
if (!input.amount || input.amount <= 0) {
|
||
throw new BadRequestException('Duty amount must be greater than zero.');
|
||
}
|
||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||
const hasNotice = files.some((f) => f.code === 'duty_tax_notice_2');
|
||
if (!attachment && !hasNotice) {
|
||
throw new BadRequestException('Attach the additional duty/tax notice.');
|
||
}
|
||
if (attachment) {
|
||
await this.filesService.upsertByCode({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: 'duty_tax_notice_2',
|
||
file: attachment,
|
||
});
|
||
}
|
||
|
||
await this.milestoneService.completeWithMetadataForBooking(
|
||
bookingId,
|
||
'SECOND_DUTY_ADVISED',
|
||
{
|
||
dutyAmount: input.amount,
|
||
dutyCurrency: input.currency ?? 'ETB',
|
||
declarationSerial: input.declarationSerial,
|
||
},
|
||
userId,
|
||
);
|
||
this.notifier.secondDutyAdvised(booking, input.amount, input.currency ?? 'ETB');
|
||
return { advised: true, skipped: false };
|
||
}
|
||
|
||
/** Customer attaches the payment slip for the additional duty round. */
|
||
async uploadSecondDutySlip(
|
||
bookingId: string,
|
||
file: Express.Multer.File,
|
||
): Promise<{ milestoneCompleted: boolean }> {
|
||
const booking = await this.getBooking(bookingId);
|
||
if (!file) throw new BadRequestException('No payment slip uploaded');
|
||
|
||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
|
||
if (advised?.status !== 'COMPLETED') {
|
||
throw new BadRequestException('No additional duty has been advised for this shipment.');
|
||
}
|
||
|
||
await this.filesService.upsertByCode({
|
||
resourceId: bookingId,
|
||
resource: 'bookings',
|
||
code: 'duty_tax_receipt_2',
|
||
file,
|
||
});
|
||
|
||
await this.milestoneService.ensureForBooking(
|
||
bookingId,
|
||
'SECOND_DUTY_PAID',
|
||
booking.tradeDirection ?? 'IMPORT',
|
||
);
|
||
await this.milestoneService.completeForBooking(bookingId, 'SECOND_DUTY_PAID');
|
||
this.notifier.dutySlipUploadedToStaff(booking, 'second');
|
||
return { milestoneCompleted: true };
|
||
}
|
||
|
||
/** Second duty round state for clearance views. */
|
||
secondDutyState(
|
||
milestones: Array<{
|
||
milestoneCode: string;
|
||
status: string;
|
||
metadata?: { dutyAmount?: number; dutyCurrency?: string; declarationSerial?: string } | null;
|
||
}>,
|
||
files: Array<{ code?: string | null; id: string; name: string; url: string }>,
|
||
): Freight.ClearanceSecondDuty | null {
|
||
const advised = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_ADVISED');
|
||
const paid = milestones.find((m) => m.milestoneCode === 'SECOND_DUTY_PAID');
|
||
if (!advised && !paid) return null;
|
||
|
||
const toRef = (code: string) => {
|
||
const f = files.find((x) => x.code === code);
|
||
return f ? { id: f.id, name: f.name, url: f.url } : null;
|
||
};
|
||
|
||
return {
|
||
advised: advised?.status === 'COMPLETED',
|
||
skipped: advised?.status === 'SKIPPED',
|
||
amount: advised?.metadata?.dutyAmount ?? null,
|
||
currency: advised?.metadata?.dutyCurrency ?? null,
|
||
declarationSerial: advised?.metadata?.declarationSerial ?? null,
|
||
noticeFile: toRef('duty_tax_notice_2'),
|
||
slipFile: toRef('duty_tax_receipt_2'),
|
||
paid: paid?.status === 'COMPLETED',
|
||
};
|
||
}
|
||
|
||
/** Final-invoice state joined with its document + slip files, for clearance views. */
|
||
async finalInvoiceSummary(
|
||
bookingId: string,
|
||
): Promise<Freight.ClearanceFinalInvoiceSummary | null> {
|
||
const invoice = await this.billingService.findInvoice(
|
||
Freight.InvoiceSource.Booking,
|
||
bookingId,
|
||
GL_FINAL_INVOICE_TYPE,
|
||
);
|
||
if (!invoice) return null;
|
||
|
||
const files = await this.filesService.findByResource(bookingId, 'bookings');
|
||
const toRef = (code: string) => {
|
||
const f = files.find((x) => x.code === code);
|
||
return f ? { id: f.id, name: f.name, url: f.url } : null;
|
||
};
|
||
const line = await this.dataSource
|
||
.getRepository(InvoiceLine)
|
||
.findOne({ where: { invoiceId: invoice.id } });
|
||
|
||
return {
|
||
id: invoice.id,
|
||
invoiceNumber: invoice.invoiceNumber,
|
||
status: invoice.status,
|
||
totalAmount: Number(invoice.totalAmount),
|
||
currency: invoice.currency,
|
||
description: line?.description ?? null,
|
||
invoiceFile: toRef('final_invoice'),
|
||
slipFile: toRef('final_invoice_slip'),
|
||
confirmedAt: invoice.paidAt ? new Date(invoice.paidAt).toISOString() : null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* GL ET uploads export transport document after wagon allocation (export ONE_TIME).
|
||
*/
|
||
async uploadTransportDocument(
|
||
bookingId: string,
|
||
files: Express.Multer.File[],
|
||
): Promise<{ uploaded: boolean; milestoneCompleted: boolean }> {
|
||
const booking = await this.getBooking(bookingId);
|
||
if (booking.tradeDirection !== 'EXPORT') {
|
||
throw new BadRequestException('Transport document upload applies to export shipments only.');
|
||
}
|
||
|
||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||
const wagonAllocated = milestones.find((m) => m.milestoneCode === 'WAGON_ALLOCATED');
|
||
const wagonDone =
|
||
wagonAllocated?.status === 'COMPLETED' || booking.schedulingStatus === 'SCHEDULED';
|
||
if (!wagonDone) {
|
||
throw new BadRequestException(
|
||
'Wagon must be allocated before the transport document can be uploaded.',
|
||
);
|
||
}
|
||
|
||
if (files.length === 0) {
|
||
throw new BadRequestException('No transit permit documents uploaded');
|
||
}
|
||
|
||
await persistExportTransportUploads(this.filesService, bookingId, files);
|
||
|
||
if (wagonAllocated && wagonAllocated.status !== 'COMPLETED') {
|
||
await this.milestoneService.completeForBooking(bookingId, 'WAGON_ALLOCATED');
|
||
}
|
||
|
||
await this.milestoneService.completeByDocTrigger(
|
||
{ bookingId },
|
||
'EXPORT_TRANSPORT_ISSUED',
|
||
);
|
||
|
||
return { uploaded: true, milestoneCompleted: true };
|
||
}
|
||
}
|