mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 21:50:57 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -957,12 +957,27 @@ export class BillingService {
|
||||
returnUrl: opts.returnUrl,
|
||||
failureUrl: opts.failureUrl,
|
||||
});
|
||||
|
||||
//
|
||||
// Link the intent to the invoice BEFORE any settlement can correlate against it.
|
||||
await this.dataSource
|
||||
.getRepository(Invoice)
|
||||
.update({ id: invoice.id }, { paymentId: result.intentId });
|
||||
|
||||
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
|
||||
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
|
||||
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
|
||||
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
|
||||
if (!result.immediateSuccess) {
|
||||
await this.payment.handlePaymentEvent({
|
||||
eventType: "payment.succeeded",
|
||||
eventId: `demo-${result.intentId}`,
|
||||
referenceId: invoice.sourceId,
|
||||
intentId: result.intentId,
|
||||
providerTxnId: result.providerTxnId,
|
||||
paidAt: (result.paidAt ?? new Date()).toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
if (result.immediateSuccess) {
|
||||
await this.settleByPaymentId(
|
||||
result.intentId,
|
||||
|
||||
@@ -1086,6 +1086,9 @@ export class BookingTransitionService {
|
||||
offeredAmount: number;
|
||||
paymentDeadline: Date;
|
||||
} | null;
|
||||
/** Flat list of physical container numbers on this booking (for the
|
||||
* customer truck-assignment container picker). */
|
||||
containerNumbers: string[];
|
||||
}
|
||||
> {
|
||||
// This enrichment runs AFTER the transition has committed. A failure here
|
||||
@@ -1141,12 +1144,20 @@ export class BookingTransitionService {
|
||||
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
// Physical container numbers entered at booking time (booking_container
|
||||
// units), flattened for the customer truck-assignment container picker.
|
||||
const containerNumbers = (booking.bookingContainers ?? [])
|
||||
.flatMap((bc) => bc.units ?? [])
|
||||
.map((unit) => unit.containerNumber)
|
||||
.filter((n): n is string => Boolean(n));
|
||||
|
||||
return {
|
||||
...booking,
|
||||
latestChangeRequestNote: note?.note ?? null,
|
||||
contractSummary: summary,
|
||||
nextStep,
|
||||
activeBatchOffer,
|
||||
containerNumbers,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,7 +205,7 @@ export class BookingClearanceService {
|
||||
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
|
||||
const bookingMilestone = (code: string) =>
|
||||
milestones.find((m) => m.milestoneCode === code);
|
||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
||||
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
|
||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
|
||||
@@ -242,14 +242,8 @@ export class BookingClearanceService {
|
||||
workflowFiles,
|
||||
t1,
|
||||
train,
|
||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
||||
gatepassAt:
|
||||
gatepassMilestone?.status === 'COMPLETED'
|
||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
||||
(gatepassMilestone.triggeredAt
|
||||
? gatepassMilestone.triggeredAt.toISOString()
|
||||
: null))
|
||||
: null,
|
||||
gatepassGranted: gatepass.granted,
|
||||
gatepassAt: gatepass.grantedAt,
|
||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||
t1ClosedAt:
|
||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||
|
||||
@@ -278,7 +278,9 @@ export class ContractClearanceService {
|
||||
}
|
||||
const bookingMilestone = (code: string) =>
|
||||
bookingMilestones.find((m) => m.milestoneCode === code);
|
||||
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
|
||||
const gatepass = cycle?.bookingId
|
||||
? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
|
||||
: { granted: false, grantedAt: null };
|
||||
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
|
||||
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
|
||||
const secondDuty = this.glOperationsService.secondDutyState(
|
||||
@@ -344,14 +346,8 @@ export class ContractClearanceService {
|
||||
workflowFiles,
|
||||
t1,
|
||||
train,
|
||||
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
|
||||
gatepassAt:
|
||||
gatepassMilestone?.status === 'COMPLETED'
|
||||
? (gatepassMilestone.metadata?.gatepassAt ??
|
||||
(gatepassMilestone.triggeredAt
|
||||
? gatepassMilestone.triggeredAt.toISOString()
|
||||
: null))
|
||||
: null,
|
||||
gatepassGranted: gatepass.granted,
|
||||
gatepassAt: gatepass.grantedAt,
|
||||
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
|
||||
t1ClosedAt:
|
||||
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
|
||||
|
||||
@@ -77,7 +77,6 @@ import {
|
||||
} from './dto/gl-operations.dto';
|
||||
import {
|
||||
AdviseContractDutyDto,
|
||||
GatepassDto,
|
||||
RoAmendmentDto,
|
||||
} from './dto/phased-clearance.dto';
|
||||
|
||||
@@ -688,30 +687,6 @@ export class ContractsController {
|
||||
return this.clearanceService.djQueue(filter);
|
||||
}
|
||||
|
||||
@Get('clearance/dj-schedules')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
|
||||
djClearanceSchedules() {
|
||||
return this.glOperationsService.djSchedules();
|
||||
}
|
||||
|
||||
@Post('clearance/schedules/:scheduleId/gatepass')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({
|
||||
summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
|
||||
})
|
||||
grantScheduleGatepass(
|
||||
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
|
||||
@Body() dto: GatepassDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.grantScheduleGatepass(
|
||||
scheduleId,
|
||||
dto?.gatepassAt,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
|
||||
|
||||
@Get('clearance/ops-queue')
|
||||
@@ -947,21 +922,6 @@ export class ContractsController {
|
||||
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/gatepass')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
|
||||
grantGatepass(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: GatepassDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.glOperationsService.grantGatepass(
|
||||
bookingId,
|
||||
dto?.gatepassAt,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/final-invoice')
|
||||
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
|
||||
@@ -36,11 +36,3 @@ export class RoAmendmentDto {
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export class GatepassDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'When the gate pass was granted (ISO datetime; defaults to now)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gatepassAt?: string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource, In, IsNull } from 'typeorm';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
|
||||
|
||||
import { BillingService } from '../billing/billing.service';
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
ClearanceIncident,
|
||||
IncidentType,
|
||||
} from './entities/clearance-incident.entity';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import {
|
||||
@@ -198,6 +197,7 @@ export class GlOperationsService {
|
||||
}
|
||||
|
||||
return {
|
||||
scheduleId: schedule?.id ?? null,
|
||||
wagonAllocated,
|
||||
departedAt: schedule?.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
@@ -208,6 +208,41 @@ export class GlOperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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. Wagon
|
||||
* allocation opens the upload window; train departure locks it; train arrival
|
||||
@@ -302,8 +337,11 @@ export class GlOperationsService {
|
||||
'The transport document must be uploaded before T1 can be closed.',
|
||||
);
|
||||
}
|
||||
if (!done('GATEPASS_GRANTED')) {
|
||||
throw new BadRequestException('Grant the gate pass before closing T1.');
|
||||
const gatepass = await this.gatepassForBooking(bookingId);
|
||||
if (!gatepass.granted) {
|
||||
throw new BadRequestException(
|
||||
'Secure the Djibouti gate pass on the train schedule before closing T1.',
|
||||
);
|
||||
}
|
||||
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
|
||||
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
|
||||
@@ -322,182 +360,6 @@ export class GlOperationsService {
|
||||
'ARRIVED_AT_DJIBOUTI',
|
||||
];
|
||||
|
||||
/**
|
||||
* GL Djibouti grants the gate pass for a customs booking, capturing the time.
|
||||
* Export: requires the train to have arrived at Djibouti; back-fills the
|
||||
* arrival-chain milestones. Import: requires wagon allocation (pre-loading).
|
||||
*/
|
||||
async grantGatepass(
|
||||
bookingId: string,
|
||||
gatepassAt?: string,
|
||||
userId?: string,
|
||||
): Promise<{ bookingId: string; gatepassAt: string }> {
|
||||
const booking = await this.getBooking(bookingId);
|
||||
if (!booking.customsClearingEnabled) {
|
||||
throw new BadRequestException('Gate pass applies to customs bookings only.');
|
||||
}
|
||||
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
|
||||
const milestones = await this.milestoneService.listForBooking(bookingId);
|
||||
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
|
||||
|
||||
const existing = byCode.get('GATEPASS_GRANTED');
|
||||
if (existing?.status === 'COMPLETED') {
|
||||
return {
|
||||
bookingId,
|
||||
gatepassAt:
|
||||
existing.metadata?.gatepassAt ??
|
||||
(existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
|
||||
};
|
||||
}
|
||||
|
||||
const train = await this.trainState(bookingId);
|
||||
if (tradeDirection === 'EXPORT') {
|
||||
if (!train.arrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
|
||||
);
|
||||
}
|
||||
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
|
||||
if (byCode.get(code)?.status === 'PENDING') {
|
||||
await this.milestoneService.completeForBooking(bookingId, code, userId);
|
||||
}
|
||||
}
|
||||
} else if (!train.wagonAllocated) {
|
||||
throw new BadRequestException(
|
||||
'Wagons must be allocated before the gate pass can be granted.',
|
||||
);
|
||||
}
|
||||
|
||||
const at = gatepassAt?.trim() || new Date().toISOString();
|
||||
await this.milestoneService.completeWithMetadataForBooking(
|
||||
bookingId,
|
||||
'GATEPASS_GRANTED',
|
||||
{ gatepassAt: at },
|
||||
userId,
|
||||
);
|
||||
return { bookingId, gatepassAt: at };
|
||||
}
|
||||
|
||||
/** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
|
||||
async djSchedules(): Promise<Freight.DjClearanceSchedule[]> {
|
||||
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
|
||||
relations: {
|
||||
scheduleBookings: { booking: true },
|
||||
originStation: true,
|
||||
destinationStation: true,
|
||||
},
|
||||
order: { scheduledDepartureDate: 'DESC' },
|
||||
});
|
||||
|
||||
const withCustoms = schedules
|
||||
.filter((s) => s.status !== 'CANCELLED')
|
||||
.map((s) => ({
|
||||
schedule: s,
|
||||
customs: (s.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
|
||||
}))
|
||||
.filter((s) => s.customs.length > 0);
|
||||
|
||||
const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
|
||||
const gatepassRows = bookingIds.length
|
||||
? await this.dataSource.getRepository(ClearanceMilestone).find({
|
||||
where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
|
||||
})
|
||||
: [];
|
||||
const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
|
||||
|
||||
return withCustoms.map(({ schedule, customs }) => {
|
||||
const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
|
||||
return {
|
||||
id: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
routeName: null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination:
|
||||
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
status: schedule.status,
|
||||
scheduledDepartureDate: schedule.scheduledDepartureDate
|
||||
? new Date(schedule.scheduledDepartureDate).toISOString()
|
||||
: null,
|
||||
actualDepartureAt: schedule.actualDepartureAt
|
||||
? new Date(schedule.actualDepartureAt).toISOString()
|
||||
: null,
|
||||
actualArrivalAt: schedule.actualArrivalAt
|
||||
? new Date(schedule.actualArrivalAt).toISOString()
|
||||
: null,
|
||||
freightType:
|
||||
freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
|
||||
customsBookings: customs.map((b) => {
|
||||
const m = gatepassByBooking.get(b.id);
|
||||
const granted = m?.status === 'COMPLETED';
|
||||
return {
|
||||
bookingId: b.id,
|
||||
reference: b.reference ?? b.id,
|
||||
tradeDirection: b.tradeDirection ?? 'IMPORT',
|
||||
contractId: b.contractId ?? null,
|
||||
gatepassGranted: granted,
|
||||
gatepassAt: granted
|
||||
? (m?.metadata?.gatepassAt ??
|
||||
(m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-click gate pass for every customs booking on a train schedule. Per-booking
|
||||
* guard failures are collected, not fatal. Import schedules also get the
|
||||
* schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
|
||||
*/
|
||||
async grantScheduleGatepass(
|
||||
scheduleId: string,
|
||||
gatepassAt?: string,
|
||||
userId?: string,
|
||||
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
|
||||
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||
where: { id: scheduleId },
|
||||
relations: { scheduleBookings: { booking: true } },
|
||||
});
|
||||
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
|
||||
const customs = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
|
||||
if (customs.length === 0) {
|
||||
throw new BadRequestException('No customs bookings ride this schedule.');
|
||||
}
|
||||
|
||||
let granted = 0;
|
||||
const skipped: Array<{ bookingId: string; error: string }> = [];
|
||||
for (const booking of customs) {
|
||||
try {
|
||||
await this.grantGatepass(booking.id, gatepassAt, userId);
|
||||
granted += 1;
|
||||
} catch (e) {
|
||||
skipped.push({
|
||||
bookingId: booking.id,
|
||||
error: e instanceof Error ? e.message : 'Failed',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
|
||||
const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
if (!operation) {
|
||||
operation = opRepo.create({ trainScheduleId: scheduleId });
|
||||
}
|
||||
if (!operation.gatepassGrantedAt) {
|
||||
operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
|
||||
await opRepo.save(operation);
|
||||
}
|
||||
}
|
||||
|
||||
return { granted, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* GL Djibouti raises the post-offload final invoice (export): manual amount +
|
||||
|
||||
@@ -479,7 +479,7 @@ export class PaymentService {
|
||||
alreadyFinalized?: boolean;
|
||||
reason?: string;
|
||||
}> {
|
||||
console.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||
this.logger.log(`Received payment event: ${JSON.stringify(event)}`);
|
||||
if (event.eventType === "payment.succeeded") {
|
||||
const intent = await this.paymentRepo.findOneBy({
|
||||
refId: event.referenceId,
|
||||
@@ -490,13 +490,12 @@ export class PaymentService {
|
||||
reason: `No local intent for reference ${event.referenceId}`,
|
||||
};
|
||||
}
|
||||
console.log(`Processing payment succeeded event for intent: }`, intent);
|
||||
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
|
||||
providerTxnId: event.providerTxnId,
|
||||
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
|
||||
notify: true,
|
||||
});
|
||||
console.log(
|
||||
this.logger.log(
|
||||
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||
const open = (
|
||||
await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: "OPEN" },
|
||||
where: [
|
||||
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft },
|
||||
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
})
|
||||
).filter((s) => s.windowPhase == null);
|
||||
const groups = new Map<string, RouteDayGroup>();
|
||||
|
||||
@@ -20,6 +20,7 @@ import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
|
||||
import { Container } from '../container-management/entities/container.entity';
|
||||
import { Locomotive } from '../locomotives/entities/locomotive.entity';
|
||||
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
|
||||
@@ -1168,12 +1169,47 @@ export class TrainSchedulingService {
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
||||
);
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
|
||||
* milestone for every customs booking on this schedule, so contract/booking
|
||||
* clearance views still reading that milestone (older deployed builds) see
|
||||
* the gate pass as done. Drop once every clearance-api deployment reads
|
||||
* ImportDjiboutiOperation.gatepassGrantedAt directly.
|
||||
*/
|
||||
private async completeGatepassMilestoneForSchedule(
|
||||
scheduleId: string,
|
||||
securedAt: Date,
|
||||
): Promise<void> {
|
||||
const bookings = await this.dataSource.getRepository(Booking).find({
|
||||
where: { trainScheduleId: scheduleId, customsClearingEnabled: true },
|
||||
});
|
||||
if (bookings.length === 0) return;
|
||||
|
||||
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
|
||||
const rows = await milestoneRepo.find({
|
||||
where: {
|
||||
bookingId: In(bookings.map((b) => b.id)),
|
||||
milestoneCode: 'GATEPASS_GRANTED',
|
||||
},
|
||||
});
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.status === 'COMPLETED') continue;
|
||||
row.status = 'COMPLETED';
|
||||
row.triggeredAt = securedAt;
|
||||
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
|
||||
await milestoneRepo.save(row);
|
||||
}
|
||||
}
|
||||
|
||||
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
@@ -1265,7 +1301,9 @@ export class TrainSchedulingService {
|
||||
performedBy: 'DOCUMENT_GENERATION',
|
||||
});
|
||||
const html = this.buildImportLoadListHtml(loadList);
|
||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
||||
// Generic render — NOT the release-order fallback (would mislabel this as a
|
||||
// gate-clearance / release order when Chromium is unavailable).
|
||||
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
|
||||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||||
return {
|
||||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
@@ -1283,7 +1321,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule);
|
||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
||||
// Generic render — NOT the release-order fallback (see importLoadListDocument).
|
||||
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
|
||||
const reference = schedule.trainNumber ?? schedule.id;
|
||||
return {
|
||||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
@@ -2049,7 +2088,9 @@ export class TrainSchedulingService {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
id,
|
||||
TrainScheduleStatusEnum.Cancelled,
|
||||
{},
|
||||
// Retire the booking window so a canceled schedule never lingers as an
|
||||
// "open window" in booking-window lists or the legacy batch fill.
|
||||
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
|
||||
manager,
|
||||
);
|
||||
if (schedule.trainSetId) {
|
||||
|
||||
@@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render arbitrary document HTML to PDF via the shared renderer WITHOUT the
|
||||
* release-order fallback. Non-release documents (e.g. the import/export
|
||||
* marshalling load list) must use this so a Chromium-less fallback degrades to
|
||||
* a plain-text dump of *their own* content — instead of masquerading as a
|
||||
* "Warehouse Gate Clearance / Release Order", which the release-specific
|
||||
* fallback would otherwise draw regardless of the input HTML.
|
||||
*/
|
||||
renderDocumentHtml(html: string, label = 'Document'): Promise<Buffer> {
|
||||
return this.pdf.htmlToPdfBuffer(html, { label });
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
const doc = this.extractReleaseDocument(html);
|
||||
const body: string[] = [
|
||||
|
||||
Reference in New Issue
Block a user