Merge branch 'dev' into freight/feat/fixes-v1

This commit is contained in:
Nathnael
2026-07-03 06:28:38 +00:00
116 changed files with 8529 additions and 2334 deletions

View File

@@ -138,7 +138,7 @@ export class BookingInvoiceService {
);
return;
}
if (booking.paymentStatus === "PAID") return;
// if (booking.paymentStatus === "PAID") return;
await this.dataSource.transaction(async (mg) => {
await mg.update(

View File

@@ -991,6 +991,15 @@ export class BookingTransitionService {
private async acceptOperationRequest(booking: Booking): Promise<Booking> {
const now = new Date();
// Export is FCFS: fail the accept up-front (409) when no export train on the
// booking's day still has capacity — nothing below runs and the request stays
// pending for staff to move/decline.
const isExportTrain =
booking.tradeDirection === "EXPORT" && !isRoadService(booking.serviceType);
const exportScheduleId = isExportTrain
? await this.bookingBatchService.pickExportSchedule(booking)
: null;
const invoice = await this.invoiceService.ensureInvoiceForBooking(booking);
this.logger.log(
`Generated invoice ${invoice.invoiceNumber} (${invoice.id}) for ${booking.reference}:${booking.id}`,
@@ -1014,7 +1023,15 @@ export class BookingTransitionService {
lockedAt: booking.lockedAt ?? now,
} as never);
if (booking.scheduledDate) {
if (exportScheduleId) {
// FCFS: reserve the slot and send the payment notification immediately;
// paid → auto-allocated by the settle/paid pipeline.
const fresh = await this.bookingsService.findById(booking.id);
await this.bookingBatchService.reserveExportBooking(fresh, exportScheduleId);
} else if (booking.tradeDirection === "IMPORT") {
// Import bookings wait for their booking-day window cycle — the batch runs
// after staff document review, never at accept time.
} else if (booking.scheduledDate) {
this.bookingBatchService.enqueueRouteDayProcessing(
booking.originYardId,
booking.destinationYardId,
@@ -1029,6 +1046,12 @@ export class BookingTransitionService {
latestChangeRequestNote?: string | null;
contractSummary?: string | null;
nextStep: BookingNextStep | null;
activeBatchOffer?: {
offeredWagons: number;
totalWagons: number;
offeredAmount: number;
paymentDeadline: Date;
} | null;
}
> {
const note = await this.bookingsRepository.findLatestReviewNote(
@@ -1044,11 +1067,16 @@ export class BookingTransitionService {
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
: null;
const nextStep = computeNextStep(booking, nextPending);
const activeBatchOffer =
booking.status === "SELECTED_FOR_BATCH"
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
: null;
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
activeBatchOffer,
};
}
}

View File

@@ -617,12 +617,14 @@ export class BookingsController {
async uploadBookingDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingClearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
vesselDepartureDate,
);
return this.transitionService.enrichBookingResponse(booking);
}

View File

@@ -33,14 +33,14 @@ import { BookingContractSignature } from './entities/booking-contract-signature.
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractPricingScheduleBuilder } from '../../contracts/contract-pricing-schedule.builder';
import { ContractRendererService } from '../../contracts/contract-renderer.service';
import { ContractTemplateResolver } from '../../contracts/contract-template.resolver';
import { ContractViewModelBuilder } from '../../contracts/contract-view-model.builder';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
import { ContractPricingScheduleBuilder } from "../../contracts/contract-pricing-schedule.builder";
import { ContractRendererService } from "../../contracts/contract-renderer.service";
import { ContractTemplateResolver } from "../../contracts/contract-template.resolver";
import { ContractViewModelBuilder } from "../../contracts/contract-view-model.builder";
import { TrainSchedulingModule } from "../train-scheduling/train-scheduling.module";
import { VehiclesModule } from "../vehicles/vehicles.module";
@Module({
imports: [
@@ -62,6 +62,7 @@ import { BookingContainerAllocation } from "./entities/booking-container-allocat
forwardRef(() => ContractsModule),
FilesModule,
MinioModule,
VehiclesModule,
CompaniesModule,
// CustomersModule,
RuleEngineModule,

View File

@@ -31,6 +31,8 @@ import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingsRepository } from './bookings.repository';
import { ConsolidationService } from './consolidation.service';
import { VehiclesService } from '../vehicles/vehicles.service';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { assertFreightShape } from './booking-freight.util';
import { CreateBookingContainerDto, CreateBookingDto } from './dto/create-booking.dto';
import { mapStatusCountsToTabs } from './booking-list-tabs.config';
@@ -94,6 +96,7 @@ export class BookingsService {
private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService,
private readonly vehiclesService: VehiclesService,
private readonly contractPdfService: ContractPdfService,
) {}
@@ -1520,6 +1523,16 @@ export class BookingsService {
throw new NotFoundException(`Booking ${bookingId} not found`);
}
const previousAllocations = await this.dataSource.manager.find(BookingContainerAllocation, {
where: {
bookingId,
containerId: In(allocations.map((a) => a.containerId)),
},
});
const previousVehicleIds = previousAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(BookingContainerAllocation, {
@@ -1536,6 +1549,16 @@ export class BookingsService {
}
});
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
await Promise.all(
[...vehicleIds].map((vehicleId) =>
this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY),
),
);
await this.vehiclesService.releaseIfUnused(
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
return {
success: true,
allocated: allocations.length,

View File

@@ -291,7 +291,7 @@ export class Booking extends BaseEntity {
@Column({ name: 'origin_yard_id', type: 'uuid' })
originYardId!: string;
@ManyToOne(() => Yard)
@ManyToOne(() => Yard)
@JoinColumn({ name: 'origin_yard_id' })
originYard?: Yard;

View File

@@ -65,6 +65,16 @@ function makeService(overrides?: {
children: [{ value: '2' }],
}),
};
const glOperationsService = {
t1State: jest.fn().mockResolvedValue({
bookingId: 'b-general',
wagonAllocated: false,
trainDepartedAt: null,
trainArrivedAt: null,
closed: false,
closedAt: null,
}),
};
const service = new BookingClearanceService(
bookingsRepository as never,
@@ -74,6 +84,7 @@ function makeService(overrides?: {
workflowService as never,
milestoneService as never,
dropdownSettingsService as never,
glOperationsService as never,
);
return {

View File

@@ -1,5 +1,11 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -11,6 +17,7 @@ import { Booking } from '../bookings/entities/booking.entity';
import { clearanceCodesForBooking } from '../bookings/clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { AdviseContractDutyDto } from './dto/phased-clearance.dto';
import { buildWorkflowFiles, belongsOnDjClearanceQueue, belongsOnEtClearanceQueue, DJ_BOOKING_QUEUE_STATUSES, persistDeclarationUploads, persistTransitPermitUploads, PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES } from './phased-clearance.util';
@@ -63,6 +70,23 @@ export interface BookingClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until wagon allocation). */
t1?: ClearanceT1State | null;
/** Train link state for the booking (both directions). */
train?: ClearanceTrainState | null;
gatepassGranted?: boolean;
gatepassAt?: string | null;
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
}
@Injectable()
@@ -75,6 +99,7 @@ export class BookingClearanceService {
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
) {}
private async assertPhasedGeneralCustoms(booking: Booking): Promise<void> {
@@ -162,6 +187,29 @@ export class BookingClearanceService {
booking.tradeDirection ?? 'IMPORT',
);
let t1: ClearanceT1State | null = null;
if ((booking.tradeDirection ?? 'IMPORT') === 'IMPORT') {
try {
t1 = await this.glOperationsService.t1State(bookingId);
} catch {
t1 = null;
}
}
let train: ClearanceTrainState | null = null;
try {
train = await this.glOperationsService.trainState(bookingId);
} catch {
train = null;
}
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
return {
bookingId,
status: booking.status,
@@ -192,6 +240,34 @@ export class BookingClearanceService {
preClearanceFinalized: Boolean(booking.preClearanceFinalizedAt),
dutyAdvice,
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
: null,
riskAssignedAt:
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString()
: null,
secondDuty,
importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
};
}
@@ -288,6 +364,12 @@ export class BookingClearanceService {
: ContractDocPhase.CustomerDuty,
} as never);
// Export: the declaration is the last GL ET pre-operation action — release
// immediately so the customer can proceed without a separate confirm click.
if (tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleasedForBooking(bookingId, userId);
}
return this.bookingsService.findById(bookingId);
}
@@ -421,6 +503,13 @@ export class BookingClearanceService {
clearanceCurrentPhase: ContractDocPhase.GlDjCollection,
} as never);
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(bookingId, 'bookings');
if (files.some((f) => f.code === 'delivery_order')) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED');
await this.workflowService.markReadyForOperation(bookingId);
}
return this.bookingsService.findById(bookingId);
}
@@ -428,21 +517,18 @@ export class BookingClearanceService {
bookingId: string,
file: Express.Multer.File,
userId?: string,
vesselDepartureDate?: string,
): Promise<Booking> {
const booking = await this.loadBooking(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException('Delivery Order applies only to import bookings.');
}
if (!booking.preClearanceFinalizedAt) {
throw new BadRequestException(
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
);
}
await this.workflowService.assertPriorCompleteForBooking(bookingId, 'IMPORT', 'DO_COLLECTED');
if (!file) throw new BadRequestException('No Delivery Order uploaded');
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and operation readiness) still
// waits for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: bookingId,
resource: 'bookings',
@@ -450,8 +536,16 @@ export class BookingClearanceService {
file,
});
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForOperation(bookingId);
if (vesselDepartureDate?.trim()) {
await this.bookingsRepository.update(bookingId, {
vesselDepartureDate: vesselDepartureDate.trim(),
} as never);
}
if (booking.preClearanceFinalizedAt) {
await this.workflowService.completeMilestoneForBooking(bookingId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForOperation(bookingId);
}
return this.bookingsService.findById(bookingId);
}

View File

@@ -39,6 +39,16 @@ const IMPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
OFFLOADED: { label: 'Offloaded', ownerRegion: 'OPS', triggeredByDoc: false },
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'ET', triggeredByDoc: false },
RISK_ASSIGNED: { label: 'Risk Assigned', ownerRegion: 'ET', triggeredByDoc: false },
SECOND_DUTY_ADVISED: {
label: 'Additional Duty and Taxes Advised',
ownerRegion: 'ET',
triggeredByDoc: false,
},
SECOND_DUTY_PAID: {
label: 'Additional Duty and Tax Paid',
ownerRegion: 'CUST',
triggeredByDoc: true,
},
IMPORT_RELEASE_GRANTED: { label: 'Import Release Granted', ownerRegion: 'ET', triggeredByDoc: true },
IMPORT_PROCESS_COMPLETED: { label: 'Import Process Completed', ownerRegion: 'ET', triggeredByDoc: true },
STORAGE_INVOICE_RAISED: { label: 'Storage Invoice Raised', ownerRegion: 'OPS', triggeredByDoc: false },
@@ -68,6 +78,7 @@ const EXPORT_DEFS: Record<string, Omit<MilestoneDef, 'code'>> = {
DEPARTED_TO_DJIBOUTI: { label: 'Departed to Djibouti', ownerRegion: 'OPS', triggeredByDoc: false },
ARRIVED_AT_DJIBOUTI: { label: 'Arrived at Djibouti', ownerRegion: 'DJ', triggeredByDoc: false },
GATEPASS_GRANTED: { label: 'Gatepass Granted', ownerRegion: 'DJ', triggeredByDoc: false },
T1_CLOSED: { label: 'T1 Closed', ownerRegion: 'DJ', triggeredByDoc: false },
OFFLOADED: { label: 'Offloaded', ownerRegion: 'DJ', triggeredByDoc: true },
};

View File

@@ -91,6 +91,39 @@ export class ClearanceMilestoneService {
});
}
/**
* Find-or-create a post-booking milestone row from the catalog. Needed for codes
* added to the catalog after a booking's rows were seeded (e.g. export T1_CLOSED).
*/
async ensureForBooking(
bookingId: string,
code: string,
tradeDirection: string,
): Promise<ClearanceMilestone> {
const existing = await this.repo.findOne({ where: { bookingId, milestoneCode: code } });
if (existing) return existing;
const { postBooking } = splitMilestones(tradeDirection);
const idx = postBooking.findIndex((d) => d.code === code);
if (idx < 0) {
throw new NotFoundException(
`Milestone ${code} is not a ${tradeDirection} post-booking milestone`,
);
}
const def = postBooking[idx]!;
return this.repo.save(
this.repo.create({
bookingId,
milestoneCode: def.code,
milestoneLabel: def.label,
ownerRegion: def.ownerRegion,
triggeredByDoc: def.triggeredByDoc,
status: 'PENDING',
sortOrder: idx,
}),
);
}
/** Mark a milestone complete (by code) on a booking. */
async completeForBooking(
bookingId: string,

View File

@@ -1,5 +1,11 @@
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { ContractDocPhase } from '@edr/types';
import {
ContractDocPhase,
type ClearanceFinalInvoiceSummary,
type ClearanceSecondDuty,
type ClearanceT1State,
type ClearanceTrainState,
} from '@edr/types';
import { DropdownSettingsService } from '../dropdown-settings/dropdown-settings.service';
import { FileUploadSettingsService } from '../file-upload-settings/file-upload-settings.service';
@@ -10,6 +16,7 @@ import { BookingsService } from '../bookings/bookings.service';
import { contractClearanceCodes } from './contract-clearance.util';
import { ClearanceWorkflowService } from './clearance-workflow.service';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { GlOperationsService } from './gl-operations.service';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { Contract } from './entities/contract.entity';
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
@@ -77,6 +84,23 @@ export interface ContractClearanceView {
noticeFile?: { id: string; name: string; url: string } | null;
} | null;
workflowFiles?: ReturnType<typeof buildWorkflowFiles>;
/** Import post-allocation T1 transit document state (null until a booking is linked). */
t1?: ClearanceT1State | null;
/** Train link state for the booking (both directions; null until a booking is linked). */
train?: ClearanceTrainState | null;
gatepassGranted?: boolean;
gatepassAt?: string | null;
t1Closed?: boolean;
t1ClosedAt?: string | null;
offloaded?: boolean;
/** GL Djibouti post-offload final invoice (export). */
finalInvoice?: ClearanceFinalInvoiceSummary | null;
/** Customs risk level assigned by GL ET (import; visible to the customer). */
riskLevel?: string | null;
riskAssignedAt?: string | null;
/** Post-arrival additional duty/tax round (import). */
secondDuty?: ClearanceSecondDuty | null;
importReleaseGranted?: boolean;
}
@Injectable()
@@ -90,6 +114,7 @@ export class ContractClearanceService {
private readonly workflowService: ClearanceWorkflowService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly dropdownSettingsService: DropdownSettingsService,
private readonly glOperationsService: GlOperationsService,
) {}
private isPhasedCustoms(contract: Contract): boolean {
@@ -208,8 +233,9 @@ export class ContractClearanceService {
files,
contract.tradeDirection ?? 'IMPORT',
);
let bookingFiles: Awaited<ReturnType<FilesService['findByResource']>> = [];
if (cycle?.bookingId) {
const bookingFiles = await this.filesService.findByResource(
bookingFiles = await this.filesService.findByResource(
cycle.bookingId,
'bookings',
);
@@ -224,11 +250,41 @@ export class ContractClearanceService {
workflowFiles = [...byCode.values()];
}
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
const bookingMilestones = await this.workflowService.listMilestonesForBooking(
let t1: ClearanceT1State | null = null;
if (cycle?.bookingId && contract.tradeDirection === 'IMPORT') {
try {
t1 = await this.glOperationsService.t1State(cycle.bookingId);
} catch {
t1 = null; // linked booking missing — view stays usable
}
}
let train: ClearanceTrainState | null = null;
let bookingMilestones: ClearanceMilestone[] = [];
let finalInvoice: ClearanceFinalInvoiceSummary | null = null;
if (cycle?.bookingId) {
try {
train = await this.glOperationsService.trainState(cycle.bookingId);
} catch {
train = null;
}
bookingMilestones = await this.workflowService.listMilestonesForBooking(
cycle.bookingId,
);
finalInvoice = await this.glOperationsService.finalInvoiceSummary(cycle.bookingId);
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(
bookingMilestones,
bookingFiles,
);
let nextAction = this.workflowService.computeNextAction(contract, cycle, milestones);
if (cycle?.bookingId && contract.tradeDirection === 'EXPORT') {
const booking = await this.bookingsService.findById(cycle.bookingId);
if (booking) {
nextAction = this.workflowService.computeNextActionForBooking(
@@ -272,6 +328,34 @@ export class ContractClearanceService {
linkedBookingId: cycle?.bookingId ?? null,
dutyAdvice,
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt
? t1ClosedMilestone.triggeredAt.toISOString()
: null,
offloaded: bookingMilestone('OFFLOADED')?.status === 'COMPLETED',
finalInvoice,
riskLevel:
riskMilestone?.status === 'COMPLETED'
? ((riskMilestone.metadata?.riskLevel as string | undefined) ?? null)
: null,
riskAssignedAt:
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
? riskMilestone.triggeredAt.toISOString()
: null,
secondDuty,
importReleaseGranted:
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
};
}
@@ -859,6 +943,12 @@ export class ContractClearanceService {
});
}
// Export: the declaration is the last GL ET pre-booking action — release
// immediately so booking creation unlocks without a separate confirm click.
if (contract.tradeDirection === 'EXPORT') {
await this.workflowService.onExportReleased(contractId, userId);
}
return this.contractsService.findById(contractId);
}
@@ -1011,6 +1101,13 @@ export class ContractClearanceService {
currentPhase: ContractDocPhase.GlDjCollection,
});
// GL Djibouti may have uploaded the DO early (un-gated) — count it now.
const files = await this.filesService.findByResource(contractId, 'contracts');
if (files.some((f) => f.code === 'delivery_order')) {
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED');
await this.workflowService.markReadyForBooking(contractId);
}
return this.contractsService.findById(contractId);
}
@@ -1018,6 +1115,7 @@ export class ContractClearanceService {
contractId: string,
file: Express.Multer.File,
userId?: string,
vesselDepartureDate?: string,
): Promise<Contract> {
const contract = await this.contractsService.findById(contractId);
this.assertPhasedCustoms(contract);
@@ -1025,17 +1123,11 @@ export class ContractClearanceService {
throw new BadRequestException('Delivery Order applies only to import contracts.');
}
const cycle = await this.contractsRepository.currentCycle(contractId);
if (!cycle?.preClearanceFinalizedAt) {
throw new BadRequestException(
'GL Ethiopia must finalize pre-clearance before the Delivery Order can be uploaded.',
);
}
await this.workflowService.assertPriorComplete(contractId, 'IMPORT', 'DO_COLLECTED');
if (!file) throw new BadRequestException('No Delivery Order uploaded');
// DO upload is deliberately un-gated: GL Djibouti may attach it at any point,
// any file type. The DO_COLLECTED milestone (and booking readiness) still waits
// for GL Ethiopia to finalize pre-clearance so the workflow order holds.
await this.filesService.upsertByCode({
resourceId: contractId,
resource: 'contracts',
@@ -1043,8 +1135,16 @@ export class ContractClearanceService {
file,
});
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForBooking(contractId);
const cycle = await this.contractsRepository.currentCycle(contractId);
if (cycle && vesselDepartureDate?.trim()) {
await this.contractsRepository.updateCycle(cycle.id, {
vesselDepartureDate: vesselDepartureDate.trim(),
});
}
if (cycle?.preClearanceFinalizedAt) {
await this.workflowService.completeMilestone(contractId, 'DO_COLLECTED', userId);
await this.workflowService.markReadyForBooking(contractId);
}
return this.contractsService.findById(contractId);
}

View File

@@ -77,6 +77,7 @@ import {
} from './dto/gl-operations.dto';
import {
AdviseContractDutyDto,
GatepassDto,
RoAmendmentDto,
} from './dto/phased-clearance.dto';
@@ -610,9 +611,15 @@ export class ContractsController {
uploadDeliveryOrder(
@Param('id', ParseUUIDPipe) id: string,
@UploadedFile() file: Express.Multer.File,
@Body('vesselDepartureDate') vesselDepartureDate: string | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.clearanceService.uploadDeliveryOrder(id, file, resolveAuthUserId(user));
return this.clearanceService.uploadDeliveryOrder(
id,
file,
resolveAuthUserId(user),
vesselDepartureDate,
);
}
@Post(':id/clearance/release-order')
@@ -681,6 +688,30 @@ 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')
@@ -873,6 +904,147 @@ export class ContractsController {
return this.glOperationsService.uploadTransportDocument(bookingId, files ?? []);
}
@Post('bookings/:bookingId/t1-documents')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(AnyFilesInterceptor())
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary:
'GL Djibouti uploads T1 transit documents (multi-file) after wagon allocation; locked once the train departs',
})
uploadT1Documents(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFiles() files: Express.Multer.File[],
) {
return this.glOperationsService.uploadT1Documents(bookingId, files ?? []);
}
@Post('bookings/:bookingId/t1-close')
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceEtActions,
FREIGHT_PERMS.contracts.clearanceDjActions,
])
@ApiOperation({
summary:
'Close (accept) the T1 set — GL ET after arrival (import) / GL DJ after gate pass (export)',
})
closeT1(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
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'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL DJ raises the post-offload final invoice (amount + invoice document)',
})
createFinalInvoice(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body('amount') amountRaw: string,
@Body('currency') currency: string | undefined,
@Body('description') description: string | undefined,
@UploadedFile() file: Express.Multer.File,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.createFinalInvoice(
bookingId,
{
amount: Number(amountRaw),
currency: currency?.trim() || 'ETB',
description,
},
file,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the payment slip for the final invoice' })
uploadFinalInvoiceSlip(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.glOperationsService.uploadFinalInvoiceSlip(bookingId, file);
}
@Post('bookings/:bookingId/final-invoice/confirm')
@BookingStaff([
FREIGHT_PERMS.contracts.clearanceDjActions,
FREIGHT_PERMS.contracts.clearanceEtActions,
])
@ApiOperation({ summary: 'GL (ET or DJ) confirms the payment slip — settles the final invoice' })
confirmFinalInvoicePaid(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.confirmFinalInvoicePaid(
bookingId,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/second-duty')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceEtActions)
@UseInterceptors(FileInterceptor('attachment'))
@ApiConsumes('multipart/form-data')
@ApiOperation({
summary: 'GL ET advises (or skips) the post-arrival additional duty/tax round (import)',
})
adviseSecondDuty(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body('dutyRequired') dutyRequiredRaw: string,
@Body('amount') amountRaw: string | undefined,
@Body('currency') currency: string | undefined,
@Body('declarationSerial') declarationSerial: string | undefined,
@UploadedFile() attachment: Express.Multer.File | undefined,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.adviseSecondDuty(
bookingId,
{
dutyRequired: dutyRequiredRaw === 'true' || dutyRequiredRaw === '1',
amount:
amountRaw != null && amountRaw !== '' ? Number(amountRaw) : undefined,
currency: currency ?? 'ETB',
declarationSerial,
},
attachment,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/second-duty-slip')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiOperation({ summary: 'Customer attaches the additional duty/tax payment slip' })
uploadSecondDutySlip(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.glOperationsService.uploadSecondDutySlip(bookingId, file);
}
@Post('bookings/:bookingId/documents')
@BookingStaff(FREIGHT_PERMS.bookings.uploadClearanceOutput)
@UseInterceptors(AnyFilesInterceptor())

View File

@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ExchangeModule, ExchangeOptions } from '@edr/api-common';
import { BillingModule } from '../billing/billing.module';
import { CompaniesModule } from '../companies/companies.module';
import { FilesModule } from '../files/files.module';
import { MinioModule } from '../minio/minio.module';
@@ -64,6 +65,7 @@ import { ContractDocumentViewModelBuilder } from '../../contracts/contract-docum
Booking,
BookingContainerUnit,
]),
BillingModule,
RuleEngineModule,
FileUploadSettingsModule,
DropdownSettingsModule,

View File

@@ -35,3 +35,12 @@ export class RoAmendmentDto {
@IsString()
note?: string;
}
export class GatepassDto {
@ApiPropertyOptional({
description: 'When the gate pass was granted (ISO datetime; defaults to now)',
})
@IsOptional()
@IsString()
gatepassAt?: string;
}

View File

@@ -23,6 +23,8 @@ export interface MilestoneMetadata {
dutyAmount?: number;
dutyCurrency?: string;
declarationSerial?: string;
/** When the gate pass was physically granted (GL DJ captures the time). */
gatepassAt?: string;
}
/**

View File

@@ -1,14 +1,29 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, In, 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 { 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 { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import { persistExportTransportUploads } from './phased-clearance.util';
import {
persistExportTransportUploads,
persistT1TransportUploads,
} from './phased-clearance.util';
/**
* Maps a GL post-booking document `code` to the milestone it auto-completes when
@@ -18,7 +33,8 @@ import { persistExportTransportUploads } from './phased-clearance.util';
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: 'T1_CLOSED', // import — GL ET
// 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
@@ -37,6 +53,7 @@ export class GlOperationsService {
private readonly dataSource: DataSource,
private readonly filesService: FilesService,
private readonly milestoneService: ClearanceMilestoneService,
private readonly billingService: BillingService,
) {}
private get bookings() {
@@ -161,6 +178,629 @@ export class GlOperationsService {
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 } });
}
return {
wagonAllocated,
departedAt: schedule?.actualDepartureAt
? new Date(schedule.actualDepartureAt).toISOString()
: null,
arrivedAt: schedule?.actualArrivalAt
? new Date(schedule.actualArrivalAt).toISOString()
: null,
};
}
/**
* T1 transit-document lifecycle state for an import shipment booking. 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) after wagon allocation.
* Replaces the previous batch; locked once the train departs or T1 is closed.
*/
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.',
);
}
if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
}
if (state.trainDepartedAt) {
throw new BadRequestException(
'The train has departed — T1 transport documents can no longer be changed.',
);
}
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 after the gate pass (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 (!done('GATEPASS_GRANTED')) {
throw new BadRequestException('Grant the gate pass before closing T1.');
}
// 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 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 +
* 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.');
const milestones = await this.milestoneService.listForBooking(bookingId);
const offloaded = milestones.find(
(m) => m.milestoneCode === 'OFFLOADED' && m.status === 'COMPLETED',
);
if (!offloaded) {
throw new BadRequestException(
'Cargo must be offloaded 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.');
return summary;
}
/** Customer attaches the payment slip for the final invoice. */
async uploadFinalInvoiceSlip(
bookingId: string,
file: Express.Multer.File,
): Promise<{ uploaded: boolean }> {
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,
});
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> {
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);
}
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,
);
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');
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).
*/

View File

@@ -107,8 +107,12 @@ describe('belongsOnDjClearanceQueue', () => {
).toBe(true);
});
it('excludes import contracts still on Ethiopia-side clearance only', () => {
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(false);
it('keeps import contracts from the start — DO upload is un-gated', () => {
expect(belongsOnDjClearanceQueue('IMPORT', null, [])).toBe(true);
});
it('excludes export contracts with no DJ activity or RO hold', () => {
expect(belongsOnDjClearanceQueue('EXPORT', null, [])).toBe(false);
});
});

View File

@@ -5,7 +5,9 @@ import {
isDeclarationFileCode,
isImportTransitPermitFileCode,
isExportTransportFileCode,
isT1TransportFileCode,
exportTransportFileLabel,
t1TransportFileLabel,
transitPermitFileLabel,
type ClearanceWorkflowFile,
} from '@edr/types';
@@ -160,6 +162,50 @@ export async function persistExportTransportUploads(
);
}
/** Require at least one T1 transport document in the upload batch. */
export function assertT1TransportFiles(files: Express.Multer.File[]): void {
if (files.length === 0) {
throw new BadRequestException('No T1 transport documents uploaded');
}
}
export function normalizeT1TransportFieldNames(
files: Express.Multer.File[],
): Express.Multer.File[] {
return files.map((file, index) => ({
...file,
fieldname: `t1_transport_document_${index}`,
}));
}
/** Replace all T1 transport documents on a booking with a new multi-file batch. */
export async function persistT1TransportUploads(
store: DeclarationFileStore,
bookingId: string,
files: Express.Multer.File[],
): Promise<void> {
const normalized = normalizeT1TransportFieldNames(files);
assertT1TransportFiles(normalized);
const existing = await store.findByResource(bookingId, 'bookings');
await Promise.all(
existing
.filter((f) => f.code && isT1TransportFileCode(f.code))
.map((f) => store.deleteByCode(bookingId, 'bookings', f.code!)),
);
await Promise.all(
normalized.map((file, index) =>
store.upload({
resourceId: bookingId,
resource: 'bookings',
code: `t1_transport_document_${index}`,
file,
}),
),
);
}
export function parseDutyRequiredForm(value: string | boolean | undefined): boolean {
if (typeof value === 'boolean') return value;
if (value === undefined || value === '') return false;
@@ -194,9 +240,9 @@ export function belongsOnDjClearanceQueue(
);
if (hasDjActivity) return true;
const preFinalized =
cycle?.preClearanceFinalizedAt ?? extras?.preClearanceFinalizedAt ?? null;
if (tradeDirection === 'IMPORT' && preFinalized) return true;
// Import DO upload is un-gated — Djibouti GL must see import customs items from
// the start, not only after Ethiopia finalizes pre-clearance.
if (tradeDirection === 'IMPORT') return true;
return false;
}
@@ -295,6 +341,22 @@ export function buildWorkflowFiles(
file: { id: file.id, name: file.name, url: file.url },
});
});
const extraT1 = files
.filter((f) => f.code && isT1TransportFileCode(f.code) && !included.has(f.code))
.sort((a, b) => (a.code ?? '').localeCompare(b.code ?? ''));
extraT1.forEach((file, index) => {
if (!file.code) return;
included.add(file.code);
out.push({
code: file.code,
label: t1TransportFileLabel(file.code, index),
uploadedBy: 'gl_dj',
category: 'djibouti',
file: { id: file.id, name: file.name, url: file.url },
});
});
}
if (tradeDirection === 'EXPORT') {

View File

@@ -48,12 +48,10 @@ export class DriversService {
const qb = this.driverRepo.createQueryBuilder('d');
if (query.search) {
const searchTerm = `%${query.search}%`;
qb.where('d.firstName ILIKE :search', { search: searchTerm })
.orWhere('d.lastName ILIKE :search', { search: searchTerm })
.orWhere('d.email ILIKE :search', { search: searchTerm })
.orWhere('d.licenseNumber ILIKE :search', { search: searchTerm })
.orWhere('d.phoneNumber ILIKE :search', { search: searchTerm });
qb.where(
'(d.firstName ILIKE :search OR d.lastName ILIKE :search OR d.email ILIKE :search OR d.licenseNumber ILIKE :search OR d.phoneNumber ILIKE :search)',
{ search: `%${query.search}%` },
);
}
if (query.status) {

View File

@@ -65,6 +65,12 @@ export class FirstMileController {
return this.firstMileService.findById(id);
}
@Get('acceptitem/:id')
@ApiOperation({ summary: 'Get a first-mile accep by ID' })
acceptItem(@Param('id', ParseUUIDPipe) id: string) {
return this.firstMileService.acceptBooking(id);
}
@Post('accept/:reference')
@TrainSchedulingManage()
@ApiOperation({ summary: 'Accept a paid booking and create a first-mile leg' })

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
import { FindOptionsWhere } from "typeorm";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { FindOptionsWhere, In } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { BookingsRepository } from "../bookings/bookings.repository";
import { DriversService } from "../drivers/drivers.service";
import { SmsClientService } from "../notifications/sms-client.service";
@@ -95,7 +95,6 @@ export class FirstMileService {
if (booking.paymentStatus !== "PAID") {
return null;
}
if (!this.bookingRequestsFirstMile(booking)) {
return null;
}
@@ -198,7 +197,7 @@ export class FirstMileService {
return existing;
}
return this.firstMileRepository.create({
const record = await this.firstMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? "READY_TO_TRANSIT",
advancedPayment: dto.advancedPayment ?? 0,
@@ -208,6 +207,12 @@ export class FirstMileService {
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,
});
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
}
return record;
}
private async findByBookingId(bookingId: string): Promise<FirstMile | null> {
@@ -233,12 +238,10 @@ export class FirstMileService {
firstMilePickupAddress?: string | null;
serviceType?: { includesFirstMile?: boolean | null } | null;
}): boolean {
// Export bookings always need a first mile (pickup → origin yard); the
// pickup address is captured at assignment time, not required upfront.
return Boolean(
booking.tradeDirection === "EXPORT" ||
booking.firstMilePickupAddress?.trim() ||
booking.serviceType?.includesFirstMile,
booking.tradeDirection === 'EXPORT' &&
(booking.firstMilePickupAddress?.trim() ||
booking.serviceType?.includesFirstMile),
);
}
@@ -267,28 +270,62 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${id} not found`);
}
// Keep vehicle statuses in sync: new vehicle goes BUSY, replaced one goes back to FREE
if (dto.vehicleId !== undefined && dto.vehicleId !== existing.vehicleId) {
if (dto.vehicleId) {
await this.vehiclesService.setAvailability(dto.vehicleId, VehicleAvailability.BUSY);
}
if (existing.vehicleId) {
await this.vehiclesService.releaseIfUnused([existing.vehicleId]);
}
}
// Notify assigned driver on every explicit vehicle assignment or reassignment
if (dto.vehicleId) {
void this.notifyDriverAssignment(dto.vehicleId, existing);
}
// Trip finished — release the vehicles it was holding
if (dto.status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}
return updated;
}
async updateStatus(id: string, status: FirstMileStatus): Promise<FirstMile> {
const existing = await this.findById(id);
const updated = await this.firstMileRepository.update(id, { status });
if (!updated) {
throw new NotFoundException(`First-mile record ${id} not found`);
}
if (status === 'RECEIVED_TO_PORT' && existing.status !== 'RECEIVED_TO_PORT') {
await this.releaseVehicles(updated);
}
return updated;
}
private async notifyDriverAssignment(
vehicleId: string,
record: FirstMile,
): Promise<void> {
/**
* Free every vehicle held by this record (direct assignment + container
* allocations), unless still in use by another active trip.
*/
private async releaseVehicles(record: FirstMile): Promise<void> {
const recordAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
where: { firstMileId: record.id },
});
const vehicleIds = recordAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
if (record.vehicleId) {
vehicleIds.push(record.vehicleId);
}
await this.vehiclesService.releaseIfUnused(vehicleIds);
}
private async notifyDriverAssignment(vehicleId: string, record: FirstMile): Promise<void> {
try {
const vehicle = await this.vehiclesService.findById(vehicleId);
if (!vehicle.assignedDriverId) {
@@ -359,6 +396,16 @@ export class FirstMileService {
throw new NotFoundException(`First-mile record ${firstMileId} not found`);
}
const previousAllocations = await this.dataSource.manager.find(FirstMileContainerAllocation, {
where: {
firstMileId,
containerId: In(allocations.map((a) => a.containerId)),
},
});
const previousVehicleIds = previousAllocations
.map((a) => a.vehicleId)
.filter((id): id is string => Boolean(id));
await this.dataSource.transaction(async (manager) => {
for (const allocation of allocations) {
await manager.delete(FirstMileContainerAllocation, {
@@ -375,6 +422,14 @@ export class FirstMileService {
}
});
const vehicleIds = new Set(allocations.map((a) => a.vehicleId));
await Promise.all(
[...vehicleIds].map((vehicleId) => this.vehiclesService.setAvailability(vehicleId, VehicleAvailability.BUSY)),
);
await this.vehiclesService.releaseIfUnused(
previousVehicleIds.filter((id) => !vehicleIds.has(id)),
);
return {
success: true,
allocated: allocations.length,

View File

@@ -71,6 +71,7 @@ export class LastMileService {
return null;
}
return this.create({
bookingId: booking.id,
advancedPayment: 0,
@@ -131,12 +132,6 @@ export class LastMileService {
}
async create(dto: CreateLastMileDto): Promise<LastMile> {
const [existing] = await this.lastMileRepository.findAll({
where: { bookingId: dto.bookingId },
take: 1,
});
if (existing) return existing;
return this.lastMileRepository.create({
bookingId: dto.bookingId,
status: dto.status ?? 'READY_TO_TRANSIT',

View File

@@ -83,6 +83,34 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'booking_window_status', type: 'varchar', length: 10, default: 'OPEN' })
bookingWindowStatus!: string;
/**
* Booking-window lifecycle for the one-booking-day cycle
* (PRE_WINDOW → OPEN → DOC_REVIEW → PAYMENT → reopen | CLOSED_FOR_DAY | DONE).
* NULL on legacy and DOMESTIC schedules — the window engine ignores those.
*/
@Column({ name: 'window_phase', type: 'varchar', length: 20, nullable: true })
windowPhase?: string | null;
@Column({ name: 'window_opens_at', type: 'timestamptz', nullable: true })
windowOpensAt?: Date | null;
@Column({ name: 'window_closes_at', type: 'timestamptz', nullable: true })
windowClosesAt?: Date | null;
@Column({ name: 'doc_review_ends_at', type: 'timestamptz', nullable: true })
docReviewEndsAt?: Date | null;
/** Staff finished document review early — starts the batch/payment phase immediately. */
@Column({ name: 'doc_review_completed_at', type: 'timestamptz', nullable: true })
docReviewCompletedAt?: Date | null;
@Column({ name: 'payment_phase_ends_at', type: 'timestamptz', nullable: true })
paymentPhaseEndsAt?: Date | null;
/** 1-based count of open→settle cycles run on the booking day. */
@Column({ name: 'booking_cycle_no', type: 'int', default: 0 })
bookingCycleNo!: number;
@OneToMany(() => TrainScheduleBooking, (scheduleBooking) => scheduleBooking.trainSchedule)
scheduleBookings?: TrainScheduleBooking[];
}

View File

@@ -121,6 +121,66 @@ function windowFromEatStart(
};
}
/** Build a UTC Date for an EAT wall-clock time on a `yyyy-MM-dd` EAT calendar day. */
export function eatDayToUtc(day: string, hour: number, minute = 0): Date {
const [year, month, dayOfMonth] = day.split('-').map(Number);
return eatToUtc(year, month, dayOfMonth, hour, minute);
}
/** Shift a `yyyy-MM-dd` EAT day key by whole days. */
export function shiftEatDay(day: string, deltaDays: number): string {
// Noon UTC keeps the +3h EAT offset from crossing a day boundary.
const [year, month, dayOfMonth] = day.split('-').map(Number);
const shifted = new Date(Date.UTC(year, month - 1, dayOfMonth + deltaDays, 12));
return `${shifted.getUTCFullYear()}-${String(shifted.getUTCMonth() + 1).padStart(2, '0')}-${String(
shifted.getUTCDate(),
).padStart(2, '0')}`;
}
export interface InitialWindowTimes {
windowOpensAt: Date;
windowClosesAt: Date;
}
/**
* Import booking-day window: opens at `windowOpenHour` EAT on departure-day minus
* `importWindowLeadDays`, for `windowDurationHours`. A schedule created after its
* computed window has fully passed gets a same-day window starting now instead,
* capped at departure.
*/
export function computeImportWindowTimes(
departure: Date,
cfg: {
importWindowLeadDays: number;
windowOpenHour: number;
windowDurationHours: number;
},
now: Date,
): InitialWindowTimes {
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt = eatDayToUtc(windowDay, cfg.windowOpenHour);
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
if (closesAt.getTime() <= now.getTime()) {
opensAt = now;
closesAt = new Date(now.getTime() + cfg.windowDurationHours * 3_600_000);
}
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: closesAt };
}
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */
export function computeExportWindowTimes(
departure: Date,
cfg: { exportBookingLeadHours: number },
): InitialWindowTimes {
return {
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000),
windowClosesAt: departure,
};
}
/** Which 3h EAT intake window a timestamp (e.g. fullyExecutedAt) belongs to. */
export function getBatchWindowForTimestamp(date: Date): BatchWindow {
const { year, month, day, hour } = eatParts(date);

View File

@@ -1,20 +1,14 @@
/**
* Tunables for the demand-batching booking → allocation flow.
* Times run in EAT so the 07:00/10:00/… boundaries match the local operating clock.
* Times run in EAT so window boundaries match the local operating clock.
*
* Cadence and pay-window durations moved to the train_scheduling_global_rules
* table (TrainSchedulingService.getWindowConfig) — the window engine
* (BookingWindowService) drives all timing off that config.
*/
/** Batch boundaries — every 3h from 00:00 (0003, 0306, … 2124), matching the board windows. */
// export const BATCH_CRON = '0 7,10,13,16,19,22 * * *';
// export const BATCH_CRON = '*/3 * * * *';
export const BATCH_CRON = '*/5 * * * *';
// export const BATCH_CRON = '0 */3 * * *';//
export const BATCH_TIMEZONE = 'Africa/Addis_Ababa';
/** How long a selected commercial customer has to pay before their slot expires. */
// export const PAYMENT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
export const PAYMENT_WINDOW_MS = 5 * 60 * 1000; // 5 minutes (test mode)
/** Fallback wagons-per-booking when a booking has no computed `wagonsRequired`. */
export const DEFAULT_WAGONS_PER_BOOKING = 1;

View File

@@ -35,6 +35,7 @@ describe('BookingBatchService — PAID reconcile', () => {
let trainSchedulingService: {
tryAutoWagonAllocation: jest.Mock;
getBookableSchedules: jest.Mock;
getWindowConfig: jest.Mock;
};
let dataSource: {
getRepository: jest.Mock;
@@ -77,6 +78,15 @@ describe('BookingBatchService — PAID reconcile', () => {
violations: [],
}),
getBookableSchedules: jest.fn().mockResolvedValue([]),
getWindowConfig: jest.fn().mockResolvedValue({
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 8,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,
reopenDelayMinutes: 90,
}),
};
const bookingRepo = {
@@ -187,17 +197,24 @@ describe('BookingBatchService — PAID reconcile', () => {
}) as unknown as Booking;
beforeEach(() => {
// Two OPEN trains on the same route + day, train A earlier than train B.
trainSchedulingService.getBookableSchedules.mockResolvedValue([
// Two OPEN legacy trains on the same route + day, train A earlier than train B.
// fillRouteDay now selects fillable schedules straight from the repository.
trainSchedulesRepository.findAll.mockResolvedValue([
{
id: trainA,
scheduleDate: '2026-06-20T06:00:00.000Z',
originStationId: originYardId,
destinationStationId: destinationYardId,
scheduledDepartureDate: new Date('2026-06-20T06:00:00.000Z'),
bookingWindowStatus: 'OPEN',
windowPhase: null,
},
{
id: trainB,
scheduleDate: '2026-06-20T09:00:00.000Z',
originStationId: originYardId,
destinationStationId: destinationYardId,
scheduledDepartureDate: new Date('2026-06-20T09:00:00.000Z'),
bookingWindowStatus: 'OPEN',
windowPhase: null,
},
]);
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
Injectable,
Logger,
NotFoundException,
@@ -7,7 +8,7 @@ import {
Optional,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { Cron, SchedulerRegistry } from '@nestjs/schedule';
import { SchedulerRegistry } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
@@ -22,17 +23,14 @@ import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-r
import { BookingNotifierService } from './booking-notifier.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
import { Freight } from "@edr/types";
import { Freight, TrainScheduleStatus as TrainScheduleStatusEnum } from "@edr/types";
import { BillingService } from "../billing/billing.service";
import {
BATCH_CRON,
BATCH_TIMEZONE,
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_WAGONS_PER_BOOKING,
PAYMENT_WINDOW_MS,
} from "./booking-batch.constants";
import {
bookingTrainLengthMeters,
@@ -41,6 +39,7 @@ import {
} from './train-capacity.util';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
import { BookingSplitService } from './booking-split.service';
/** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity {
@@ -121,6 +120,13 @@ export interface BatchBoardScheduleDetail {
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
direction: string | null;
windowPhase: string | null;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
locomotive: BatchBoardSchedule["locomotive"];
capacity: BatchBoardSchedule["capacity"];
counts: BatchBoardSchedule["counts"];
@@ -138,6 +144,13 @@ export interface BatchBoardSchedule {
scheduleDate: string | null;
status: string;
bookingWindowStatus: string;
direction: string | null;
windowPhase: string | null;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
bookingCycleNo: number;
locomotive: {
code: string;
name: string | null;
@@ -189,6 +202,7 @@ export class BookingBatchService implements OnModuleInit {
private readonly billing: BillingService,
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
@Optional() private readonly splitService?: BookingSplitService,
) {}
@@ -284,11 +298,17 @@ export class BookingBatchService implements OnModuleInit {
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
}
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
/**
* Distinct (origin, destination, EAT day) groups across LEGACY OPEN schedules —
* schedules with a `windowPhase` are driven exclusively by the window engine
* (BookingWindowService), never by the periodic legacy fill.
*/
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: "OPEN" },
});
const open = (
await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: "OPEN" },
})
).filter((s) => s.windowPhase == null);
const groups = new Map<string, RouteDayGroup>();
for (const s of open) {
if (!s.scheduledDepartureDate) continue;
@@ -340,6 +360,12 @@ export class BookingBatchService implements OnModuleInit {
.update(bookingId, { paymentStatus: "PAID" });
}
// Paying inside the window accepts an open partial offer — reduce the booking
// to the offered part before it boards (remainder returns to the contract cap).
if (this.splitService) {
await this.splitService.applySplit(bookingId);
}
const linked =
await this.trainScheduleBookingsRepository.existsForBooking(bookingId);
if (!linked) {
@@ -381,6 +407,99 @@ export class BookingBatchService implements OnModuleInit {
await this.ensurePaidBookingAllocated(bookingId);
}
/** Open partial-capacity offer summary for booking detail payloads (null when none). */
async getOpenOfferSummary(bookingId: string): Promise<{
offeredWagons: number;
totalWagons: number;
offeredAmount: number;
paymentDeadline: Date;
} | null> {
if (!this.splitService) return null;
const offer = await this.splitService.findOpenOffer(bookingId);
if (!offer) return null;
return {
offeredWagons: offer.offeredWagons,
totalWagons: offer.totalWagons,
offeredAmount: Number(offer.offeredAmount),
paymentDeadline: offer.paymentDeadline,
};
}
// ---- export FCFS -----------------------------------------------------------
/**
* Export is first-come-first-serve: no window cycle, no priority, no batch.
* Pick the earliest open export train on the booking's corridor/day that still
* fits the booking. Throws ConflictException when every train is full — the
* staff accept fails and no more export bookings are taken.
*/
async pickExportSchedule(booking: Booking): Promise<string> {
if (!booking.scheduledDate) {
throw new BadRequestException('Booking has no scheduled date');
}
const day = eatDay(new Date(booking.scheduledDate));
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
originStationId: booking.originYardId,
destinationStationId: booking.destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
});
const candidates = corridor
.filter(
(s) =>
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
this.isFillable(s),
)
.sort(
(a, b) =>
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
);
if (!candidates.length) {
throw new ConflictException(
'No export train is accepting bookings for this day',
);
}
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const need = this.needFor(booking, wagonLengths);
for (const candidate of candidates) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
candidate.id,
);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) continue;
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
if (this.fits(need, budget)) return schedule.id;
}
throw new ConflictException('Train is full — no export capacity left for this day');
}
/**
* Reserve an accepted export booking on its picked train and open the pay
* window immediately (payment notification goes out on reserve). Marks the
* train FULL when this reservation exhausts the wagon budget.
*/
async reserveExportBooking(booking: Booking, scheduleId: string): Promise<void> {
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (schedule && (await this.remainingWagons(schedule)) <= 0) {
await this.setWindow(scheduleId, 'FULL');
}
}
/** Link PAID bookings that have no train_schedule_bookings row (cron backstop). */
async reconcilePaidUnlinked(scheduleId: string): Promise<void> {
const unlinked =
@@ -393,9 +512,13 @@ export class BookingBatchService implements OnModuleInit {
}
}
// ---- cron entry point -----------------------------------------------------
// ---- legacy fill entry point ----------------------------------------------
@Cron(BATCH_CRON, { name: "booking-batch-fill", timeZone: BATCH_TIMEZONE })
/**
* Legacy periodic fill for schedules without a window phase (DOMESTIC and
* pre-migration trains). Invoked by BookingWindowService's tick — the old
* standalone cron was replaced by the window engine.
*/
async runBatchFill(): Promise<void> {
const groups = await this.openRouteDayGroups();
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
@@ -595,6 +718,15 @@ export class BookingBatchService implements OnModuleInit {
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
direction: s.direction ?? null,
windowPhase: s.windowPhase ?? null,
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
paymentPhaseEndsAt: s.paymentPhaseEndsAt
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
locomotive: loco
? {
code: loco.code,
@@ -679,6 +811,15 @@ export class BookingBatchService implements OnModuleInit {
: null,
status: s.status,
bookingWindowStatus: s.bookingWindowStatus,
direction: s.direction ?? null,
windowPhase: s.windowPhase ?? null,
windowOpensAt: s.windowOpensAt ? s.windowOpensAt.toISOString() : null,
windowClosesAt: s.windowClosesAt ? s.windowClosesAt.toISOString() : null,
docReviewEndsAt: s.docReviewEndsAt ? s.docReviewEndsAt.toISOString() : null,
paymentPhaseEndsAt: s.paymentPhaseEndsAt
? s.paymentPhaseEndsAt.toISOString()
: null,
bookingCycleNo: s.bookingCycleNo ?? 0,
locomotive: loco
? {
code: loco.code,
@@ -722,11 +863,27 @@ export class BookingBatchService implements OnModuleInit {
// ---- core fill ------------------------------------------------------------
/**
* Whether the batch engine may reserve/allocate onto this schedule right now.
* Legacy (no window phase): the customer-facing OPEN gate doubles as the fill gate.
* Import window cycle: the engine fills while the customer window is CLOSED —
* during DOC_REVIEW (early staff trigger) and PAYMENT (batch run + top-ups).
* Export: FCFS while the booking window is open.
*/
isFillable(schedule: TrainSchedule): boolean {
if (schedule.bookingWindowStatus === "FULL") return false;
if (!schedule.windowPhase) return schedule.bookingWindowStatus === "OPEN";
if (schedule.direction === "EXPORT") {
return schedule.windowPhase === "OPEN" && schedule.bookingWindowStatus === "OPEN";
}
return schedule.windowPhase === "DOC_REVIEW" || schedule.windowPhase === "PAYMENT";
}
/** Fill one schedule from its priority-ordered pool until full. */
async fillSchedule(scheduleId: string): Promise<void> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule || schedule.bookingWindowStatus !== "OPEN") return;
if (!schedule || !this.isFillable(schedule)) return;
const locomotive = schedule.trainSet?.locomotive;
if (!schedule.trainSetId || !locomotive) {
this.logger.warn(
@@ -793,22 +950,33 @@ export class BookingBatchService implements OnModuleInit {
destinationYardId: string,
day: string,
): Promise<string[]> {
// The day's OPEN bookable schedules on this exact corridor, earliest first.
const bookable = await this.trainSchedulingService.getBookableSchedules(
originYardId,
destinationYardId,
);
const scheduleIds = bookable
// The day's fillable schedules on this exact corridor, earliest first. Fillable
// covers legacy OPEN trains and window-cycle trains in DOC_REVIEW/PAYMENT —
// the batch must run while the customer window is closed.
const corridor = await this.trainSchedulesRepository.findAll({
where: [
{
originStationId: originYardId,
destinationStationId: destinationYardId,
status: TrainScheduleStatusEnum.Draft,
},
{
originStationId: originYardId,
destinationStationId: destinationYardId,
status: TrainScheduleStatusEnum.Scheduled,
},
],
});
const scheduleIds = corridor
.filter(
(s) =>
s.bookingWindowStatus === "OPEN" &&
s.scheduleDate != null &&
eatDay(new Date(s.scheduleDate)) === day,
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === day &&
this.isFillable(s),
)
.sort(
(a, b) =>
new Date(a.scheduleDate).getTime() -
new Date(b.scheduleDate).getTime(),
a.scheduledDepartureDate.getTime() - b.scheduledDepartureDate.getTime(),
)
.map((s) => s.id);
@@ -870,7 +1038,33 @@ export class BookingBatchService implements OnModuleInit {
}
if (!target) {
// Fits no train this day — stays in the pool, retried next batch.
// Fits no train whole. Import GENERAL-contract commercial bookings get a
// partial-capacity offer on the train with the most free wagons: pay =
// accept the split (remainder returns to the contract cap), no pay =
// booking stays whole and expires for this train.
const partialTarget = [...trains]
.filter((t) => t.budget.wagons >= 1)
.sort((a, b) => b.budget.wagons - a.budget.wagons)[0];
if (
partialTarget &&
!booking.isGovernment &&
booking.tradeDirection === "IMPORT" &&
booking.contractKind === "GENERAL" &&
this.splitService
) {
const offered = await this.tryPartialOffer(
booking,
partialTarget.id,
partialTarget.budget,
need,
);
if (offered) {
partialTarget.budget = this.subtract(partialTarget.budget, offered);
partialTarget.armed = true;
continue;
}
}
// Stays in the pool, retried next batch/window cycle.
this.notifier.unplaced(booking, day);
continue;
}
@@ -893,6 +1087,62 @@ export class BookingBatchService implements OnModuleInit {
return trains.map((t) => t.id);
}
/**
* Offer the largest fitting part of an over-capacity booking as a partial
* (split-on-payment). Returns the capacity the offer consumes, or null when no
* meaningful partial fits / an offer is already open.
*/
private async tryPartialOffer(
booking: Booking,
scheduleId: string,
budget: Capacity,
need: Capacity,
): Promise<Capacity | null> {
if (!this.splitService) return null;
if (await this.splitService.findOpenOffer(booking.id)) return null;
const wagonLengths = await this.loadWagonLengths();
const bulkCapacityTons = await this.loadBulkWagonCapacityTons();
const sized = await this.splitService.sizeOffer(
booking,
budget.wagons,
need.wagons,
bulkCapacityTons,
);
if (!sized) return null;
const offeredNeed: Capacity = {
wagons: sized.offeredWagons,
weightTons: sized.offeredWeightTons,
lengthMeters: bookingTrainLengthMeters(booking.freightType, sized.offeredWagons, {
container: wagonLengths.container,
bulk: wagonLengths.bulk,
}),
};
if (!this.fits(offeredNeed, budget)) return null;
const deadline = new Date(Date.now() + (await this.paymentWindowMs()));
await this.splitService.createOffer(booking, scheduleId, sized, deadline);
// Reserve like a normal batch selection, but the partial invoice + partial
// pay-now notification were already produced by createOffer.
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: "SELECTED_FOR_BATCH",
selectedForBatchAt: new Date(),
paymentDeadline: deadline,
} as never);
booking.trainScheduleId = scheduleId;
return offeredNeed;
}
private async loadBulkWagonCapacityTons(): Promise<number> {
const cw3 = await this.dataSource
.getRepository(WagonType)
.findOne({ where: { code: "CW3" } });
const capacity = cw3 ? wagonTypeDimensionsFromEntity(cw3).capacityTons : 60;
return capacity > 0 ? capacity : 60;
}
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
async settleDueReservations(scheduleId: string): Promise<void> {
const reserved =
@@ -1063,7 +1313,7 @@ export class BookingBatchService implements OnModuleInit {
*/
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
const now = new Date();
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
const deadline = new Date(now.getTime() + (await this.paymentWindowMs()));
await this.bookingsRepository.update(booking.id, {
trainScheduleId: scheduleId,
status: "SELECTED_FOR_BATCH",
@@ -1136,6 +1386,10 @@ export class BookingBatchService implements OnModuleInit {
selectedForBatchAt: null,
} as never);
booking.trainScheduleId = null;
// An unpaid partial offer dies with the reservation — the booking stays whole.
if (this.splitService) {
await this.splitService.expireOpenOffer(booking.id);
}
// Pay window closed before settlement → expire the booking's open invoice too
// (emits `booking.invoice.expired`). Domain owns the reaction; billing stays
// source-agnostic.
@@ -1359,7 +1613,7 @@ export class BookingBatchService implements OnModuleInit {
return (schedule.maxWagons ?? 0) - used;
}
private async setWindow(
async setWindow(
scheduleId: string,
status: "OPEN" | "FULL" | "CLOSED",
): Promise<void> {
@@ -1368,22 +1622,48 @@ export class BookingBatchService implements OnModuleInit {
.update(scheduleId, { bookingWindowStatus: status });
}
/** No wagon slots left for allocated + reserved bookings. */
async isScheduleFull(scheduleId: string): Promise<boolean> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) return false;
return (await this.remainingWagons(schedule)) <= 0;
}
// ---- timer plumbing -------------------------------------------------------
/** Configured customer pay window in ms (global rules, with defaults). */
private async paymentWindowMs(): Promise<number> {
const cfg = await this.trainSchedulingService.getWindowConfig();
return cfg.paymentWindowMinutes * 60_000;
}
private timeoutName(scheduleId: string): string {
return `settle:${scheduleId}`;
}
/**
* In-process accelerator only — the durable settle enforcement is the window
* engine's minute tick calling settleDueReservations off `paymentDeadline`.
*/
private armSettle(scheduleId: string): void {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>
this.logger.error(
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
void this.paymentWindowMs()
.then((delayMs) => {
this.removeTimeout(scheduleId);
const handle = setTimeout(() => {
void this.settleBatch(scheduleId).catch((err) =>
this.logger.error(
`settleBatch ${scheduleId} failed: ${(err as Error).message}`,
),
);
}, delayMs);
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
})
.catch((err) =>
this.logger.warn(
`armSettle ${scheduleId} skipped: ${(err as Error).message}`,
),
);
}, PAYMENT_WINDOW_MS);
this.scheduler.addTimeout(this.timeoutName(scheduleId), handle);
}
private removeTimeout(scheduleId: string): void {

View File

@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { Booking } from '../bookings/entities/booking.entity';
import { NotificationsService } from '../notifications/notifications.service';
import { PAYMENT_WINDOW_MS } from './booking-batch.constants';
@Injectable()
export class BookingNotifierService {
@@ -43,12 +42,32 @@ export class BookingNotifierService {
}
async payNow(b: Booking, deadline: Date): Promise<void> {
const payMinutes = Math.round(PAYMENT_WINDOW_MS / 60_000);
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg = `Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to secure train slot ${b.reference ?? b.id}. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW');
}
/**
* Partial-capacity offer: only `offeredWagons` of the booking's `totalWagons` fit
* this train. Paying accepts the split; letting the deadline pass keeps the
* booking whole and expires it for this train.
*/
async payNowPartial(
b: Booking,
deadline: Date,
offeredWagons: number,
totalWagons: number,
): Promise<void> {
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
const msg =
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now ` +
`(the rest returns to your contract to book later). If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
}
secured(b: Booking, reason: 'paid' | 'gov'): void {
const msg = `Booking ${b.reference ?? b.id} allocated on train schedule ${b.trainScheduleId ?? ''}${
reason === 'gov' ? ' (government)' : ''

View File

@@ -0,0 +1,270 @@
import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Freight } from '@edr/types';
import { BookingPricingService } from '../bookings/booking-pricing.service';
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
import { BillingService } from '../billing/billing.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
import {
BookingBatchOffer,
OfferedLine,
} from './entities/booking-batch-offer.entity';
import { BookingNotifierService } from './booking-notifier.service';
export interface SizedOffer {
offeredWagons: number;
totalWagons: number;
offeredLines: OfferedLine[] | null;
offeredWeightTons: number;
offeredAmount: number;
offeredPricingBreakdown: Record<string, unknown>;
}
/**
* Partial-capacity booking splits (import batch). The offer is sized and priced
* against an in-memory clone — the booking row is untouched until the customer
* pays, which is the act of accepting the split (applySplit). No payment →
* offer expires and the booking stays whole.
*
* Only GENERAL-contract commercial bookings are offered partials: the remainder
* returns to the contract's quantity cap (derived live from booking_container
* rows, so reducing the lines releases it automatically) and can be rebooked in
* any later window within contract validity.
*/
@Injectable()
export class BookingSplitService {
private readonly logger = new Logger(BookingSplitService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
@Inject(forwardRef(() => BookingPricingService))
private readonly pricing: BookingPricingService,
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService,
private readonly billing: BillingService,
private readonly notifier: BookingNotifierService,
) {}
/**
* Size the largest part of the booking that fits `freeWagons`, priced via an
* in-memory clone. Returns null when nothing meaningful fits (no whole
* container unit / no bulk tonnage, or pricing failed).
*/
async sizeOffer(
booking: Booking,
freeWagons: number,
totalWagons: number,
bulkWagonCapacityTons: number,
): Promise<SizedOffer | null> {
if (freeWagons < 1 || freeWagons >= totalWagons) return null;
const containers = booking.bookingContainers ?? [];
let offeredLines: OfferedLine[] | null = null;
let offeredWeightTons = 0;
let offeredWagons = 0;
const clone: Booking = Object.assign(Object.create(Object.getPrototypeOf(booking)), booking);
clone.adjustedTotalAmount = null;
if (containers.length) {
offeredLines = [];
let remaining = freeWagons;
const clonedContainers: BookingContainer[] = [];
for (const line of containers) {
const quantity = Number(line.quantity ?? 0);
const lineWagons = Number(line.wagonsRequired ?? 0);
if (quantity <= 0 || lineWagons <= 0 || remaining <= 0) continue;
const perUnit = lineWagons / quantity;
// Largest unit count whose wagon need still fits the remaining budget.
let take = Math.min(quantity, Math.floor(remaining / perUnit));
while (take > 0 && Math.ceil(take * perUnit) > remaining) take -= 1;
if (take <= 0) continue;
const takeWagons = Math.ceil(take * perUnit);
const vgmPerUnit = Number(line.vgmPerUnitTons ?? 0);
offeredLines.push({
bookingContainerId: line.id,
quantity: take,
wagonsRequired: takeWagons,
totalVgmTons: Math.round(take * vgmPerUnit * 1000) / 1000,
});
offeredWeightTons += take * vgmPerUnit;
offeredWagons += takeWagons;
remaining -= takeWagons;
const clonedLine: BookingContainer = Object.assign(
Object.create(Object.getPrototypeOf(line)),
line,
{
quantity: take,
wagonsRequired: takeWagons,
totalVgmTons: take * vgmPerUnit,
},
);
clonedContainers.push(clonedLine);
}
if (!offeredLines.length || offeredWagons <= 0) return null;
clone.bookingContainers = clonedContainers;
} else {
// Bulk: split by weight — the offered part is what freeWagons can carry.
const totalWeight = Number(booking.cargoTotalWeightVgm ?? 0);
if (totalWeight <= 0 || bulkWagonCapacityTons <= 0) return null;
offeredWeightTons = Math.min(totalWeight, freeWagons * bulkWagonCapacityTons);
if (offeredWeightTons <= 0) return null;
offeredWagons = Math.min(
freeWagons,
Math.max(1, Math.ceil(offeredWeightTons / bulkWagonCapacityTons)),
);
}
offeredWeightTons = Math.round(offeredWeightTons * 1000) / 1000;
clone.cargoTotalWeightVgm = offeredWeightTons;
clone.wagonsRequired = offeredWagons;
try {
const priced = await this.pricing.computePriceForBooking(clone);
return {
offeredWagons,
totalWagons,
offeredLines,
offeredWeightTons,
offeredAmount: priced.totalAmount,
offeredPricingBreakdown: {
lineItems: priced.lineItems,
totalAmount: priced.totalAmount,
currency: priced.currency,
generatedAt: new Date().toISOString(),
partialOfWagons: totalWagons,
},
};
} catch (err) {
this.logger.warn(
`Partial pricing failed for ${booking.reference ?? booking.id}: ${(err as Error).message}`,
);
return null;
}
}
/**
* Persist the offer and swap the booking's payable to a partial invoice for the
* offered amount. Any previous open offer for the booking is superseded.
*/
async createOffer(
booking: Booking,
scheduleId: string,
sized: SizedOffer,
deadline: Date,
): Promise<BookingBatchOffer> {
const repo = this.dataSource.getRepository(BookingBatchOffer);
await repo.update({ bookingId: booking.id, status: 'OFFERED' }, { status: 'EXPIRED' });
// The full-amount invoice must not stay payable next to the partial one.
await this.billing.expirePayable(Freight.InvoiceSource.Booking, booking.id, 'PREPAID');
const invoice = await this.invoiceService.ensureInvoiceForBooking(
{ ...booking, pricingBreakdown: sized.offeredPricingBreakdown, adjustedTotalAmount: null } as Booking,
{ dueDate: deadline, invoiceStatus: Freight.InvoiceStatus.Pending },
);
const offer = await repo.save(
repo.create({
bookingId: booking.id,
trainScheduleId: scheduleId,
offeredWagons: sized.offeredWagons,
totalWagons: sized.totalWagons,
offeredLines: sized.offeredLines,
offeredWeightTons: sized.offeredWeightTons,
offeredAmount: sized.offeredAmount,
offeredPricingBreakdown: sized.offeredPricingBreakdown,
invoiceId: invoice.id,
paymentDeadline: deadline,
status: 'OFFERED',
}),
);
await this.notifier.payNowPartial(booking, deadline, sized.offeredWagons, sized.totalWagons);
return offer;
}
/**
* Payment received inside the window — the customer accepted the split.
* Reduce the booking to the offered lines/weight; the remainder returns to the
* contract cap automatically (bookedQuantities derives from live lines).
* Idempotent: no OFFERED offer → no-op.
*/
async applySplit(bookingId: string): Promise<void> {
const offer = await this.dataSource.getRepository(BookingBatchOffer).findOne({
where: { bookingId, status: 'OFFERED' },
order: { createdAt: 'DESC' },
});
if (!offer) return;
await this.dataSource.transaction(async (manager) => {
if (offer.offeredLines?.length) {
const keptByLine = new Map(offer.offeredLines.map((l) => [l.bookingContainerId, l]));
const lines = await manager.getRepository(BookingContainer).find({
where: { bookingId },
});
for (const line of lines) {
const kept = keptByLine.get(line.id);
if (!kept) {
await manager.getRepository(BookingContainer).softDelete(line.id);
await manager
.getRepository(BookingContainerUnit)
.softDelete({ bookingContainerId: line.id });
continue;
}
const dropCount = Number(line.quantity) - kept.quantity;
await manager.getRepository(BookingContainer).update(line.id, {
quantity: kept.quantity,
wagonsRequired: kept.wagonsRequired,
totalVgmTons: kept.totalVgmTons,
hazardousQuantity: Math.min(Number(line.hazardousQuantity ?? 0), kept.quantity),
reeferQuantity: Math.min(Number(line.reeferQuantity ?? 0), kept.quantity),
});
if (dropCount > 0) {
// Trim surplus physical units, last-entered first.
const units = await manager.getRepository(BookingContainerUnit).find({
where: { bookingContainerId: line.id },
order: { sortOrder: 'DESC', createdAt: 'DESC' },
take: dropCount,
});
if (units.length) {
await manager
.getRepository(BookingContainerUnit)
.softDelete(units.map((u) => u.id));
}
}
}
}
await manager.getRepository(Booking).update(bookingId, {
wagonsRequired: offer.offeredWagons,
cargoTotalWeightVgm: offer.offeredWeightTons,
totalAmount: offer.offeredAmount,
pricingBreakdown: offer.offeredPricingBreakdown,
} as never);
await manager
.getRepository(BookingBatchOffer)
.update(offer.id, { status: 'APPLIED' });
});
this.logger.log(
`Split applied for booking ${bookingId}: ${offer.offeredWagons}/${offer.totalWagons} wagons ride schedule ${offer.trainScheduleId}`,
);
}
/** Pay window closed without payment — offer dies, booking stays whole. */
async expireOpenOffer(bookingId: string): Promise<void> {
await this.dataSource
.getRepository(BookingBatchOffer)
.update({ bookingId, status: 'OFFERED' }, { status: 'EXPIRED' });
}
async findOpenOffer(bookingId: string): Promise<BookingBatchOffer | null> {
return this.dataSource.getRepository(BookingBatchOffer).findOne({
where: { bookingId, status: 'OFFERED' },
order: { createdAt: 'DESC' },
});
}
}

View File

@@ -0,0 +1,30 @@
/**
* Booking-window timings sourced from the train_scheduling_global_rules singleton,
* with hardcoded fallbacks when the row is missing (see TrainSchedulingService.getWindowConfig).
*/
export interface BookingWindowConfig {
/** Days before departure the single import booking-window day falls on. */
importWindowLeadDays: number;
/** Hours before departure an export booking becomes acceptable (FCFS). */
exportBookingLeadHours: number;
/** Local (Africa/Addis_Ababa) hour at which the import window opens. */
windowOpenHour: number;
windowDurationHours: number;
/** Max staff document-review time after the window closes. */
docReviewMinutes: number;
paymentWindowMinutes: number;
/** Delay after window close before reopening when the train is not full. */
reopenDelayMinutes: number;
}
/** Window phase lifecycle for the one-booking-day import cycle. NULL on legacy/DOMESTIC schedules. */
export const WINDOW_PHASES = [
'PRE_WINDOW',
'OPEN',
'DOC_REVIEW',
'PAYMENT',
'CLOSED_FOR_DAY',
'DONE',
] as const;
export type WindowPhase = (typeof WINDOW_PHASES)[number];

View File

@@ -0,0 +1,346 @@
import { Injectable, Logger, NotFoundException, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { TrainScheduleStatus as TrainScheduleStatusEnum } from '@edr/types';
import { Booking } from '../bookings/entities/booking.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { BookingBatchService } from './booking-batch.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay } from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
* Drives the one-booking-day window cycle for IMPORT schedules and the FCFS
* booking window for EXPORT schedules. All state lives in DB timestamps on the
* schedule row, so every transition is derived purely from the clock — a restart
* resumes mid-phase with no loss (onModuleInit runs one tick immediately).
*
* Import phases: PRE_WINDOW → OPEN (customers book) → DOC_REVIEW (staff accept
* documents) → PAYMENT (batch reserves in priority order, customers pay) →
* reopen same day | CLOSED_FOR_DAY | DONE (full → auto-finalized).
* Export phases: PRE_WINDOW → OPEN → DONE (no batch, no priority).
* Legacy/DOMESTIC schedules have windowPhase NULL and are served by the legacy
* fill (runBatchFill), which this tick invokes every 5th minute.
*/
@Injectable()
export class BookingWindowService implements OnModuleInit {
private readonly logger = new Logger(BookingWindowService.name);
private ticking = false;
private tickCount = 0;
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly trainSchedulesRepository: TrainSchedulesRepository,
private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService,
) {}
async onModuleInit(): Promise<void> {
await this.tick().catch((err) =>
this.logger.warn(`Boot window tick failed: ${(err as Error).message}`),
);
}
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
async tick(): Promise<void> {
if (this.ticking) return;
this.ticking = true;
try {
const now = new Date();
const cfg = await this.trainSchedulingService.getWindowConfig();
const active = (
await this.trainSchedulesRepository.findAll({
where: [
{ status: TrainScheduleStatusEnum.Draft },
{ status: TrainScheduleStatusEnum.Scheduled },
],
})
).filter(
(s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY',
);
for (const schedule of active) {
try {
await this.advanceSchedule(schedule, cfg, now);
} catch (err) {
this.logger.error(
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
);
}
}
await this.settleOverdueReservations();
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick.
this.tickCount += 1;
if (this.tickCount % 5 === 0) {
await this.bookingBatchService.runBatchFill();
}
} finally {
this.ticking = false;
}
}
/** Staff finished document review early — start the batch/payment phase now. */
async completeDocReview(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (schedule.windowPhase !== 'DOC_REVIEW') {
// Idempotent for the whole route-day group: only DOC_REVIEW schedules move.
return schedule;
}
const now = new Date();
const cfg = await this.trainSchedulingService.getWindowConfig();
// Stamp the whole route-day group so one staff action releases every train
// sharing this booking day's pool.
const group = (
await this.trainSchedulesRepository.findAll({
where: {
originStationId: schedule.originStationId,
destinationStationId: schedule.destinationStationId,
},
})
).filter(
(s) =>
s.windowPhase === 'DOC_REVIEW' &&
s.scheduledDepartureDate != null &&
eatDay(s.scheduledDepartureDate) === eatDay(schedule.scheduledDepartureDate),
);
for (const s of group) {
await this.dataSource
.getRepository(TrainSchedule)
.update(s.id, { docReviewCompletedAt: now });
s.docReviewCompletedAt = now;
await this.advanceSchedule(s, cfg, now);
}
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
return fresh ?? schedule;
}
// ---- transitions ------------------------------------------------------------
private async advanceSchedule(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
now: Date,
): Promise<void> {
// Apply every transition that is due, in order (fast-forwards after downtime).
for (let guard = 0; guard < 6; guard += 1) {
const advanced =
schedule.direction === 'EXPORT'
? await this.advanceExport(schedule, now)
: await this.advanceImport(schedule, cfg, now);
if (!advanced) return;
}
}
/** Export: PRE_WINDOW → OPEN at opensAt, OPEN → DONE at closesAt (= departure). */
private async advanceExport(schedule: TrainSchedule, now: Date): Promise<boolean> {
if (
schedule.windowPhase === 'PRE_WINDOW' &&
schedule.windowOpensAt &&
now >= schedule.windowOpensAt
) {
await this.setPhase(schedule, {
windowPhase: 'OPEN',
bookingCycleNo: schedule.bookingCycleNo + 1,
});
if (schedule.bookingWindowStatus !== 'FULL') {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true;
}
if (
schedule.windowPhase === 'OPEN' &&
schedule.windowClosesAt &&
now >= schedule.windowClosesAt
) {
await this.setPhase(schedule, { windowPhase: 'DONE' });
if (schedule.bookingWindowStatus === 'OPEN') {
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
schedule.bookingWindowStatus = 'CLOSED';
}
return true;
}
return false;
}
private async advanceImport(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
now: Date,
): Promise<boolean> {
const { windowPhase, windowOpensAt, windowClosesAt } = schedule;
if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) {
await this.setPhase(schedule, {
windowPhase: 'OPEN',
bookingCycleNo: schedule.bookingCycleNo + 1,
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
if (schedule.bookingWindowStatus !== 'FULL') {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN';
}
this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
);
return true;
}
if (windowPhase === 'OPEN' && windowClosesAt && now >= windowClosesAt) {
const docReviewEndsAt = new Date(
windowClosesAt.getTime() + cfg.docReviewMinutes * 60_000,
);
await this.setPhase(schedule, { windowPhase: 'DOC_REVIEW', docReviewEndsAt });
if (schedule.bookingWindowStatus === 'OPEN') {
await this.bookingBatchService.setWindow(schedule.id, 'CLOSED');
schedule.bookingWindowStatus = 'CLOSED';
}
this.logger.log(
`Booking stopped for schedule ${schedule.id}; staff document review until ${docReviewEndsAt.toISOString()}`,
);
return true;
}
if (
windowPhase === 'DOC_REVIEW' &&
(schedule.docReviewCompletedAt != null ||
(schedule.docReviewEndsAt != null && now >= schedule.docReviewEndsAt))
) {
const paymentPhaseEndsAt = new Date(now.getTime() + cfg.paymentWindowMinutes * 60_000);
await this.setPhase(schedule, { windowPhase: 'PAYMENT', paymentPhaseEndsAt });
// Run the batch: priority fill over the route-day pool, reserving pay windows
// (or allocating government) — skipped automatically for everyone who fits
// is handled inside the fill (all fit → all reserved → all notified).
await this.bookingBatchService.processRouteDay({
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
day: eatDay(schedule.scheduledDepartureDate),
});
this.logger.log(
`Batch ran for schedule ${schedule.id}; payment phase until ${paymentPhaseEndsAt.toISOString()}`,
);
return true;
}
if (
windowPhase === 'PAYMENT' &&
schedule.paymentPhaseEndsAt != null &&
now >= schedule.paymentPhaseEndsAt
) {
await this.bookingBatchService.settleDueReservations(schedule.id);
await this.concludeCycle(schedule, cfg, now);
return true;
}
return false;
}
/** After settle: full → finalize + DONE; space left → reopen same day or close for the day. */
private async concludeCycle(
schedule: TrainSchedule,
cfg: BookingWindowConfig,
now: Date,
): Promise<void> {
const full = await this.bookingBatchService.isScheduleFull(schedule.id);
if (full) {
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
await this.setPhase(schedule, { windowPhase: 'DONE' });
await this.tryAutoFinalize(schedule.id);
return;
}
const closesAt = schedule.windowClosesAt ?? now;
const reopenAt = new Date(closesAt.getTime() + cfg.reopenDelayMinutes * 60_000);
const nextOpensAt = reopenAt > now ? reopenAt : now;
let nextClosesAt = new Date(nextOpensAt.getTime() + cfg.windowDurationHours * 3_600_000);
if (nextClosesAt > schedule.scheduledDepartureDate) {
nextClosesAt = schedule.scheduledDepartureDate;
}
const sameBookingDay = eatDay(nextOpensAt) === eatDay(closesAt);
const beforeDeparture = nextOpensAt < schedule.scheduledDepartureDate;
if (sameBookingDay && beforeDeparture) {
await this.setPhase(schedule, {
windowPhase: 'PRE_WINDOW',
windowOpensAt: nextOpensAt,
windowClosesAt: nextClosesAt,
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
this.logger.log(
`Schedule ${schedule.id} not full — window reopens at ${nextOpensAt.toISOString()}`,
);
} else {
await this.setPhase(schedule, { windowPhase: 'CLOSED_FOR_DAY' });
this.logger.log(
`Booking day over for schedule ${schedule.id} — remaining capacity is staff-managed`,
);
}
}
private async tryAutoFinalize(scheduleId: string): Promise<void> {
try {
await this.trainSchedulingService.finalizeSchedule(scheduleId);
this.logger.log(`Schedule ${scheduleId} is full — auto-finalized`);
} catch (err) {
// Not DRAFT / no linked bookings yet — staff finalize manually.
this.logger.warn(
`Auto-finalize skipped for ${scheduleId}: ${(err as Error).message}`,
);
}
}
/** Durable settle backstop: expire/allocate reservations whose deadline passed. */
private async settleOverdueReservations(): Promise<void> {
const overdue = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere('b.payment_deadline <= now()')
.andWhere('b.train_schedule_id IS NOT NULL')
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of overdue) {
try {
await this.bookingBatchService.settleDueReservations(scheduleId);
} catch (err) {
this.logger.warn(
`Overdue settle failed for ${scheduleId}: ${(err as Error).message}`,
);
}
}
}
private async setPhase(
schedule: TrainSchedule,
patch: Partial<
Pick<
TrainSchedule,
| 'windowPhase'
| 'windowOpensAt'
| 'windowClosesAt'
| 'docReviewEndsAt'
| 'docReviewCompletedAt'
| 'paymentPhaseEndsAt'
| 'bookingCycleNo'
>
>,
): Promise<void> {
await this.dataSource.getRepository(TrainSchedule).update(schedule.id, patch);
Object.assign(schedule, patch);
}
}

View File

@@ -1,6 +1,6 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Min } from 'class-validator';
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
export class UpdateTrainSchedulingGlobalRulesDto {
@ApiPropertyOptional({ example: 760 })
@@ -37,4 +37,55 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@IsNumber()
@Min(0)
max20ftPairWeightDiffTons?: number;
@ApiPropertyOptional({ example: 3, description: 'Days before departure the import booking-window day falls on' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
importWindowLeadDays?: number;
@ApiPropertyOptional({ example: 24, description: 'Hours before departure an export booking becomes acceptable' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
exportBookingLeadHours?: number;
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowOpenHour?: number;
@ApiPropertyOptional({ example: 3 })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.25)
@Max(12)
windowDurationHours?: number;
@ApiPropertyOptional({ example: 30 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
docReviewMinutes?: number;
@ApiPropertyOptional({ example: 60 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
@ApiPropertyOptional({ example: 90 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
reopenDelayMinutes?: number;
}

View File

@@ -0,0 +1,75 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
export const BOOKING_BATCH_OFFER_STATUSES = ['OFFERED', 'APPLIED', 'EXPIRED'] as const;
export type BookingBatchOfferStatus = (typeof BOOKING_BATCH_OFFER_STATUSES)[number];
/** One reduced container line of a partial offer (per original booking_container row). */
export interface OfferedLine {
bookingContainerId: string;
/** Units of this line that ride the offered train (≤ original quantity). */
quantity: number;
wagonsRequired: number;
totalVgmTons: number;
}
/**
* A partial-capacity payment offer made by the batch when a booking needs more
* wagons than the train has left (e.g. needs 20, 3 free). The booking itself is
* NOT mutated at offer time — paying inside the window accepts the split
* (BookingSplitService.applySplit reduces the booking to the offered lines and
* the remainder returns to the contract's quantity cap); letting the deadline
* pass expires the offer and the booking stays whole.
*/
@Entity({ schema: 'freight', name: 'booking_batch_offers' })
@Index(['bookingId'])
@Index(['trainScheduleId'])
@Index(['status'])
export class BookingBatchOffer extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@ManyToOne(() => TrainSchedule, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'offered_wagons', type: 'int' })
offeredWagons!: number;
/** Booking's full wagon need at offer time (for messaging / audit). */
@Column({ name: 'total_wagons', type: 'int' })
totalWagons!: number;
/** Reduced container lines (null for bulk offers — bulk splits by weight). */
@Column({ name: 'offered_lines', type: 'jsonb', nullable: true })
offeredLines?: OfferedLine[] | null;
@Column({ name: 'offered_weight_tons', type: 'numeric', precision: 12, scale: 3 })
offeredWeightTons!: number;
@Column({ name: 'offered_amount', type: 'numeric', precision: 14, scale: 2 })
offeredAmount!: number;
@Column({ name: 'offered_pricing_breakdown', type: 'jsonb', nullable: true })
offeredPricingBreakdown?: Record<string, unknown> | null;
/** The partial PREPAID invoice generated for the offered part. */
@Column({ name: 'invoice_id', type: 'uuid', nullable: true })
invoiceId?: string | null;
@Column({ name: 'payment_deadline', type: 'timestamptz' })
paymentDeadline!: Date;
@Column({ name: 'status', type: 'varchar', length: 10, default: 'OFFERED' })
status!: BookingBatchOfferStatus;
}

View File

@@ -41,4 +41,36 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
default: 10,
})
max20ftPairWeightDiffTons!: number;
/** Days before departure the single import booking-window day falls on. */
@Column({ name: 'import_window_lead_days', type: 'int', default: 3 })
importWindowLeadDays!: number;
/** Hours before departure an export booking becomes acceptable (FCFS, no window cycle). */
@Column({ name: 'export_booking_lead_hours', type: 'int', default: 24 })
exportBookingLeadHours!: number;
/** Local (Africa/Addis_Ababa) hour at which the import window opens on its window day. */
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
windowOpenHour!: number;
@Column({
name: 'window_duration_hours',
type: 'numeric',
precision: 4,
scale: 2,
default: 3,
})
windowDurationHours!: number;
/** Max time staff have to accept booking documents after the window closes. */
@Column({ name: 'doc_review_minutes', type: 'int', default: 30 })
docReviewMinutes!: number;
@Column({ name: 'payment_window_minutes', type: 'int', default: 60 })
paymentWindowMinutes!: number;
/** Delay after window close before the window reopens when the train is not yet full. */
@Column({ name: 'reopen_delay_minutes', type: 'int', default: 90 })
reopenDelayMinutes!: number;
}

View File

@@ -43,6 +43,8 @@ import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-qu
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingWindowService } from "./booking-window.service";
import { BillingService } from "../billing/billing.service";
@ApiTags("train-scheduling")
@ApiBearerAuth()
@@ -51,8 +53,23 @@ export class TrainSchedulingController {
constructor(
private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService,
private readonly bookingWindowService: BookingWindowService,
private readonly billingService: BillingService,
) { }
@Get("my-booking-windows")
@ApiOperation({
summary:
"Upcoming/open booking windows on the signed-in customer's active contract lanes",
})
async getMyBookingWindows(@CurrentUser() user: AuthUserPayload) {
const companyId = await this.billingService.resolveCompanyId(
resolveAuthUserId(user),
);
if (!companyId) return [];
return this.trainSchedulingService.getBookingWindowsForCompany(companyId);
}
@Get("global-rules")
@TrainSchedulingView()
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
@@ -457,6 +474,17 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/doc-review-complete")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Staff finished document review early — run the batch/payment phase now (applies to the whole route-day group)",
})
async completeDocReview(@Param("id", ParseUUIDPipe) id: string) {
await this.bookingWindowService.completeDocReview(id);
return this.bookingBatchService.getBatchBoardDetail(id);
}
@Post("bookings/:bookingId/mark-paid")
@TrainSchedulingManage()
@ApiOperation({

View File

@@ -25,6 +25,9 @@ import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service';
import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service';
import { BookingWindowService } from './booking-window.service';
import { BookingSplitService } from './booking-split.service';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
import { NotificationsModule } from '../notifications/notifications.module';
import { ContractsModule } from '../contracts/contracts.module';
@@ -42,6 +45,7 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainSchedulingGlobalRules,
TrainCheckpointEvent,
ImportDjiboutiOperation,
BookingBatchOffer,
]),
forwardRef(() => BookingsModule),
BillingModule,
@@ -60,7 +64,9 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEventsRepository,
BookingBatchService,
BookingNotifierService,
BookingWindowService,
BookingSplitService,
],
exports: [TrainSchedulingService, BookingBatchService],
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
})
export class TrainSchedulingModule {}

View File

@@ -13,7 +13,7 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { DataSource, EntityManager, In, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
@@ -58,6 +58,7 @@ import {
UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { type BookingWindowConfig } from './booking-window.config';
import {
buildCappedWagonPlan,
computeFleetAvailability,
@@ -98,7 +99,11 @@ import {
DEFAULT_BULK_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
} from './booking-batch.constants';
import { eatDay } from './batch-window.util';
import {
computeExportWindowTimes,
computeImportWindowTimes,
eatDay,
} from './batch-window.util';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
@@ -236,9 +241,37 @@ export class TrainSchedulingService {
if (dto.max20ftPairWeightDiffTons != null) {
row.max20ftPairWeightDiffTons = dto.max20ftPairWeightDiffTons;
}
if (dto.importWindowLeadDays != null) row.importWindowLeadDays = dto.importWindowLeadDays;
if (dto.exportBookingLeadHours != null) row.exportBookingLeadHours = dto.exportBookingLeadHours;
if (dto.windowOpenHour != null) row.windowOpenHour = dto.windowOpenHour;
if (dto.windowDurationHours != null) row.windowDurationHours = dto.windowDurationHours;
if (dto.docReviewMinutes != null) row.docReviewMinutes = dto.docReviewMinutes;
if (dto.paymentWindowMinutes != null) row.paymentWindowMinutes = dto.paymentWindowMinutes;
if (dto.reopenDelayMinutes != null) row.reopenDelayMinutes = dto.reopenDelayMinutes;
return this.dataSource.getRepository(TrainSchedulingGlobalRules).save(row);
}
/**
* Booking-window timings with hardcoded fallbacks for a missing/legacy config row.
* Numeric columns come back from pg as strings — normalize every field.
*/
async getWindowConfig(): Promise<BookingWindowConfig> {
const row = await this.loadGlobalRulesRow();
const num = (v: unknown, fallback: number) => {
const n = v == null ? NaN : Number(v);
return Number.isFinite(n) ? n : fallback;
};
return {
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
windowOpenHour: num(row?.windowOpenHour, 8),
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
reopenDelayMinutes: num(row?.reopenDelayMinutes, 90),
};
}
async previewTrainSchedule(dto: PreviewTrainScheduleDto) {
const limits = await this.resolveTrainLimitConfig(dto);
return this.buildPreviewResponse(
@@ -310,8 +343,12 @@ export class TrainSchedulingService {
throw new BadRequestException('A train must be pulled by at least two locomotives');
}
const scheduleWarnings: string[] = [];
const createdScheduleId = await this.dataSource.transaction(async (manager) => {
// Lock and validate every locomotive: all must be AVAILABLE and at the origin yard.
// Lock every locomotive. Advance scheduling is allowed: a locomotive may sit on
// multiple future schedules and does not need to be at the origin yard yet — staff
// plan around its arrival. Only decommissioned locomotives are hard-blocked;
// everything else surfaces as a warning.
const lockedLocomotives: Locomotive[] = [];
for (const locomotiveId of locomotiveIds) {
const locked = await manager.getRepository(Locomotive).findOne({
@@ -321,12 +358,17 @@ export class TrainSchedulingService {
if (!locked) {
throw new NotFoundException(`Locomotive ${locomotiveId} not found`);
}
if (locked.status === 'OUT_OF_SERVICE') {
throw new ConflictException(`Locomotive ${locked.code} is out of service`);
}
if (locked.status !== 'AVAILABLE') {
throw new ConflictException(`Locomotive ${locked.code} is not available`);
scheduleWarnings.push(
`Locomotive ${locked.code} is currently ${locked.status}; it must be released before this train dispatches`,
);
}
if (locked.currentYardId !== route.originYardId) {
throw new ConflictException(
`Locomotive ${locked.code} is at yard ${locked.currentYardId} but schedule originates from ${route.originYardId}`,
scheduleWarnings.push(
`Locomotive ${locked.code} is not at the origin yard yet; it must arrive before this train dispatches`,
);
}
lockedLocomotives.push(locked);
@@ -340,27 +382,46 @@ export class TrainSchedulingService {
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set.
const limitLoco = minLocomotiveLimits(lockedLocomotives) ?? undefined;
const departure = new Date(dto.scheduleDate);
// IMPORT/EXPORT trains start with a CLOSED customer window; the window engine
// opens it on schedule (import: booking day at 08:00 EAT; export: 24h lead).
// DOMESTIC keeps the legacy always-OPEN behavior (windowPhase stays NULL).
const windowCfg = await this.getWindowConfig();
const windowFields =
direction === 'IMPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...computeImportWindowTimes(departure, windowCfg, new Date()),
}
: direction === 'EXPORT'
? {
bookingWindowStatus: 'CLOSED',
windowPhase: 'PRE_WINDOW',
...computeExportWindowTimes(departure, windowCfg),
}
: {};
const schedule = manager.getRepository(TrainSchedule).create({
trainSetId: trainSet.id,
routeId: route.id,
originStationId: route.originYardId,
destinationStationId: route.destinationYardId,
scheduledDepartureDate: new Date(dto.scheduleDate),
scheduledDepartureDate: departure,
status: TrainScheduleStatusEnum.Draft,
direction,
maxWagons: (
await this.resolveTrainLimitConfig(dto, limitLoco)
).maxWagonsPerTrain,
...windowFields,
});
const saved = await manager.getRepository(TrainSchedule).save(schedule);
await manager.getRepository(Locomotive).update(
{ id: In(lockedLocomotives.map((l) => l.id)) },
{ status: 'ASSIGNED' },
);
// Locomotives stay in their current status until dispatch — advance scheduling
// must not block the locomotive from serving earlier trains.
return saved.id;
});
return this.getTrainScheduleById(createdScheduleId);
const created = await this.getTrainScheduleById(createdScheduleId);
return { ...created, warnings: scheduleWarnings };
}
async assignBookingsToSchedule(
@@ -778,10 +839,19 @@ export class TrainSchedulingService {
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
}
await this.assertImportDjiboutiMayDepart(schedule);
// A locomotive may sit on many future schedules, but it can only pull one train
// at a time — block dispatch while any set locomotive is out on a dispatched train.
const setLocomotiveIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
await this.assertLocomotivesNotDispatchedElsewhere(setLocomotiveIds, scheduleId);
const now = new Date();
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
if (setLocomotiveIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(setLocomotiveIds) }, { status: 'ASSIGNED' });
}
await this.trainSchedulesRepository.updateStatus(
scheduleId,
@@ -1461,6 +1531,12 @@ export class TrainSchedulingService {
/** Open or close a schedule's booking window (staff override). */
async setBookingWindow(scheduleId: string, status: 'OPEN' | 'CLOSED'): Promise<void> {
if (status === 'OPEN') {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (schedule?.bookingWindowStatus === 'FULL') {
throw new ConflictException('Train is full — the booking window cannot be reopened');
}
}
await this.dataSource
.getRepository(TrainSchedule)
.update(scheduleId, { bookingWindowStatus: status });
@@ -1685,16 +1761,14 @@ export class TrainSchedulingService {
[scheduleId, 'IN_TRANSIT', SchedulingStatus.Dispatched],
);
if (schedule.trainSet?.locomotiveId) {
const loco = await manager
.getRepository(Locomotive)
.findOne({ where: { id: schedule.trainSet.locomotiveId } });
if (loco) {
await manager.getRepository(Locomotive).update(loco.id, {
status: 'AVAILABLE',
currentYardId: schedule.destinationStationId,
});
}
// Release every locomotive of the set (not just the legacy primary) and move it
// to the destination yard where it physically arrived.
const arrivedLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (arrivedLocoIds.length) {
await manager.getRepository(Locomotive).update(
{ id: In(arrivedLocoIds) },
{ status: 'AVAILABLE', currentYardId: schedule.destinationStationId },
);
}
for (const slot of schedule.trainSet?.wagons ?? []) {
@@ -1769,11 +1843,21 @@ export class TrainSchedulingService {
if (schedule.trainSetId) {
await manager.getRepository(TrainSet).update(schedule.trainSetId, { status: 'CANCELLED' });
}
// Locomotives are only ASSIGNED while out on a dispatched train. Release ours,
// but never stomp a locomotive that is currently pulling another dispatched train.
const cancelledLocoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (cancelledLocoIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(cancelledLocoIds) }, { status: 'AVAILABLE' });
const busyElsewhere = await this.findLocomotiveIdsDispatchedElsewhere(
cancelledLocoIds,
id,
manager,
);
const releasable = cancelledLocoIds.filter((locoId) => !busyElsewhere.has(locoId));
if (releasable.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(releasable), status: 'ASSIGNED' }, { status: 'AVAILABLE' });
}
}
for (const wagon of schedule.trainSet?.wagons ?? []) {
if (wagon.physicalWagonId) {
@@ -2016,15 +2100,17 @@ export class TrainSchedulingService {
}
if (assignedLocomotives.length) {
// Every locomotive of the set must sit at the origin yard, and the weakest
// one must still be able to pull the train (min limits across the set).
// Advance scheduling: a locomotive that hasn't reached the origin yard yet is a
// warning (it must arrive before dispatch), but a set too weak to pull the train
// is a hard violation.
const offYard = assignedLocomotives.find((l) => l.currentYardId !== originYardId);
const setLimits = minLocomotiveLimits(assignedLocomotives);
if (offYard) {
violations.push(
`Locomotive ${offYard.code} is not at the schedule origin yard`,
warnings.push(
`Locomotive ${offYard.code} is not at the schedule origin yard yet; it must arrive before dispatch`,
);
} else if (
}
if (
setLimits &&
(setLimits.maxPullWeightTons < totalWeightTons ||
setLimits.maxTrainLengthMeters < totalLengthMeters)
@@ -2034,21 +2120,22 @@ export class TrainSchedulingService {
);
}
} else {
const availableLocomotives = (
await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE' },
})
).filter((l) => l.currentYardId === originYardId);
if (!availableLocomotives.length) {
violations.push('No available locomotive at the schedule origin yard');
} else if (
!availableLocomotives.some(
const inServiceLocomotives = await this.locomotivesRepository.findAll({
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
});
if (!inServiceLocomotives.some((l) => l.currentYardId === originYardId)) {
warnings.push(
'No locomotive is at the schedule origin yard yet; one must arrive before dispatch',
);
}
if (
!inServiceLocomotives.some(
(l) =>
Number(l.maxPullWeightTons) >= totalWeightTons &&
Number(l.maxTrainLengthMeters) >= totalLengthMeters,
)
) {
violations.push('No available locomotive can support the total train weight and length');
violations.push('No locomotive can support the total train weight and length');
}
}
@@ -2596,6 +2683,58 @@ export class TrainSchedulingService {
return trainSet.locomotive ? [trainSet.locomotive] : [];
}
/**
* Locomotive ids (among the given ones) that are attached to a DISPATCHED train
* other than `excludeScheduleId`. Covers both the multi-loco link rows and the
* legacy single-locomotive column on the train set.
*/
private async findLocomotiveIdsDispatchedElsewhere(
locomotiveIds: string[],
excludeScheduleId: string,
manager?: EntityManager,
): Promise<Set<string>> {
if (!locomotiveIds.length) return new Set();
const runner = manager ?? this.dataSource;
const rows: { locomotive_id: string }[] = await runner.query(
`SELECT DISTINCT loco.locomotive_id
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN (
SELECT tsl.train_set_id, tsl.locomotive_id
FROM freight.train_set_locomotives tsl
WHERE tsl.deleted_at IS NULL
UNION
SELECT t.id AS train_set_id, t.locomotive_id
FROM freight.train_sets t
WHERE t.locomotive_id IS NOT NULL
) loco ON loco.train_set_id = tset.id
WHERE ts.status = 'DISPATCHED'
AND ts.deleted_at IS NULL
AND ts.id <> $1
AND loco.locomotive_id = ANY($2)`,
[excludeScheduleId, locomotiveIds],
);
return new Set(rows.map((r) => r.locomotive_id));
}
private async assertLocomotivesNotDispatchedElsewhere(
locomotiveIds: string[],
excludeScheduleId: string,
): Promise<void> {
const busy = await this.findLocomotiveIdsDispatchedElsewhere(
locomotiveIds,
excludeScheduleId,
);
if (!busy.size) return;
const locos = await this.dataSource
.getRepository(Locomotive)
.find({ where: { id: In([...busy]) } });
const codes = locos.map((l) => l.code).join(', ');
throw new ConflictException(
`Locomotive(s) ${codes} are currently out on another dispatched train`,
);
}
async selectOrValidateLocomotive(
locomotiveId: string,
totalWeightTons: number,
@@ -2732,15 +2871,113 @@ export class TrainSchedulingService {
}
/** AVAILABLE locomotives at the route's origin yard. */
async getAvailableLocomotivesForRoute(routeId: string): Promise<Locomotive[]> {
/**
* All in-service locomotives, annotated for the schedule-creation picker.
* Advance scheduling means nothing is filtered out — staff see status, whether the
* locomotive is at the origin yard yet, and how many future schedules it already has.
*/
async getAvailableLocomotivesForRoute(routeId: string) {
const route = await this.getSchedulableRoute(routeId);
const locomotives = await this.locomotivesRepository.findAll({
where: { status: 'AVAILABLE', currentYardId: route.originYardId },
where: { status: Not('OUT_OF_SERVICE' as Locomotive['status']) },
order: { code: 'ASC' },
});
return locomotives;
const counts: { locomotive_id: string; future_count: string }[] = locomotives.length
? await this.dataSource.query(
`SELECT loco.locomotive_id, COUNT(DISTINCT ts.id) AS future_count
FROM freight.train_schedules ts
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
JOIN (
SELECT tsl.train_set_id, tsl.locomotive_id
FROM freight.train_set_locomotives tsl
WHERE tsl.deleted_at IS NULL
UNION
SELECT t.id AS train_set_id, t.locomotive_id
FROM freight.train_sets t
WHERE t.locomotive_id IS NOT NULL
) loco ON loco.train_set_id = tset.id
WHERE ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.deleted_at IS NULL
AND loco.locomotive_id = ANY($1)
GROUP BY loco.locomotive_id`,
[locomotives.map((l) => l.id)],
)
: [];
const futureCounts = new Map(counts.map((c) => [c.locomotive_id, Number(c.future_count)]));
return locomotives.map((loco) => ({
...loco,
atOriginYard: loco.currentYardId === route.originYardId,
futureScheduleCount: futureCounts.get(loco.id) ?? 0,
}));
}
/**
* Upcoming/open booking windows for a customer's active-contract lanes —
* powers the portal home "booking windows" section. Only window-engine
* schedules (IMPORT cycle / EXPORT lead) are listed; DOMESTIC trains are
* always open and need no announcement.
*/
async getBookingWindowsForCompany(companyId: string) {
const rows: Array<{
schedule_id: string;
direction: string | null;
window_phase: string | null;
window_opens_at: Date | null;
window_closes_at: Date | null;
booking_window_status: string;
booking_cycle_no: number;
scheduled_departure_date: Date;
origin_label: string | null;
origin_code: string | null;
destination_label: string | null;
destination_code: string | null;
}> = await this.dataSource.query(
`SELECT DISTINCT ts.id AS schedule_id,
ts.direction,
ts.window_phase,
ts.window_opens_at,
ts.window_closes_at,
ts.booking_window_status,
ts.booking_cycle_no,
ts.scheduled_departure_date,
oy.label AS origin_label, oy.code AS origin_code,
dy.label AS destination_label, dy.code AS destination_code
FROM freight.train_schedules ts
JOIN freight.contract_routes cr
ON cr.origin_yard_id = ts.origin_station_id
AND cr.destination_yard_id = ts.destination_station_id
AND cr.deleted_at IS NULL
JOIN freight.contracts c
ON c.id = cr.contract_id
AND c.company_id = $1
AND c.status IN ('CONTRACT_ACTIVE', 'FULLY_EXECUTED')
AND c.deleted_at IS NULL
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
WHERE ts.deleted_at IS NULL
AND ts.status IN ('DRAFT', 'SCHEDULED')
AND ts.window_phase IS NOT NULL
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
AND ts.scheduled_departure_date >= now()
ORDER BY ts.window_opens_at ASC NULLS LAST`,
[companyId],
);
return rows.map((r) => ({
scheduleId: r.schedule_id,
direction: r.direction,
windowPhase: r.window_phase,
isOpenNow: r.window_phase === 'OPEN' && r.booking_window_status === 'OPEN',
windowOpensAt: r.window_opens_at,
windowClosesAt: r.window_closes_at,
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,
origin: r.origin_label ?? r.origin_code ?? null,
destination: r.destination_label ?? r.destination_code ?? null,
}));
}
/** OPEN schedules a new booking may target (with rough remaining capacity).

View File

@@ -1,5 +1,5 @@
import { IsString, IsEnum, IsNumber, IsOptional, IsUUID } from 'class-validator';
import { VehicleType, FuelType, VehicleStatus } from '../entities/vehicle.entity';
import { VehicleType, FuelType, VehicleStatus, VehicleAvailability } from '../entities/vehicle.entity';
export class CreateVehicleDto {
@IsString()
@@ -26,6 +26,10 @@ export class CreateVehicleDto {
@IsEnum(VehicleStatus)
status!: VehicleStatus;
@IsOptional()
@IsEnum(VehicleAvailability)
availability?: VehicleAvailability;
@IsOptional()
@IsString()
description?: string;
@@ -57,4 +61,8 @@ export class CreateVehicleDto {
@IsOptional()
@IsNumber()
actualDistanceKm?: number;
@IsOptional()
@IsUUID()
locationId?: string;
}

View File

@@ -25,11 +25,25 @@ export enum VehicleStatus {
OUT_OF_SERVICE = 'OUT_OF_SERVICE',
}
export enum VehicleAvailability {
FREE = 'FREE',
BUSY = 'BUSY',
}
@Entity({ name: 'vehicles', schema: 'freight' })
export class Vehicle extends BaseEntity {
@Column({ nullable: true })
code?: string;
@Column({ name: 'plate_number', unique: true, nullable: true })
plateNumber?: string;
@Column({ name: 'power_plate_no', nullable: true })
powerPlateNo?: string;
@Column({ name: 'trailer_plate_no', nullable: true })
trailerPlateNo?: string;
@Column({ name: 'registration_number', unique: true, nullable: true })
registrationNumber?: string;
@@ -54,6 +68,9 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'status', type: 'varchar', default: VehicleStatus.ACTIVE, nullable: true })
status?: VehicleStatus;
@Column({ name: 'availability', type: 'varchar', default: VehicleAvailability.FREE, nullable: true })
availability?: VehicleAvailability;
@Column({ type: 'text', nullable: true })
description?: string | null;
@@ -68,4 +85,7 @@ export class Vehicle extends BaseEntity {
@Column({ name: 'actual_distance_km', type: 'numeric', nullable: true })
actualDistanceKm?: number;
@Column({ name: 'location_id', type: 'uuid', nullable: true })
locationId?: string;
}

View File

@@ -34,6 +34,7 @@ export class VehiclesController {
findAll(
@Query('search') search?: string,
@Query('status') status?: string,
@Query('availability') availability?: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
@Query('sortBy') sortBy?: string,
@@ -42,6 +43,7 @@ export class VehiclesController {
return this.vehiclesService.findAll({
search,
status: status as any,
availability: availability as any,
page: page ? parseInt(page) : undefined,
limit: limit ? parseInt(limit) : undefined,
sortBy,

View File

@@ -1,9 +1,14 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Not, Repository } from 'typeorm';
import { CreateVehicleDto } from './dto/create-vehicle.dto';
import { UpdateVehicleDto } from './dto/update-vehicle.dto';
import { Vehicle, VehicleStatus } from './entities/vehicle.entity';
import { Vehicle, VehicleAvailability, VehicleStatus } from './entities/vehicle.entity';
import { FirstMile, FirstMileStatus } from '../first-mile/entities/first-mile.entity';
import { FirstMileContainerAllocation } from '../first-mile/entities/first-mile-container-allocation.entity';
import { LastMile, LastMileStatus } from '../last-mile/entities/last-mile.entity';
import { LastMileContainerAllocation } from '../last-mile/entities/last-mile-container-allocation.entity';
import { BookingContainerAllocation } from '../bookings/entities/booking-container-allocation.entity';
@Injectable()
export class VehiclesService {
@@ -35,6 +40,7 @@ export class VehiclesService {
async findAll(query: {
search?: string;
status?: VehicleStatus | string;
availability?: VehicleAvailability | string;
page?: number;
limit?: number;
sortBy?: string;
@@ -44,7 +50,7 @@ export class VehiclesService {
if (query.search) {
qb = qb.where(
'v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search',
'(v.plateNumber ILIKE :search OR v.manufacturer ILIKE :search OR v.model ILIKE :search OR v.code ILIKE :search OR v.trailerPlateNo ILIKE :search)',
{ search: `%${query.search}%` },
);
}
@@ -53,6 +59,10 @@ export class VehiclesService {
qb = qb.andWhere('v.status = :status', { status: query.status });
}
if (query.availability) {
qb = qb.andWhere('v.availability = :availability', { availability: query.availability });
}
const sortBy = ['plateNumber', 'status', 'year', 'createdAt'].includes(
query.sortBy ?? '',
)
@@ -91,6 +101,48 @@ export class VehiclesService {
return this.vehicleRepo.save(vehicle);
}
async setAvailability(id: string, availability: VehicleAvailability): Promise<void> {
await this.vehicleRepo.update(id, { availability });
}
/**
* Set vehicles back to FREE, but only when no active (non-completed)
* first/last-mile record or container allocation still references them.
* First-mile trips ending in RECEIVED_TO_PORT and last-mile trips ending
* in DELIVERED no longer hold the vehicle.
*/
async releaseIfUnused(vehicleIds: string[]): Promise<void> {
const manager = this.vehicleRepo.manager;
for (const vehicleId of [...new Set(vehicleIds)]) {
const [fmRecords, lmRecords, fmAllocations, lmAllocations, bookingAllocations] = await Promise.all([
manager.count(FirstMile, {
where: { vehicleId, status: Not<FirstMileStatus>('RECEIVED_TO_PORT') },
}),
manager.count(LastMile, {
where: { vehicleId, status: Not<LastMileStatus>('DELIVERED') },
}),
manager
.createQueryBuilder(FirstMileContainerAllocation, 'alloc')
.innerJoin(FirstMile, 'fm', 'fm.id = alloc.firstMileId')
.where('alloc.vehicleId = :vehicleId', { vehicleId })
.andWhere('fm.status != :done', { done: 'RECEIVED_TO_PORT' })
.andWhere('fm.deletedAt IS NULL')
.getCount(),
manager
.createQueryBuilder(LastMileContainerAllocation, 'alloc')
.innerJoin(LastMile, 'lm', 'lm.id = alloc.lastMileId')
.where('alloc.vehicleId = :vehicleId', { vehicleId })
.andWhere('lm.status != :done', { done: 'DELIVERED' })
.andWhere('lm.deletedAt IS NULL')
.getCount(),
manager.count(BookingContainerAllocation, { where: { vehicleId } }),
]);
if (fmRecords + lmRecords + fmAllocations + lmAllocations + bookingAllocations === 0) {
await this.setAvailability(vehicleId, VehicleAvailability.FREE);
}
}
}
async remove(id: string): Promise<void> {
await this.findById(id);
await this.vehicleRepo.softDelete(id);