Files
edr-platform/apps/edr-freight-api/src/modules/contracts/gl-operations.service.ts
2026-07-07 12:28:47 +00:00

735 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
}
}