Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/first_mile_invoice

This commit is contained in:
natib21
2026-07-06 12:06:22 +00:00
300 changed files with 17177 additions and 5298 deletions

View File

@@ -0,0 +1,22 @@
import { Controller, Get, Query } from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import { Public } from "@edr/api-common";
import { CheckAvailabilityService } from "./check-availability.service";
@ApiTags("auth")
@Controller("auth")
@Public()
export class CheckAvailabilityController {
constructor(
private readonly checkAvailabilityService: CheckAvailabilityService,
) {}
@Get("check-availability")
@ApiOperation({
summary: "Check whether an email and/or phone number is already registered",
})
check(@Query("email") email?: string, @Query("phone") phone?: string) {
return this.checkAvailabilityService.check({ email, phone });
}
}

View File

@@ -0,0 +1,47 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
export interface CheckAvailabilityQuery {
email?: string;
phone?: string;
}
export interface CheckAvailabilityResult {
emailTaken: boolean;
phoneTaken: boolean;
}
@Injectable()
export class CheckAvailabilityService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async check({
email,
phone,
}: CheckAvailabilityQuery): Promise<CheckAvailabilityResult> {
if (!email && !phone) {
throw new BadRequestException("email or phone is required");
}
const matches = await this.userRepository.find({
where: [
...(email ? [{ email }] : []),
...(phone ? [{ phoneNumber: phone }] : []),
],
select: { id: true, email: true, phoneNumber: true },
});
return {
emailTaken: email ? matches.some((user) => user.email === email) : false,
phoneTaken: phone
? matches.some((user) => user.phoneNumber === phone)
: false,
};
}
}

View File

@@ -1,10 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from '@tria-plc/iamapi-common/entities/iam/user/user.entity';
import { CheckAvailabilityController } from './check-availability.controller';
import { CheckAvailabilityService } from './check-availability.service';
import { FreightMeController } from './freight-me.controller';
import { FreightMeService } from './freight-me.service';
@Module({
controllers: [FreightMeController],
providers: [FreightMeService],
imports: [TypeOrmModule.forFeature([User])],
controllers: [FreightMeController, CheckAvailabilityController],
providers: [FreightMeService, CheckAvailabilityService],
})
export class FreightAuthModule {}

View File

@@ -957,12 +957,27 @@ export class BillingService {
returnUrl: opts.returnUrl,
failureUrl: opts.failureUrl,
});
//
// Link the intent to the invoice BEFORE any settlement can correlate against it.
await this.dataSource
.getRepository(Invoice)
.update({ id: invoice.id }, { paymentId: result.intentId });
// DEMO: manually fire the gateway `payment.succeeded` callback here, without
// waiting for real gateway settlement. Runs AFTER the paymentId link above so
// `handlePaymentEvent → settleByPaymentId` can correlate the invoice. TODO:
// remove — real settlement flips this via the `${source}.invoice.paid` handler.
if (!result.immediateSuccess) {
await this.payment.handlePaymentEvent({
eventType: "payment.succeeded",
eventId: `demo-${result.intentId}`,
referenceId: invoice.sourceId,
intentId: result.intentId,
providerTxnId: result.providerTxnId,
paidAt: (result.paidAt ?? new Date()).toISOString(),
});
}
if (result.immediateSuccess) {
await this.settleByPaymentId(
result.intentId,

View File

@@ -1086,6 +1086,9 @@ export class BookingTransitionService {
offeredAmount: number;
paymentDeadline: Date;
} | null;
/** Flat list of physical container numbers on this booking (for the
* customer truck-assignment container picker). */
containerNumbers: string[];
}
> {
// This enrichment runs AFTER the transition has committed. A failure here
@@ -1141,12 +1144,20 @@ export class BookingTransitionService {
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
);
}
// Physical container numbers entered at booking time (booking_container
// units), flattened for the customer truck-assignment container picker.
const containerNumbers = (booking.bookingContainers ?? [])
.flatMap((bc) => bc.units ?? [])
.map((unit) => unit.containerNumber)
.filter((n): n is string => Boolean(n));
return {
...booking,
latestChangeRequestNote: note?.note ?? null,
contractSummary: summary,
nextStep,
activeBatchOffer,
containerNumbers,
};
}
}

View File

@@ -2,6 +2,7 @@ import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
Param,
@@ -61,6 +62,11 @@ import {
} from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service';
import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service';
import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto';
import {
@@ -83,6 +89,8 @@ export class BookingsController {
private readonly transitionService: BookingTransitionService,
private readonly contractService: BookingContractService,
private readonly bookingClearanceService: BookingClearanceService,
private readonly customerTruckService: CustomerTruckService,
private readonly containerReceiptService: ContainerReceiptService,
) {}
@Post()
@@ -309,6 +317,94 @@ export class BookingsController {
res.send(buffer);
}
@Get(':id/customer-trucks')
@ApiOperation({ summary: 'List customer self-haul trucks (multi-truck) for a booking' })
async listCustomerTrucks(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.listTrucks(id);
}
@Post(':id/customer-trucks')
@ApiOperation({ summary: 'Add a customer self-haul truck carrying 12 of the booking containers' })
async addCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AddCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.addTruck(id, dto);
}
@Delete(':id/customer-trucks/:assignmentId')
@ApiOperation({ summary: 'Remove a not-yet-arrived customer truck from a booking' })
async removeCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
return this.customerTruckService.removeTruck(id, assignmentId);
}
@Post(':id/customer-trucks/:assignmentId/depart')
@ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',
})
async departCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Body() dto: DepartCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
// Weighing + registering the load on exit is a warehouse/gate staff action.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can register a truck departure');
}
return this.customerTruckService.departTruck(id, assignmentId, dto);
}
@Get(':id/received-pending-grn')
@ApiOperation({ summary: 'Containers received into port but not yet on a GRN' })
async receivedPendingGrn(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
) {
// GRN is a warehouse-staff action — no customer access.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
}
return this.containerReceiptService.listReceivedPendingGrn(id);
}
@Post(':id/generate-grn')
@ApiOperation({
summary:
'Generate a GRN over the received containers (all received, or a subset) — one GRN per batch',
})
async generateGrn(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: GenerateGrnDto,
@CurrentUser() user: TCurrentUser,
) {
// GRN is a warehouse-staff action — no customer access.
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can view or generate GRNs');
}
return this.containerReceiptService.generateGrn(id, dto.containerNumbers);
}
@Get(':id/tracking')
@ApiOperation({
summary: "Shipment tracking timeline for a booking",

View File

@@ -33,6 +33,11 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
import { BookingReviewNote } from './entities/booking-review-note.entity';
import { Booking } from './entities/booking.entity';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
import { CustomerTruckService } from './customer-truck.service';
import { ContainerReceiptService } from './container-receipt.service';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
import { ContractsModule } from '../contracts/contracts.module';
import { BookingContainerAllocation } from "./entities/booking-container-allocation.entity";
@@ -55,6 +60,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
BookingReviewNote,
BookingContractSignature,
BookingContainerAllocation,
CustomerTruckAssignment,
CustomerTruckContainer,
]),
BillingModule,
forwardRef(() => FirstMileModule),
@@ -91,12 +98,17 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
ContractPricingScheduleBuilder,
ContractRendererService,
ContractPdfService,
CustomerTruckAssignmentsRepository,
CustomerTruckService,
ContainerReceiptService,
],
exports: [
BookingsService,
BookingsRepository,
BookingPricingService,
BookingInvoiceService,
CustomerTruckService,
ContainerReceiptService,
],
})
export class BookingsModule { }

View File

@@ -5,6 +5,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, FindOptionsWhere, In, Repository, SelectQueryBuilder } from 'typeorm';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { Contract } from '../contracts/entities/contract.entity';
import { ContractRoute } from '../contracts/entities/contract-route.entity';
import { BookingApprovalStep } from './entities/booking-approval-step.entity';
import { BookingCargoModifier } from './entities/booking-cargo-modifier.entity';
@@ -61,16 +62,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
return this.repository.findOne({ where: { reference } });
}
/** Count bookings created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
/**
* Highest NNNNNN sequence already issued for `BK-<year>-…` references.
* Includes soft-deleted bookings so the next number clears references that
* still occupy the unique index. (A created-at count drifts below the issued
* sequence after any delete and then collides forever.)
*/
async maxReferenceSequence(year: number): Promise<number> {
const row = await this.repository
.createQueryBuilder('booking')
.where('booking.created_at >= :startDate', { startDate })
.andWhere('booking.created_at < :endDate', { endDate })
.getCount();
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(booking.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('booking.reference LIKE :prefix', { prefix: `BK-${year}-%` })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
/** Find a booking by reference with files and relations. */
@@ -588,8 +596,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
// Contract reference for the list column + search (no entity relation on
// Booking → contract, so join by id and select just the reference).
.leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id')
// Booking → contract, so join the entity by id and select just the
// reference — a schema-qualified table string is parsed as alias.relation
// by TypeORM and crashes).
.leftJoin(Contract, 'contract', 'contract.id = booking.contract_id')
.addSelect('contract.reference', 'contract_reference')
.where('booking.deleted_at IS NULL');
@@ -1079,8 +1089,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
destinationYard: true,
// units carry the real per-container numbers entered at booking time —
// the wagon plan shows those instead of generated placeholders.
bookingContainers: { containerType: true, units: true },
cargoType: true,
// containerType.wagonType + cargoType.wagonType drive wagon-type
// resolution during scheduling (FK, not the old load-type string map).
bookingContainers: { containerType: { wagonType: true }, units: true },
cargoType: { wagonType: true },
},
order: { priorityScore: 'DESC', createdAt: 'ASC' },
});

View File

@@ -9,6 +9,7 @@ import {
NotFoundException,
} from '@nestjs/common';
import { Freight, SchedulingStatus } from '@edr/types';
import { insertWithGeneratedReference } from '@edr/api-common';
// import { CustomersService } from '../customers/customers.service';
import { CompaniesService } from '../companies/companies.service';
import { ProfileType } from '../companies/entities/company-profile.entity';
@@ -145,7 +146,28 @@ export class BookingsService {
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
}
const html = this.buildCustomerTruckFreightOrderHtml(booking);
const trucks: Array<{
plateNumber: string;
driverName: string;
truckType: string;
arrivedAt: string | null;
containers: string | null;
}> = await this.dataSource.query(
`SELECT a.plate_number AS "plateNumber",
a.driver_name AS "driverName",
a.truck_type AS "truckType",
a.arrived_at AS "arrivedAt",
string_agg(c.container_number, ', ' ORDER BY c.container_number) AS "containers"
FROM freight.customer_truck_assignments a
LEFT JOIN freight.customer_truck_containers c
ON c.assignment_id = a.id AND c.deleted_at IS NULL
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, a.arrived_at, a.assigned_at
ORDER BY a.assigned_at`,
[bookingId],
);
const html = this.buildCustomerTruckFreightOrderHtml(booking, trucks);
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
return {
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
@@ -186,41 +208,89 @@ export class BookingsService {
/** Generate a unique booking reference number. */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
const seq = await this.bookingsRepository.maxReferenceSequence(year);
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
}
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
private buildCustomerTruckFreightOrderHtml(
booking: Booking,
trucks: Array<{
plateNumber: string;
driverName: string;
truckType: string;
arrivedAt: string | null;
containers: string | null;
}>,
): string {
const assignedAt = booking.customerTruckAssignedAt
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
: '-';
const rows: Array<[string, string | null | undefined]> = [
const bookingRows: Array<[string, string | null | undefined]> = [
['Booking Reference', booking.reference],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['Freight Type', booking.freightType],
['Truck Plate Number', booking.customerTruckPlateNumber],
['Driver Name', booking.customerTruckDriverName],
['Truck Type', booking.customerTruckType],
['Container Number to Load', booking.customerTruckContainerNumber],
['Assigned At', assignedAt],
['Booking Status', booking.status],
];
const rowHtml = rows
const bookingRowHtml = bookingRows
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.join('');
// Fall back to the legacy single-truck booking columns when there are no
// multi-truck rows (bookings assigned before the multi-truck feature).
const truckList =
trucks.length > 0
? trucks
: booking.customerTruckPlateNumber
? [
{
plateNumber: booking.customerTruckPlateNumber,
driverName: booking.customerTruckDriverName ?? '',
truckType: booking.customerTruckType ?? '',
arrivedAt: booking.customerTruckArrivedAt
? String(booking.customerTruckArrivedAt)
: null,
containers: booking.customerTruckContainerNumber ?? null,
},
]
: [];
const truckBlocks = truckList
.map((t, i) => {
const rows: Array<[string, string | null | undefined]> = [
['Truck Plate Number', t.plateNumber],
['Driver Name', t.driverName],
['Truck Type', t.truckType],
['Containers Loaded', t.containers],
[
'Arrival',
t.arrivedAt ? new Date(t.arrivedAt).toLocaleString('en-GB') : 'Awaiting arrival',
],
];
const html = rows
.map(
([label, value]) =>
`<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`,
)
.join('');
return `<div class="truck"><h2>Truck ${i + 1}</h2><table>${html}</table></div>`;
})
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div>
<h1>Freight Order</h1>
<p>Customer external truck assignment</p>
<p>Customer external truck assignment${truckList.length} truck${truckList.length !== 1 ? 's' : ''}</p>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${rowHtml}</table>
<table>${bookingRowHtml}</table>
${truckBlocks}
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
@@ -238,11 +308,13 @@ export class BookingsService {
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
h2 { margin: 18px 0 8px; font-size: 14px; color: #0a6f4d; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; margin-bottom: 6px; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.truck { page-break-inside: avoid; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
</style>
@@ -522,7 +594,6 @@ export class BookingsService {
}
}
const reference = dto.reference || (await this.generateReference());
const containers = dto.containers ?? [];
assertFreightShape({
freightType: dto.freightType,
@@ -617,7 +688,10 @@ export class BookingsService {
// the customer clears it themselves and may name their broker.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const booking = await this.bookingsRepository.create({
// Explicit reference is caller-chosen (a collision is a real conflict);
// auto-generated references retry past a concurrent same-sequence insert.
const insertBooking = (reference: string) =>
this.bookingsRepository.create({
reference,
companyId,
companyProfileId,
@@ -672,7 +746,14 @@ export class BookingsService {
priorityScore: ruleResult.priorityScore,
totalAmount: 0,
paymentStatus: 'PENDING',
});
});
const booking = dto.reference
? await insertBooking(dto.reference)
: await insertWithGeneratedReference(
() => this.generateReference(),
insertBooking,
);
if (dto.freightType === 'CONTAINER') {
await this.bookingsRepository.createContainers(

View File

@@ -42,8 +42,13 @@ describe('clearance.util — clearanceOutputSettingCode', () => {
expect(clearanceOutputSettingCode('IMPORT', 'CONTAINER', false)).toBeNull();
});
it('returns null for bulk (no container output set) and domestic', () => {
expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBeNull();
it('resolves bulk output sets (mirrors container) and returns null for domestic', () => {
expect(clearanceOutputSettingCode('IMPORT', 'BULK', true)).toBe(
'clearance_output_import_bulk',
);
expect(clearanceOutputSettingCode('EXPORT', 'BULK', true)).toBe(
'clearance_output_export_bulk',
);
expect(clearanceOutputSettingCode('DOMESTIC', 'CONTAINER', true)).toBeNull();
});
});

View File

@@ -33,7 +33,7 @@ export function clearanceSettingCode(
return `clearance_${op}_${freight}_${customs}`;
}
/** The GL-output (customs output) setting code; only container customs sets exist. */
/** The GL-output (customs output) setting code, keyed on op + freight. */
export function clearanceOutputSettingCode(
tradeDirection: string,
freightType: string,
@@ -42,9 +42,8 @@ export function clearanceOutputSettingCode(
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
// Only container customs output sets are seeded for this phase.
if (freightFor(freightType) !== 'container') return null;
return `clearance_output_${op}_container`;
const freight = freightFor(freightType);
return `clearance_output_${op}_${freight}`;
}
/** Convenience: resolve both codes for a loaded booking (with its serviceType). */

View File

@@ -0,0 +1,145 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
export interface ReceivedUnitRow {
id: string;
containerNumber: string;
receivedToPort: boolean;
receivedAt: string | null;
grnNumber: string | null;
}
/**
* Per-container receive + GRN tracking on booking_container_units.
*
* Containers arrive individually (on separate self-haul trucks), so each unit is
* flipped `received_to_port` when its truck arrives (auto). Staff then confirm a
* Goods Received Note over the received-but-un-GRN'd containers: one GRN covers a
* batch, so if the whole booking arrives together every unit shares a single GRN
* (per-booking GRN); if trucks arrive separately each batch gets its own GRN.
*/
@Injectable()
export class ContainerReceiptService {
constructor(private readonly dataSource: DataSource) {}
/**
* Auto-mark the containers loaded on an arrived truck as received into the
* port. Idempotent — only flips units not already received. Runs inside the
* caller's transaction when a manager is supplied.
*/
async markReceivedForAssignment(
bookingId: string,
assignmentId: string,
manager?: EntityManager,
): Promise<void> {
const m = manager ?? this.dataSource.manager;
await m.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc,
freight.customer_truck_containers ctc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND ctc.assignment_id = $2
AND ctc.deleted_at IS NULL
AND ctc.container_number = bcu.container_number
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = false`,
[bookingId, assignmentId],
);
}
/** Received-into-port containers that have not yet been assigned a GRN. */
async listReceivedPendingGrn(bookingId: string): Promise<ReceivedUnitRow[]> {
return this.dataSource.query(
`SELECT bcu.id,
bcu.container_number AS "containerNumber",
bcu.received_to_port AS "receivedToPort",
bcu.received_at AS "receivedAt",
bcu.grn_number AS "grnNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = true
AND bcu.grn_number IS NULL
ORDER BY bcu.received_at`,
[bookingId],
);
}
/**
* Confirm a GRN over the currently received-but-un-GRN'd containers (optionally
* a subset by container number). Assigns one GRN number to the whole batch and
* returns it with the covered containers. If the batch covers every container
* on the booking it is effectively a per-booking GRN.
*/
async generateGrn(
bookingId: string,
containerNumbers?: string[],
): Promise<{ grnNumber: string; containerNumbers: string[]; perBooking: boolean }> {
const [booking] = await this.dataSource.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
return this.dataSource.transaction(async (manager) => {
const wanted = containerNumbers?.map((n) => n.trim().toUpperCase());
const pending: ReceivedUnitRow[] = await manager.query(
`SELECT bcu.id, bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = true
AND bcu.grn_number IS NULL
${wanted ? 'AND bcu.container_number = ANY($2::varchar[])' : ''}`,
wanted ? [bookingId, wanted] : [bookingId],
);
if (!pending.length) {
throw new BadRequestException('No received containers are awaiting a GRN');
}
// Batch sequence = number of GRNs already issued for this booking + 1.
const [{ batches }]: Array<{ batches: string }> = await manager.query(
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
[bookingId],
);
const seq = Number(batches) + 1;
const grnNumber = `GRN-${String(booking.reference).replace(/^BK-?/i, '')}-${String(seq).padStart(2, '0')}`;
const ids = pending.map((p) => p.id);
await manager.query(
`UPDATE freight.booking_container_units
SET grn_number = $1, updated_at = NOW()
WHERE id = ANY($2::uuid[])`,
[grnNumber, ids],
);
// Per-booking when no container on the booking is left un-GRN'd.
const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
`SELECT COUNT(*) AS remaining
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
[bookingId],
);
return {
grnNumber,
containerNumbers: pending.map((p) => p.containerNumber),
perBooking: Number(remaining) === 0 && seq === 1,
};
});
}
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
@Injectable()
export class CustomerTruckAssignmentsRepository extends BaseRepository<CustomerTruckAssignment> {
constructor(
@InjectRepository(CustomerTruckAssignment)
private readonly repo: Repository<CustomerTruckAssignment>,
) {
super(repo);
}
/** All trucks assigned to a booking, oldest first, with their containers. */
findByBookingId(bookingId: string): Promise<CustomerTruckAssignment[]> {
return this.repo.find({
where: { bookingId },
relations: { containers: true },
order: { assignedAt: 'ASC' },
});
}
findByIdWithContainers(id: string): Promise<CustomerTruckAssignment | null> {
return this.repo.findOne({ where: { id }, relations: { containers: true } });
}
}

View File

@@ -0,0 +1,321 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
interface BookingGuardRow {
tradeDirection: string | null;
firstMile: string | null;
lastMile: string | null;
paymentStatus: string | null;
status: string | null;
}
/**
* Multi-truck self-haul assignment. A booking with no EDR first/last-mile leg
* can have several customer trucks, each carrying 12 of its containers and
* tracking its own arrival. The legacy booking.customer_truck_* columns are kept
* as a booking-level flag (any truck assigned / all arrived) so the warehouse
* exit-gate + delivery-approval logic keep working unchanged.
*/
@Injectable()
export class CustomerTruckService {
constructor(
private readonly dataSource: DataSource,
private readonly assignments: CustomerTruckAssignmentsRepository,
) {}
listTrucks(bookingId: string): Promise<CustomerTruckAssignment[]> {
return this.assignments.findByBookingId(bookingId);
}
async addTruck(bookingId: string, dto: AddCustomerTruckDto): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
this.assertSelfHaulPaid(booking);
const isExport = booking.tradeDirection === 'EXPORT';
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
// EXPORT: the truck delivers 12 known containers. IMPORT: containers are
// not pre-specified — they are registered + weighed when the truck leaves.
if (isExport) {
if (requested.length < 1 || requested.length > 2) {
throw new BadRequestException('An export truck must carry 1 or 2 of the booking containers');
}
} else if (requested.length > 2) {
throw new BadRequestException('A truck carries at most 2 containers');
}
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const alreadyAssigned = await this.assignedContainerNumbers(bookingId);
for (const n of requested) {
if (alreadyAssigned.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
}
await this.dataSource.transaction(async (manager) => {
const assignment = await manager.getRepository(CustomerTruckAssignment).save(
manager.getRepository(CustomerTruckAssignment).create({
bookingId,
plateNumber: dto.truckPlateNumber.trim().toUpperCase(),
driverName: dto.driverName.trim(),
truckType: dto.truckType.trim(),
}),
);
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId: assignment.id,
bookingId,
containerNumber,
}),
),
);
// Booking-level flag: first truck marks the booking as truck-assigned.
await manager.query(
`UPDATE freight.bookings
SET customer_truck_assigned_at = COALESCE(customer_truck_assigned_at, NOW()),
status = CASE WHEN status = 'PAID' THEN 'TRUCK_ASSIGNED' ELSE status END,
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
});
return this.listTrucks(bookingId);
}
async removeTruck(bookingId: string, assignmentId: string): Promise<CustomerTruckAssignment[]> {
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.arrivedAt) {
throw new ConflictException('Cannot remove a truck that has already arrived');
}
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckAssignment).softDelete(assignmentId);
const remaining = await manager
.getRepository(CustomerTruckAssignment)
.count({ where: { bookingId } });
if (remaining === 0) {
// No trucks left — clear the booking-level flag and revert the status.
await manager.query(
`UPDATE freight.bookings
SET customer_truck_assigned_at = NULL,
status = CASE WHEN status = 'TRUCK_ASSIGNED' THEN 'PAID' ELSE status END,
updated_at = NOW()
WHERE id = $1`,
[bookingId],
);
}
});
return this.listTrucks(bookingId);
}
/**
* Register an IMPORT self-haul truck leaving the port: the containers it
* actually loaded (replacing any provisional list) and its weighed gross.
* Export bookings have no truck departure — trucks only deliver (receive).
*/
async departTruck(
bookingId: string,
assignmentId: string,
dto: DepartCustomerTruckDto,
): Promise<CustomerTruckAssignment[]> {
const booking = await this.loadBookingGuard(bookingId);
if (booking.tradeDirection !== 'IMPORT') {
throw new BadRequestException(
'Truck departure/weighing applies to import self-haul only (export trucks only deliver)',
);
}
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
// Once filled, the departure record is uneditable.
if (assignment.departedAt) {
throw new ConflictException('This truck has already departed — its exit record is locked');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (requested.length) {
const bookingNumbers = await this.bookingContainerNumbers(bookingId);
for (const n of requested) {
if (!bookingNumbers.includes(n)) {
throw new BadRequestException(`Container ${n} is not one of this booking's containers`);
}
}
const elsewhere = await this.assignedContainerNumbersExcept(bookingId, assignmentId);
for (const n of requested) {
if (elsewhere.includes(n)) {
throw new ConflictException(`Container ${n} is already loaded onto another truck`);
}
}
}
await this.dataSource.transaction(async (manager) => {
if (requested.length) {
// Replace the truck's containers with what was actually loaded.
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
}
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: dto.grossWeightKg,
departedAt: dto.gateOutTime ? new Date(dto.gateOutTime) : new Date(),
arrivedAt: assignment.arrivedAt ?? new Date(),
});
});
return this.listTrucks(bookingId);
}
/**
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
* receive flow. When every truck on the booking has arrived, the booking-level
* customer_truck_arrived_at flag is stamped (used by the delivery-approval
* gate). No-op when the container is not on any customer truck.
*/
async markArrivedByContainer(
bookingId: string,
containerNumber: string,
manager?: EntityManager,
): Promise<void> {
const m = manager ?? this.dataSource.manager;
const cn = containerNumber.trim().toUpperCase();
const container = await m.getRepository(CustomerTruckContainer).findOne({
where: { bookingId, containerNumber: cn },
});
if (!container) return;
await m
.getRepository(CustomerTruckAssignment)
.update({ id: container.assignmentId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
}
/** Mark every truck on the booking arrived (fallback when no container is known). */
async markAllArrived(bookingId: string, manager?: EntityManager): Promise<void> {
const m = manager ?? this.dataSource.manager;
await m
.getRepository(CustomerTruckAssignment)
.update({ bookingId, arrivedAt: IsNull() }, { arrivedAt: new Date() });
await this.syncBookingArrival(bookingId, m);
}
/**
* Stamp the booking-level arrival flag on the FIRST truck arrival. The import
* handover is signed once, before the first truck leaves, even though trucks
* pick up per-container — so the flag fires on the first arrival (COALESCE
* keeps it), not once all trucks have arrived.
*/
private async syncBookingArrival(bookingId: string, m: EntityManager): Promise<void> {
await m.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL`,
[bookingId],
);
}
private async loadBookingGuard(bookingId: string): Promise<BookingGuardRow> {
const [row]: BookingGuardRow[] = await this.dataSource.query(
`SELECT trade_direction AS "tradeDirection",
first_mile_pickup_address AS "firstMile",
last_mile_delivery_address AS "lastMile",
payment_status AS "paymentStatus",
status
FROM freight.bookings
WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if (!row) throw new NotFoundException(`Booking ${bookingId} not found`);
return row;
}
private assertSelfHaulPaid(booking: BookingGuardRow): void {
const hasFirstMile = Boolean(booking.firstMile?.trim());
const hasLastMile = Boolean(booking.lastMile?.trim());
const usesMileService =
booking.tradeDirection === 'IMPORT'
? hasLastMile
: booking.tradeDirection === 'EXPORT'
? hasFirstMile
: hasFirstMile || hasLastMile;
if (usesMileService) {
throw new BadRequestException(
'Customer truck assignment is only allowed when first/last mile delivery is not selected',
);
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException(
'Booking must be paid before assigning an external customer truck',
);
}
}
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
private async assignedContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber"
FROM freight.customer_truck_containers
WHERE booking_id = $1 AND deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
private async assignedContainerNumbersExcept(
bookingId: string,
exceptAssignmentId: string,
): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT container_number AS "containerNumber"
FROM freight.customer_truck_containers
WHERE booking_id = $1 AND assignment_id <> $2 AND deleted_at IS NULL`,
[bookingId, exceptAssignmentId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
}

View File

@@ -0,0 +1,47 @@
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsIn,
IsNotEmpty,
IsOptional,
IsString,
Matches,
MaxLength,
} from 'class-validator';
import { CUSTOMER_TRUCK_TYPES } from './customer-truck-assignment.dto';
/**
* Add one external customer truck to a booking.
* - EXPORT: the truck delivers 12 known containers (required, validated in the
* service against the booking's containers).
* - IMPORT: the customer does not pre-specify — containers are registered and
* weighed when the truck leaves, so `containerNumbers` may be omitted/empty.
*/
export class AddCustomerTruckDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(120)
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
}

View File

@@ -0,0 +1,37 @@
import {
ArrayMaxSize,
ArrayUnique,
IsArray,
IsDateString,
IsNumber,
IsOptional,
Matches,
Min,
} from 'class-validator';
/**
* Register an import self-haul truck leaving the port: the containers it actually
* loaded (staff read them off the truck) and the weighed gross. Container numbers
* are optional here only because they may already have been recorded; the weighed
* gross is required.
*/
export class DepartCustomerTruckDto {
@IsOptional()
@IsArray()
@ArrayMaxSize(2)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
@IsNumber()
@Min(0)
grossWeightKg!: number;
/** Gate-out time. Defaults to now when omitted. */
@IsOptional()
@IsDateString()
gateOutTime?: string;
}

View File

@@ -0,0 +1,17 @@
import { ArrayUnique, IsArray, IsOptional, Matches } from 'class-validator';
/**
* Confirm a Goods Received Note. Omit `containerNumbers` to GRN every
* received-but-un-GRN'd container on the booking (per-booking when that's all of
* them); pass a subset to GRN just those.
*/
export class GenerateGrnDto {
@IsOptional()
@IsArray()
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers?: string[];
}

View File

@@ -34,4 +34,17 @@ export class BookingContainerUnit extends BaseEntity {
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
sortOrder!: number;
/** Whether this container has been received into the port (auto-set when its
* self-haul truck arrives). */
@Column({ name: 'received_to_port', type: 'boolean', default: false })
receivedToPort!: boolean;
@Column({ name: 'received_at', type: 'timestamptz', nullable: true })
receivedAt?: Date | null;
/** The GRN this container was received under (assigned when staff confirm the
* Goods Received Note for a batch of received containers). */
@Column({ name: 'grn_number', type: 'varchar', length: 100, nullable: true })
grnNumber?: string | null;
}

View File

@@ -0,0 +1,47 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Booking } from './booking.entity';
import { CustomerTruckContainer } from './customer-truck-container.entity';
/**
* One external (self-haul) truck a customer assigns to a booking that has no
* EDR first/last-mile leg. Each truck carries 12 containers and tracks its own
* arrival at the terminal/warehouse.
*/
@Entity({ schema: 'freight', name: 'customer_truck_assignments' })
@Index(['bookingId'])
export class CustomerTruckAssignment extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'plate_number', type: 'varchar', length: 32 })
plateNumber!: string;
@Column({ name: 'driver_name', type: 'varchar', length: 120 })
driverName!: string;
@Column({ name: 'truck_type', type: 'varchar', length: 60 })
truckType!: string;
@Column({ name: 'assigned_at', type: 'timestamptz', default: () => 'now()' })
assignedAt!: Date;
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
arrivedAt?: Date | null;
/** Weighed gross of what the truck actually loaded (import), captured on
* leaving. Null until the truck departs. */
@Column({ name: 'gross_weight_kg', type: 'numeric', precision: 14, scale: 2, nullable: true })
grossWeightKg?: number | null;
@Column({ name: 'departed_at', type: 'timestamptz', nullable: true })
departedAt?: Date | null;
@OneToMany(() => CustomerTruckContainer, (c) => c.assignment, { cascade: true })
containers?: CustomerTruckContainer[];
}

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { CustomerTruckAssignment } from './customer-truck-assignment.entity';
/**
* A container number loaded onto a customer truck. A container may be loaded
* onto exactly one truck per booking (enforced by a partial unique index on
* booking_id + container_number).
*/
@Entity({ schema: 'freight', name: 'customer_truck_containers' })
@Index(['assignmentId'])
export class CustomerTruckContainer extends BaseEntity {
@Column({ name: 'assignment_id', type: 'uuid' })
assignmentId!: string;
@ManyToOne(() => CustomerTruckAssignment, (a) => a.containers, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'assignment_id' })
assignment?: CustomerTruckAssignment;
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@Column({ name: 'container_number', type: 'varchar', length: 64 })
containerNumber!: string;
}

View File

@@ -33,6 +33,11 @@ import { ETradeService } from "./services/etrade.service";
CompanyDashboardRepository,
ETradeService,
],
exports: [CompaniesService],
exports: [
CompaniesService,
// Consumed by NotificationInboxModule for portal recipient targeting.
ExternalProfileRepository,
CompanyProfileRepository,
],
})
export class CompaniesModule { }

View File

@@ -334,9 +334,28 @@ export class CompaniesService {
const company = await this.companiesRepo.findById(id);
if (!company) throw new NotFoundException(`Company ${id} not found`);
company.companyProfiles = await this.companyProfilesRepo.findByCompanyId(id);
for (const profile of company.companyProfiles) {
profile.businessLicenseFiles = await this.signLicenseFiles(
profile.businessLicenseFiles,
);
}
return company;
}
/**
* Business-license files are stored as raw, unsigned MinIO URLs (see
* `BusinessLicenseFile` on `CompanyProfile`) — a browser can't fetch them
* directly. Sign each one with a short-lived URL before it reaches a response.
*/
private async signLicenseFiles(
files?: BusinessLicenseFile[] | null,
): Promise<BusinessLicenseFile[]> {
if (!files?.length) return [];
return Promise.all(
files.map(async (f) => ({ ...f, url: await this.filesService.signUrl(f.url) })),
);
}
/**
* Validate an explicitly-chosen company profile for a booking: it must belong
* to the booking's company and be Active. Used for government bookings (staff
@@ -1183,9 +1202,11 @@ export class CompaniesService {
const { businessInfo } = await this.etradeService.resolveCompanyData(tin);
if (!businessInfo) {
throw new BadRequestException(
"No business license found for this TIN. Please check the number and try again.",
"We couldn't find a business license for this TIN with eTrade. Please double-check the number and try again.",
);
}
return this.etradeService.extractRegistrationData(businessInfo);
const registrationData = this.etradeService.extractRegistrationData(businessInfo);
const tinTaken = await this.companiesRepo.existsByTin(tin);
return { ...registrationData, tinTaken };
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, Matches, IsEmail } from 'class-validator';
import { IsString, IsNotEmpty, IsOptional, IsEnum, MaxLength, Length, IsEmail } from 'class-validator';
import { CompanyType, CompanyStatus } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -17,10 +17,7 @@ export class CreateCompanyDto {
@IsString()
@IsNotEmpty()
@Length(10, 10)
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
tin!: string;
@IsOptional()

View File

@@ -17,6 +17,7 @@ export class ETradeResponseDto implements CompanyRegistrationData {
managerName!: string;
managerEmail?: string;
managerPhone!: string;
tinTaken?: boolean;
constructor(data: CompanyRegistrationData) {
this.licenceNumber = data.licenceNumber;
@@ -35,5 +36,6 @@ export class ETradeResponseDto implements CompanyRegistrationData {
this.managerName = data.managerName;
this.managerEmail = data.managerEmail;
this.managerPhone = data.managerPhone;
this.tinTaken = data.tinTaken;
}
}

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEmail, MaxLength, Length, Matches, IsEnum } from 'class-validator';
import { IsString, IsOptional, IsEmail, MaxLength, Length, IsEnum } from 'class-validator';
import { CompanyNationality } from '../entities/company.entity';
import { IsValidPhone } from '../../../common/validators/is-phone-number.validator';
@@ -34,10 +34,7 @@ export class UpdateProfileDto {
@IsOptional()
@IsString()
@Length(10, 10)
@Matches(/^00\d{8}$/, {
message: 'TIN must be 10 digits starting with 00',
})
@Length(10, 10, { message: 'TIN must be exactly 10 digits' })
tin?: string;
@IsOptional()

View File

@@ -205,7 +205,7 @@ export class BookingClearanceService {
const finalInvoice = await this.glOperationsService.finalInvoiceSummary(bookingId);
const bookingMilestone = (code: string) =>
milestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const gatepass = await this.glOperationsService.gatepassForBooking(bookingId);
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(milestones, files);
@@ -242,14 +242,8 @@ export class BookingClearanceService {
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
gatepassGranted: gatepass.granted,
gatepassAt: gatepass.grantedAt,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt

View File

@@ -48,8 +48,22 @@ export class BookingRequestRepository extends BaseRepository<BookingRequest> {
});
}
/** Total rows — used to mint the next sequential reference. */
async count(): Promise<number> {
return this.repository.count();
/**
* Highest NNNNNN sequence already issued for `SR-…` references (all-time —
* these are not year-scoped). Includes soft-deleted rows so a cancel/delete
* can't make the next number reuse an earlier one. A plain row count drifts
* below the issued sequence after any delete and hands out duplicates.
*/
async maxReferenceSequence(): Promise<number> {
const row = await this.repository
.createQueryBuilder('request')
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(request.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('request.reference LIKE :prefix', { prefix: 'SR-%' })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
}

View File

@@ -192,8 +192,7 @@ export class BookingRequestService {
}
private async generateReference(): Promise<string> {
const count = await this.repo.count();
const seq = String(count + 1).padStart(6, '0');
return `SR-${seq}`;
const seq = await this.repo.maxReferenceSequence();
return `SR-${String(seq + 1).padStart(6, '0')}`;
}
}

View File

@@ -8,6 +8,7 @@ import {
forwardRef,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
@@ -110,7 +111,6 @@ export class ContractBookingService {
const route = await this.resolveRoute(contract, dto.contractRouteId);
const warnings: string[] = [];
const reference = await this.generateReference();
const freightType = contract.freightType;
// GENERAL + customs (Path B) runs per-booking clearance: the booking starts
@@ -138,10 +138,19 @@ export class ContractBookingService {
// no override. Checked before any row is written.
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
// 20ft weight-pairing gate at CREATION: two 20ft on a wagon must differ
// ≤ the cap, and drawdown bookings never pass through submit — so this is
// their only chance to hard-block an unbalanceable set. Entry order is
// irrelevant (the check sorts by weight before pairing).
await this.assert20ftPairableAtCreate(dto);
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
const booking = await this.bookingsRepository.create({
// Retry past a concurrent insert that grabbed the same BK sequence number.
const booking = await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.bookingsRepository.create({
reference,
companyId: contract.companyId ?? null,
companyProfileId: contract.companyProfileId ?? null,
@@ -175,7 +184,8 @@ export class ContractBookingService {
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
lastMileDeliveryLat: contract.lastMileDeliveryLat ?? null,
lastMileDeliveryLng: contract.lastMileDeliveryLng ?? null,
} as never);
} as never),
);
// Persist container lines + per-unit container numbers (container freight only).
if (freightType === 'CONTAINER') {
@@ -761,6 +771,35 @@ export class ContractBookingService {
}
}
/**
* Hard-block booking creation when the 20ft container weights cannot be
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {
const twentyFtUnits = (dto.containers ?? [])
.filter((line) => (line.containerSize ?? '').includes('20'))
.flatMap((line, lineIdx) =>
(line.units ?? []).map((u, idx) => ({
label: u.containerNumber || `20ft-${lineIdx + 1}.${idx + 1}`,
grossWeightTons: Number(u.vgmTons ?? 0),
})),
);
if (twentyFtUnits.length < 2) return;
const maxDiff = await this.max20ftPairDiffTons();
const violations = validate20ftWeightPairing(twentyFtUnits, maxDiff);
if (violations.length) {
throw new BadRequestException(
`Cannot create booking — 20ft containers cannot be paired on wagons: ${violations
.map((v) => v.message)
.join(' ')}`,
);
}
}
private async max20ftPairDiffTons(): Promise<number> {
const row = await this.dataSource
.getRepository(TrainSchedulingGlobalRules)
@@ -791,8 +830,7 @@ export class ContractBookingService {
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.bookingsRepository.countByYear(year);
const seq = String(count + 1).padStart(6, '0');
return `BK-${year}-${seq}`;
const seq = await this.bookingsRepository.maxReferenceSequence(year);
return `BK-${year}-${String(seq + 1).padStart(6, '0')}`;
}
}

View File

@@ -278,7 +278,9 @@ export class ContractClearanceService {
}
const bookingMilestone = (code: string) =>
bookingMilestones.find((m) => m.milestoneCode === code);
const gatepassMilestone = bookingMilestone('GATEPASS_GRANTED');
const gatepass = cycle?.bookingId
? await this.glOperationsService.gatepassForBooking(cycle.bookingId)
: { granted: false, grantedAt: null };
const t1ClosedMilestone = bookingMilestone('T1_CLOSED');
const riskMilestone = bookingMilestone('RISK_ASSIGNED');
const secondDuty = this.glOperationsService.secondDutyState(
@@ -344,14 +346,8 @@ export class ContractClearanceService {
workflowFiles,
t1,
train,
gatepassGranted: gatepassMilestone?.status === 'COMPLETED',
gatepassAt:
gatepassMilestone?.status === 'COMPLETED'
? (gatepassMilestone.metadata?.gatepassAt ??
(gatepassMilestone.triggeredAt
? gatepassMilestone.triggeredAt.toISOString()
: null))
: null,
gatepassGranted: gatepass.granted,
gatepassAt: gatepass.grantedAt,
t1Closed: t1ClosedMilestone?.status === 'COMPLETED',
t1ClosedAt:
t1ClosedMilestone?.status === 'COMPLETED' && t1ClosedMilestone.triggeredAt

View File

@@ -45,7 +45,7 @@ export function contractClearanceSettingCode(
return `contract_clearance_${op}_${freight}`;
}
/** The GL-output (customs output) setting code; only container customs sets exist. */
/** The GL-output (customs output) setting code, keyed on op + freight. */
export function contractClearanceOutputSettingCode(
tradeDirection: string,
freightType: string,
@@ -54,8 +54,8 @@ export function contractClearanceOutputSettingCode(
if (!includesCustoms) return null;
const op = operationFor(tradeDirection);
if (!op) return null;
if (freightFor(freightType) !== 'container') return null;
return `contract_clearance_output_${op}_container`;
const freight = freightFor(freightType);
return `contract_clearance_output_${op}_${freight}`;
}
/** Convenience: resolve both codes for a loaded contract. */

View File

@@ -5,6 +5,7 @@ import {
Logger,
} from '@nestjs/common';
import { Readable } from 'stream';
import { insertWithGeneratedReference } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { ContractDocumentViewModelBuilder } from '../../contracts/contract-document-view-model.builder';
@@ -611,8 +612,11 @@ export class ContractTransitionService {
async renew(contractId: string, userId?: string): Promise<Contract> {
const source = await this.contractsService.findById(contractId);
const reference = await this.generateRenewalReference();
const renewal = await this.contractsRepository.create({
// Retry past a concurrent insert that grabbed the same CTR sequence number.
const renewal = await insertWithGeneratedReference(
() => this.generateRenewalReference(),
(reference) =>
this.contractsRepository.create({
reference,
companyId: source.companyId,
companyProfileId: source.companyProfileId,
@@ -640,7 +644,8 @@ export class ContractTransitionService {
status: 'RENEWAL_DRAFT',
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
} as never),
);
void userId;
return this.contractsService.findById(renewal.id);
@@ -648,7 +653,7 @@ export class ContractTransitionService {
private async generateRenewalReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
const seq = await this.contractsRepository.maxReferenceSequence(year);
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
}

View File

@@ -77,7 +77,6 @@ import {
} from './dto/gl-operations.dto';
import {
AdviseContractDutyDto,
GatepassDto,
RoAmendmentDto,
} from './dto/phased-clearance.dto';
@@ -688,30 +687,6 @@ export class ContractsController {
return this.clearanceService.djQueue(filter);
}
@Get('clearance/dj-schedules')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'Train schedules carrying customs bookings — GL DJ gate-pass table' })
djClearanceSchedules() {
return this.glOperationsService.djSchedules();
}
@Post('clearance/schedules/:scheduleId/gatepass')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({
summary: 'GL DJ grants the gate pass for every customs booking on a train schedule',
})
grantScheduleGatepass(
@Param('scheduleId', ParseUUIDPipe) scheduleId: string,
@Body() dto: GatepassDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.grantScheduleGatepass(
scheduleId,
dto?.gatepassAt,
resolveAuthUserId(user),
);
}
// ── Path A self-clearance — Operations reviews the customer's own docs ───────
@Get('clearance/ops-queue')
@@ -947,21 +922,6 @@ export class ContractsController {
return this.glOperationsService.closeT1(bookingId, resolveAuthUserId(user));
}
@Post('bookings/:bookingId/gatepass')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@ApiOperation({ summary: 'GL DJ grants the gate pass for a customs booking (captures time)' })
grantGatepass(
@Param('bookingId', ParseUUIDPipe) bookingId: string,
@Body() dto: GatepassDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.glOperationsService.grantGatepass(
bookingId,
dto?.gatepassAt,
resolveAuthUserId(user),
);
}
@Post('bookings/:bookingId/final-invoice')
@BookingStaff(FREIGHT_PERMS.contracts.clearanceDjActions)
@UseInterceptors(FileInterceptor('file'))

View File

@@ -45,16 +45,23 @@ export class ContractsRepository extends BaseRepository<Contract> {
return this.repository.findOne({ where: { reference } });
}
/** Count contracts created in a specific year. */
async countByYear(year: number): Promise<number> {
const startDate = new Date(year, 0, 1);
const endDate = new Date(year + 1, 0, 1);
return this.repository
/**
* Highest NNNNN sequence already issued for `CTR-<year>-…` references.
* Includes soft-deleted contracts — their references still occupy the unique
* index, so the next number must move past them. (A created-at count drifts
* below the issued sequence after any delete and then collides forever.)
*/
async maxReferenceSequence(year: number): Promise<number> {
const row = await this.repository
.createQueryBuilder('contract')
.where('contract.created_at >= :startDate', { startDate })
.andWhere('contract.created_at < :endDate', { endDate })
.getCount();
.withDeleted()
.select(
"COALESCE(MAX(CAST(SUBSTRING(contract.reference FROM '[0-9]+$') AS int)), 0)",
'max',
)
.where('contract.reference LIKE :prefix', { prefix: `CTR-${year}-%` })
.getRawOne<{ max: string | number | null }>();
return Number(row?.max ?? 0);
}
/** Find a contract by ID with all child collections, service type, company and files. */

View File

@@ -6,6 +6,7 @@ import {
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common';
import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
@@ -57,8 +58,8 @@ export class ContractsService {
/** Generate a unique contract reference number (CTR-YYYY-NNNNN). */
private async generateReference(): Promise<string> {
const year = new Date().getFullYear();
const count = await this.contractsRepository.countByYear(year);
return `CTR-${year}-${String(count + 1).padStart(5, '0')}`;
const seq = await this.contractsRepository.maxReferenceSequence(year);
return `CTR-${year}-${String(seq + 1).padStart(5, '0')}`;
}
/** Whether a service type bundles customs clearance. */
@@ -144,8 +145,6 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes);
const reference = dto.reference || (await this.generateReference());
// Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null;
if (!isGovernment && companyId) {
@@ -177,7 +176,61 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
const contract = await this.contractsRepository.create({
// An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a
// concurrent insert that grabbed the same sequence number.
const contract = dto.reference
? await this.insertContract(dto.reference, {
companyId,
companyProfileId,
isGovernment,
includesCustoms,
dto,
})
: await insertWithGeneratedReference(
() => this.generateReference(),
(reference) =>
this.insertContract(reference, {
companyId,
companyProfileId,
isGovernment,
includesCustoms,
dto,
}),
);
await this.persistRoutes(contract.id, dto.routes);
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
if (files.length > 0) {
try {
await this.filesService.uploadMany(contract.id, 'contracts', files);
} catch {
warnings.push('File upload failed — contract was created without attached files.');
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/** Insert one DRAFT contract row with the given reference (no children). */
private insertContract(
reference: string,
ctx: {
companyId: string | null | undefined;
companyProfileId: string | null;
isGovernment: boolean;
includesCustoms: boolean;
dto: CreateContractDto;
},
): Promise<Contract> {
const { companyId, companyProfileId, isGovernment, includesCustoms, dto } = ctx;
return this.contractsRepository.create({
reference,
companyId: companyId ?? null,
companyProfileId,
@@ -205,24 +258,6 @@ export class ContractsService {
clearanceStatus: 'NOT_APPLICABLE',
clearanceCycleNumber: 0,
} as never);
await this.persistRoutes(contract.id, dto.routes);
await this.persistCargoScope(contract.id, dto.cargoScope, contract.contractKind);
if (files.length > 0) {
try {
await this.filesService.uploadMany(contract.id, 'contracts', files);
} catch {
warnings.push('File upload failed — contract was created without attached files.');
}
}
// Attach the company profile's onboarding / business-license documents to the
// contract by reference. The separate "Documents" intake step was removed —
// the profile documents are simply carried onto every contract automatically.
await this.attachProfileDocuments(contract.id, companyProfileId);
return { contract: await this.findById(contract.id), warnings };
}
/**

View File

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

View File

@@ -4,7 +4,7 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, In, IsNull } from 'typeorm';
import { DataSource, IsNull } from 'typeorm';
import { Freight, GL_FINAL_INVOICE_TYPE, isT1TransportFileCode } from '@edr/types';
import { BillingService } from '../billing/billing.service';
@@ -17,7 +17,6 @@ import {
ClearanceIncident,
IncidentType,
} from './entities/clearance-incident.entity';
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
import { ContractClearanceCycle } from './entities/contract-clearance-cycle.entity';
import { ClearanceMilestoneService } from './clearance-milestone.service';
import {
@@ -198,6 +197,7 @@ export class GlOperationsService {
}
return {
scheduleId: schedule?.id ?? null,
wagonAllocated,
departedAt: schedule?.actualDepartureAt
? new Date(schedule.actualDepartureAt).toISOString()
@@ -209,9 +209,45 @@ export class GlOperationsService {
}
/**
* 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.
* Gate pass status for a booking, sourced from the train schedule's Djibouti
* gate-pass operation (secured via the train-scheduling "Save as Secured"
* action) rather than a clearance milestone. For EXPORT bookings this also
* backfills the arrival-chain milestones once secured, same as the retired
* clearance-side grant action used to.
*/
async gatepassForBooking(
bookingId: string,
): Promise<{ granted: boolean; grantedAt: string | null }> {
const train = await this.trainState(bookingId);
if (!train.scheduleId) return { granted: false, grantedAt: null };
const operation = await this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: train.scheduleId } });
const grantedAt = operation?.gatepassGrantedAt
? new Date(operation.gatepassGrantedAt).toISOString()
: null;
if (grantedAt) {
const booking = await this.getBooking(bookingId);
if ((booking.tradeDirection ?? 'IMPORT') === 'EXPORT') {
const milestones = await this.milestoneService.listForBooking(bookingId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
if (byCode.get(code)?.status === 'PENDING') {
await this.milestoneService.completeForBooking(bookingId, code);
}
}
}
}
return { granted: Boolean(grantedAt), grantedAt };
}
/**
* T1 transit-document lifecycle state for an import shipment booking. The
* gate pass (secured on the train schedule after wagon allocation) opens the
* upload window; train departure locks it; train arrival lets GL Ethiopia
* close (accept) the T1 set.
*/
async t1State(bookingId: string): Promise<Freight.ClearanceT1State> {
const train = await this.trainState(bookingId);
@@ -234,7 +270,8 @@ export class GlOperationsService {
}
/**
* GL Djibouti uploads T1 transport documents (multi-file) after wagon allocation.
* GL Djibouti uploads T1 transport documents (multi-file) once the gate pass
* is secured on the train schedule (which itself follows wagon allocation).
* Replaces the previous batch; locked once the train departs or T1 is closed.
*/
async uploadT1Documents(
@@ -252,6 +289,12 @@ export class GlOperationsService {
'Wagons must be allocated before T1 transport documents can be uploaded.',
);
}
const gatepass = await this.gatepassForBooking(bookingId);
if (!gatepass.granted) {
throw new BadRequestException(
'Secure the Djibouti gate pass on the train schedule before uploading T1 transport documents.',
);
}
if (state.closed) {
throw new BadRequestException('T1 has been closed by GL Ethiopia — documents are final.');
}
@@ -268,7 +311,8 @@ export class GlOperationsService {
/**
* 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).
* Export: GL Djibouti closes once the train arrives at Djibouti (transport
* document required).
*/
async closeT1(
bookingId: string,
@@ -302,8 +346,10 @@ export class GlOperationsService {
'The transport document must be uploaded before T1 can be closed.',
);
}
if (!done('GATEPASS_GRANTED')) {
throw new BadRequestException('Grant the gate pass before closing T1.');
if (!state.trainArrivedAt) {
throw new BadRequestException(
'The train has not arrived at Djibouti yet — T1 can be closed only after arrival.',
);
}
// Export bookings seeded before T1_CLOSED joined the catalog lack the row.
await this.milestoneService.ensureForBooking(bookingId, 'T1_CLOSED', tradeDirection);
@@ -322,182 +368,6 @@ export class GlOperationsService {
'ARRIVED_AT_DJIBOUTI',
];
/**
* GL Djibouti grants the gate pass for a customs booking, capturing the time.
* Export: requires the train to have arrived at Djibouti; back-fills the
* arrival-chain milestones. Import: requires wagon allocation (pre-loading).
*/
async grantGatepass(
bookingId: string,
gatepassAt?: string,
userId?: string,
): Promise<{ bookingId: string; gatepassAt: string }> {
const booking = await this.getBooking(bookingId);
if (!booking.customsClearingEnabled) {
throw new BadRequestException('Gate pass applies to customs bookings only.');
}
const tradeDirection = booking.tradeDirection ?? 'IMPORT';
const milestones = await this.milestoneService.listForBooking(bookingId);
const byCode = new Map(milestones.map((m) => [m.milestoneCode, m]));
const existing = byCode.get('GATEPASS_GRANTED');
if (existing?.status === 'COMPLETED') {
return {
bookingId,
gatepassAt:
existing.metadata?.gatepassAt ??
(existing.triggeredAt ? new Date(existing.triggeredAt).toISOString() : ''),
};
}
const train = await this.trainState(bookingId);
if (tradeDirection === 'EXPORT') {
if (!train.arrivedAt) {
throw new BadRequestException(
'The train has not arrived at Djibouti yet — gate pass can be granted after arrival.',
);
}
for (const code of GlOperationsService.EXPORT_ARRIVAL_CHAIN) {
if (byCode.get(code)?.status === 'PENDING') {
await this.milestoneService.completeForBooking(bookingId, code, userId);
}
}
} else if (!train.wagonAllocated) {
throw new BadRequestException(
'Wagons must be allocated before the gate pass can be granted.',
);
}
const at = gatepassAt?.trim() || new Date().toISOString();
await this.milestoneService.completeWithMetadataForBooking(
bookingId,
'GATEPASS_GRANTED',
{ gatepassAt: at },
userId,
);
return { bookingId, gatepassAt: at };
}
/** Train schedules carrying ≥1 customs booking — the GL Djibouti gate-pass table. */
async djSchedules(): Promise<Freight.DjClearanceSchedule[]> {
const schedules = await this.dataSource.getRepository(TrainSchedule).find({
relations: {
scheduleBookings: { booking: true },
originStation: true,
destinationStation: true,
},
order: { scheduledDepartureDate: 'DESC' },
});
const withCustoms = schedules
.filter((s) => s.status !== 'CANCELLED')
.map((s) => ({
schedule: s,
customs: (s.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled)),
}))
.filter((s) => s.customs.length > 0);
const bookingIds = withCustoms.flatMap((s) => s.customs.map((b) => b.id));
const gatepassRows = bookingIds.length
? await this.dataSource.getRepository(ClearanceMilestone).find({
where: { bookingId: In(bookingIds), milestoneCode: 'GATEPASS_GRANTED' },
})
: [];
const gatepassByBooking = new Map(gatepassRows.map((m) => [m.bookingId, m]));
return withCustoms.map(({ schedule, customs }) => {
const freightTypes = [...new Set(customs.map((b) => b.freightType).filter(Boolean))];
return {
id: schedule.id,
trainNumber: schedule.trainNumber ?? null,
routeName: null,
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
destination:
schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
status: schedule.status,
scheduledDepartureDate: schedule.scheduledDepartureDate
? new Date(schedule.scheduledDepartureDate).toISOString()
: null,
actualDepartureAt: schedule.actualDepartureAt
? new Date(schedule.actualDepartureAt).toISOString()
: null,
actualArrivalAt: schedule.actualArrivalAt
? new Date(schedule.actualArrivalAt).toISOString()
: null,
freightType:
freightTypes.length === 1 ? (freightTypes[0] as string) : freightTypes.length ? 'MIXED' : null,
customsBookings: customs.map((b) => {
const m = gatepassByBooking.get(b.id);
const granted = m?.status === 'COMPLETED';
return {
bookingId: b.id,
reference: b.reference ?? b.id,
tradeDirection: b.tradeDirection ?? 'IMPORT',
contractId: b.contractId ?? null,
gatepassGranted: granted,
gatepassAt: granted
? (m?.metadata?.gatepassAt ??
(m?.triggeredAt ? new Date(m.triggeredAt).toISOString() : null))
: null,
};
}),
};
});
}
/**
* One-click gate pass for every customs booking on a train schedule. Per-booking
* guard failures are collected, not fatal. Import schedules also get the
* schedule-level ImportDjiboutiOperation gate pass so loading unblocks.
*/
async grantScheduleGatepass(
scheduleId: string,
gatepassAt?: string,
userId?: string,
): Promise<{ granted: number; skipped: Array<{ bookingId: string; error: string }> }> {
const schedule = await this.dataSource.getRepository(TrainSchedule).findOne({
where: { id: scheduleId },
relations: { scheduleBookings: { booking: true } },
});
if (!schedule) throw new NotFoundException(`Train schedule ${scheduleId} not found`);
const customs = (schedule.scheduleBookings ?? [])
.map((sb) => sb.booking)
.filter((b): b is Booking => Boolean(b?.customsClearingEnabled));
if (customs.length === 0) {
throw new BadRequestException('No customs bookings ride this schedule.');
}
let granted = 0;
const skipped: Array<{ bookingId: string; error: string }> = [];
for (const booking of customs) {
try {
await this.grantGatepass(booking.id, gatepassAt, userId);
granted += 1;
} catch (e) {
skipped.push({
bookingId: booking.id,
error: e instanceof Error ? e.message : 'Failed',
});
}
}
if (granted > 0 && customs.some((b) => (b.tradeDirection ?? 'IMPORT') === 'IMPORT')) {
const opRepo = this.dataSource.getRepository(ImportDjiboutiOperation);
let operation = await opRepo.findOne({ where: { trainScheduleId: scheduleId } });
if (!operation) {
operation = opRepo.create({ trainScheduleId: scheduleId });
}
if (!operation.gatepassGrantedAt) {
operation.gatepassGrantedAt = gatepassAt ? new Date(gatepassAt) : new Date();
await opRepo.save(operation);
}
}
return { granted, skipped };
}
/**
* GL Djibouti raises the post-offload final invoice (export): manual amount +

View File

@@ -7,4 +7,9 @@ export const minioConfig = registerAs("minio", () => ({
accessKey: process.env.MINIO_ACCESS_KEY || "",
secretKey: process.env.MINIO_SECRET_KEY || "",
bucket: process.env.MINIO_BUCKET || "fhc",
// Preset the region so presignedGetObject signs URLs locally. Without it the
// minio client fires a live GetBucketLocation request to the endpoint on every
// sign — which blocks (no timeout) when MinIO is slow/unreachable and hangs
// API responses that reload a booking's files (e.g. staff accept).
region: process.env.MINIO_REGION || "us-east-1",
}));

View File

@@ -29,6 +29,9 @@ export class MinioService {
useSSL: config.useSSL,
accessKey: config.accessKey,
secretKey: config.secretKey,
// Presetting the region keeps presignedGetObject fully local — no live
// GetBucketLocation round-trip to the endpoint on each signed URL.
region: config.region,
});
}
@@ -108,8 +111,11 @@ export class MinioService {
try {
return await this.client.presignedGetObject(this.bucket, objectName, expirySeconds);
} catch (error) {
// Signing a file URL must never break a booking/transition response — the
// caller only needs SOMETHING to link to. Degrade to the public object URL
// and log, rather than throwing (which would 500 an otherwise-good load).
this.logger.error(`Failed to generate signed URL for ${objectName}:`, error);
throw error;
return this.getPublicUrl(objectName);
}
}
}

View File

@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
import { IsBoolean, IsInt, IsOptional, Max, Min } from "class-validator";
export class ListNotificationsQueryDto {
@ApiPropertyOptional({
description: "Filter by read state. Omit to return all.",
})
@IsOptional()
@Transform(({ value }) =>
value === "true" ? true : value === "false" ? false : value,
)
@IsBoolean()
isRead?: boolean;
@ApiPropertyOptional({ minimum: 1, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page?: number;
@ApiPropertyOptional({ minimum: 1, maximum: 100, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number;
}

View File

@@ -0,0 +1,64 @@
import { BaseEntity } from "@edr/api-common";
import {
NotificationAudience,
NotificationChannelsSent,
NotificationPriority,
NotificationType,
} from "@edr/types";
import { Column, Entity, Index } from "typeorm";
/**
* A single persisted in-app notification addressed to one IAM user. Producers
* fan a logical notification out to N recipients by inserting one row per
* resolved user id (see NotificationInboxService.notify).
*/
@Entity({ schema: "freight", name: "notifications" })
@Index("IDX_NOTIFICATIONS_RECIPIENT_UNREAD", ["recipientUserId", "isRead"])
@Index("IDX_NOTIFICATIONS_RECIPIENT_CREATED", ["recipientUserId", "createdAt"])
export class Notification extends BaseEntity {
@Column({ name: "recipient_user_id", type: "uuid" })
recipientUserId!: string;
@Column({ name: "audience", type: "varchar", length: 20 })
audience!: NotificationAudience;
@Column({
name: "type",
type: "varchar",
length: 48,
default: NotificationType.GENERIC,
})
type!: NotificationType;
@Column({ name: "title", type: "varchar", length: 200 })
title!: string;
@Column({ name: "body", type: "text" })
body!: string;
/** Deep-link path within the app the item points to (e.g. `/contracts/:id`). */
@Column({ name: "link", type: "varchar", nullable: true })
link?: string | null;
/** Arbitrary structured payload (bookingId, invoiceId, contractId, …). */
@Column({ name: "data", type: "jsonb", nullable: true })
data?: Record<string, unknown> | null;
@Column({
name: "priority",
type: "varchar",
length: 12,
default: NotificationPriority.NORMAL,
})
priority!: NotificationPriority;
@Column({ name: "is_read", type: "boolean", default: false })
isRead!: boolean;
@Column({ name: "read_at", type: "timestamptz", nullable: true })
readAt?: Date | null;
/** Per-channel fan-out outcome for HIGH-priority items (email/SMS). */
@Column({ name: "channels_sent", type: "jsonb", nullable: true })
channelsSent?: NotificationChannelsSent | null;
}

View File

@@ -0,0 +1,79 @@
import { CurrentUser } from "@edr/api-common";
import {
NotificationAudience,
NotificationPriority,
NotificationType,
} from "@edr/types";
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
} from "@nestjs/common";
import { ApiOperation, ApiTags } from "@nestjs/swagger";
import {
AuthUserPayload,
resolveAuthUserId,
} from "../../common/resolve-auth-user-id";
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
import { NotificationInboxService } from "./notification-inbox.service";
@ApiTags("notifications")
@Controller("notifications")
export class NotificationInboxController {
constructor(private readonly service: NotificationInboxService) {}
@Get()
@ApiOperation({ summary: "List my notifications (paginated, newest first)" })
list(
@CurrentUser() user: AuthUserPayload,
@Query() query: ListNotificationsQueryDto,
) {
return this.service.list(resolveAuthUserId(user), query);
}
@Get("unread-count")
@ApiOperation({ summary: "Count my unread notifications" })
unreadCount(@CurrentUser() user: AuthUserPayload) {
return this.service.unreadCount(resolveAuthUserId(user));
}
@Patch(":id/read")
@ApiOperation({ summary: "Mark one of my notifications as read" })
markRead(
@CurrentUser() user: AuthUserPayload,
@Param("id", ParseUUIDPipe) id: string,
) {
return this.service.markRead(id, resolveAuthUserId(user));
}
@Post("read-all")
@ApiOperation({ summary: "Mark all my notifications as read" })
markAllRead(@CurrentUser() user: AuthUserPayload) {
return this.service.markAllRead(resolveAuthUserId(user));
}
// TODO: remove before merge — dev/verification helper only.
@Post("test")
@ApiOperation({
summary: "[dev] Send a test notification to the current user",
})
sendTest(
@CurrentUser() user: AuthUserPayload,
@Body()
body: {
audience?: NotificationAudience;
type?: NotificationType;
priority?: NotificationPriority;
title?: string;
message?: string;
},
) {
return this.service.sendTestToUser(resolveAuthUserId(user), body ?? {});
}
}

View File

@@ -0,0 +1,37 @@
import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { BackofficeModule } from "../backoffice/backoffice.module";
import { CompaniesModule } from "../companies/companies.module";
import { NotificationsModule } from "../notifications/notifications.module";
import { Notification } from "./entities/notification.entity";
import { NotificationInboxController } from "./notification-inbox.controller";
import { NotificationInboxRepository } from "./notification-inbox.repository";
import { NotificationInboxService } from "./notification-inbox.service";
import { NotificationRecipientsService } from "./notification-recipients.service";
import { NotificationsGateway } from "./notifications.gateway";
import { WsAuthService } from "./ws-auth.service";
@Module({
imports: [
TypeOrmModule.forFeature([Notification, User, Session]),
// ExternalProfileRepository + CompanyProfileRepository (portal targeting)
CompaniesModule,
// BackofficeService.getOrganizationEmployees (staff targeting)
BackofficeModule,
// EmailClientService + SmsClientService (HIGH-priority fan-out)
NotificationsModule,
],
controllers: [NotificationInboxController],
providers: [
NotificationInboxRepository,
NotificationRecipientsService,
NotificationsGateway,
WsAuthService,
NotificationInboxService,
],
exports: [NotificationInboxService],
})
export class NotificationInboxModule {}

View File

@@ -0,0 +1,59 @@
import { BaseRepository } from "@edr/api-common";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { FindOptionsWhere, Repository } from "typeorm";
import { Notification } from "./entities/notification.entity";
@Injectable()
export class NotificationInboxRepository extends BaseRepository<Notification> {
constructor(
@InjectRepository(Notification)
repo: Repository<Notification>,
) {
super(repo);
}
/** Newest-first page of a recipient's notifications, optionally read-filtered. */
async findForRecipient(
userId: string,
opts: { page?: number; limit?: number; isRead?: boolean } = {},
): Promise<[Notification[], number]> {
const page = opts.page && opts.page > 0 ? opts.page : 1;
const limit = opts.limit && opts.limit > 0 ? opts.limit : 20;
const where: FindOptionsWhere<Notification> = { recipientUserId: userId };
if (typeof opts.isRead === "boolean") {
where.isRead = opts.isRead;
}
return this.repository.findAndCount({
where,
order: { createdAt: "DESC" },
skip: (page - 1) * limit,
take: limit,
});
}
async countUnread(userId: string): Promise<number> {
return this.repository.count({
where: { recipientUserId: userId, isRead: false },
});
}
/** Mark a single notification read (scoped to its recipient). Returns true if it changed. */
async markRead(id: string, userId: string): Promise<boolean> {
const result = await this.repository.update(
{ id, recipientUserId: userId, isRead: false },
{ isRead: true, readAt: new Date() },
);
return (result.affected ?? 0) > 0;
}
/** Mark all of a recipient's unread notifications read. Returns the count updated. */
async markAllRead(userId: string): Promise<number> {
const result = await this.repository.update(
{ recipientUserId: userId, isRead: false },
{ isRead: true, readAt: new Date() },
);
return result.affected ?? 0;
}
}

View File

@@ -0,0 +1,239 @@
import {
NotificationAudience,
NotificationChannels,
NotificationChannelsSent,
NotificationDto,
NotificationListResult,
NotificationPriority,
NotificationType,
NotifyInput,
} from "@edr/types";
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { User } from "@tria-plc/iamapi-common/entities/iam/user/user.entity";
import { Repository } from "typeorm";
import { EmailClientService } from "../notifications/email-client.service";
import { SmsClientService } from "../notifications/sms-client.service";
import { ListNotificationsQueryDto } from "./dto/list-notifications-query.dto";
import { Notification } from "./entities/notification.entity";
import { NotificationInboxRepository } from "./notification-inbox.repository";
import { NotificationRecipientsService } from "./notification-recipients.service";
import { NotificationsGateway } from "./notifications.gateway";
/**
* The single entry point subsystems use for in-app notifications. Call
* {@link notify}; everything else (reads, mark-read) backs the REST controller.
*
* `notify` is deliberately fault-tolerant: it never throws into the caller so a
* notification failure can't roll back or break the business transaction that
* triggered it. Failures are logged.
*/
@Injectable()
export class NotificationInboxService {
private readonly logger = new Logger(NotificationInboxService.name);
constructor(
private readonly repo: NotificationInboxRepository,
private readonly recipients: NotificationRecipientsService,
private readonly gateway: NotificationsGateway,
private readonly emailClient: EmailClientService,
private readonly smsClient: SmsClientService,
@InjectRepository(User)
private readonly users: Repository<User>,
) {}
/**
* Fan a logical notification out to every resolved recipient: persist one row
* each, push it live over WebSocket, and (for HIGH priority) also queue
* email/SMS via the existing clients.
*/
async notify(input: NotifyInput): Promise<void> {
try {
const userIds = await this.recipients.resolve(input.recipients);
if (userIds.length === 0) {
this.logger.debug(
`notify(${input.type}) resolved 0 recipients — skipped`,
);
return;
}
const priority = input.priority ?? NotificationPriority.NORMAL;
for (const userId of userIds) {
await this.deliverToUser(userId, input, priority);
}
} catch (err) {
this.logger.error(
`notify failed: ${(err as Error).message}`,
(err as Error).stack,
);
}
}
async list(
userId: string,
query: ListNotificationsQueryDto,
): Promise<NotificationListResult> {
const [items, count] = await this.repo.findForRecipient(userId, {
page: query.page,
limit: query.limit,
isRead: query.isRead,
});
const unreadCount = await this.repo.countUnread(userId);
return { items: items.map((n) => this.toDto(n)), count, unreadCount };
}
async unreadCount(userId: string): Promise<{ unreadCount: number }> {
return { unreadCount: await this.repo.countUnread(userId) };
}
async markRead(
id: string,
userId: string,
): Promise<{ success: boolean; unreadCount: number }> {
const success = await this.repo.markRead(id, userId);
const unreadCount = await this.repo.countUnread(userId);
this.gateway.emitUnreadCount(userId, unreadCount);
return { success, unreadCount };
}
async markAllRead(
userId: string,
): Promise<{ updated: number; unreadCount: number }> {
const updated = await this.repo.markAllRead(userId);
const unreadCount = await this.repo.countUnread(userId);
this.gateway.emitUnreadCount(userId, unreadCount);
return { updated, unreadCount };
}
/** [dev/verification only] Send a canned notification straight to one user. */
async sendTestToUser(
userId: string,
body: {
audience?: NotificationAudience;
type?: NotificationType;
priority?: NotificationPriority;
title?: string;
message?: string;
},
): Promise<NotificationDto> {
const entity = await this.repo.create({
recipientUserId: userId,
audience: body.audience ?? NotificationAudience.BACKOFFICE,
type: body.type ?? NotificationType.GENERIC,
title: body.title ?? "Test notification",
body: body.message ?? "This is a test in-app notification.",
priority: body.priority ?? NotificationPriority.NORMAL,
isRead: false,
});
const dto = this.toDto(entity);
this.gateway.emitNew(userId, dto, await this.repo.countUnread(userId));
return dto;
}
private async deliverToUser(
userId: string,
input: NotifyInput,
priority: NotificationPriority,
): Promise<void> {
const entity = await this.repo.create({
recipientUserId: userId,
audience: input.audience,
type: input.type,
title: input.title,
body: input.body,
link: input.link ?? null,
data: input.data ?? null,
priority,
isRead: false,
});
const unreadCount = await this.repo.countUnread(userId);
this.gateway.emitNew(userId, this.toDto(entity), unreadCount);
const channels = this.resolveChannels(input, priority);
if (channels.email || channels.sms) {
const channelsSent = await this.fanOut(userId, input, channels);
if (channelsSent) {
await this.repo.update(entity.id, { channelsSent });
}
}
}
/**
* Decide which outbound channels to use. An explicit `input.channels`
* selection wins; otherwise fall back to priority (HIGH ⇒ email + SMS).
*/
private resolveChannels(
input: NotifyInput,
priority: NotificationPriority,
): Required<NotificationChannels> {
if (input.channels) {
return {
email: input.channels.email === true,
sms: input.channels.sms === true,
};
}
const high = priority === NotificationPriority.HIGH;
return { email: high, sms: high };
}
/**
* Best-effort email/SMS fan-out for the requested channels. Skips a channel
* the recipient has no address for. Never throws.
*/
private async fanOut(
userId: string,
input: NotifyInput,
channels: Required<NotificationChannels>,
): Promise<NotificationChannelsSent | null> {
try {
const user = await this.users.findOne({
where: { id: userId } as never,
});
if (!user) return null;
const sent: NotificationChannelsSent = {};
const text = `${input.title}\n\n${input.body}`;
if (channels.email && user.email) {
const res = await this.emailClient.sendEmail({
to: user.email,
subject: input.title,
text,
});
sent.email = res.queued;
}
if (channels.sms && user.phoneNumber) {
const res = await this.smsClient.sendSms({
to: user.phoneNumber,
message: text,
});
sent.sms = res.queued;
}
return Object.keys(sent).length ? sent : null;
} catch (err) {
this.logger.warn(
`fan-out failed for user ${userId}: ${(err as Error).message}`,
);
return null;
}
}
private toDto(n: Notification): NotificationDto {
return {
id: n.id,
recipientUserId: n.recipientUserId,
audience: n.audience,
type: n.type,
title: n.title,
body: n.body,
link: n.link ?? null,
data: n.data ?? null,
priority: n.priority,
isRead: n.isRead,
readAt: n.readAt ? new Date(n.readAt).toISOString() : null,
createdAt: new Date(n.createdAt).toISOString(),
};
}
}

View File

@@ -0,0 +1,75 @@
import { NotificationRecipients } from "@edr/types";
import { Injectable, Logger } from "@nestjs/common";
import { BackofficeService } from "../backoffice/backoffice.service";
import { CompanyProfileRepository } from "../companies/company-profile.repository";
import { ExternalProfileRepository } from "../companies/external-profile.repository";
/**
* Turns a {@link NotificationRecipients} selector into a de-duplicated set of
* IAM user ids.
*
* - `userIds` → honored as-is.
* - `companyId` → all portal users linked to the company (external_profiles).
* - `companyProfileId` → resolved to its company, then to that company's users.
* - `organizationId` → all current employees of the org (backoffice staff).
*
* NOTE: permission-scoped staff targeting is intentionally unsupported — freight
* has no "users-by-permission" lookup. Target explicit userIds or an org instead.
*/
@Injectable()
export class NotificationRecipientsService {
private readonly logger = new Logger(NotificationRecipientsService.name);
constructor(
private readonly externalProfiles: ExternalProfileRepository,
private readonly companyProfiles: CompanyProfileRepository,
private readonly backoffice: BackofficeService,
) {}
async resolve(recipients: NotificationRecipients): Promise<string[]> {
const ids = new Set<string>();
for (const id of recipients.userIds ?? []) {
if (id) ids.add(id);
}
let companyId = recipients.companyId;
if (!companyId && recipients.companyProfileId) {
const profile = await this.companyProfiles.findById(
recipients.companyProfileId,
);
companyId = profile?.companyId ?? undefined;
}
if (companyId) {
const profiles = await this.externalProfiles.findByCompanyId(companyId);
for (const p of profiles) {
if (p.userId) ids.add(p.userId);
}
}
if (recipients.organizationId) {
try {
const { items } = await this.backoffice.getOrganizationEmployees(
recipients.organizationId,
{},
);
for (const employee of items as Array<{
user?: { id?: string };
userId?: string;
}>) {
const uid = employee?.user?.id ?? employee?.userId;
if (uid) ids.add(uid);
}
} catch (err) {
this.logger.warn(
`Failed to resolve org recipients for ${recipients.organizationId}: ${
(err as Error).message
}`,
);
}
}
return [...ids];
}
}

View File

@@ -0,0 +1,75 @@
import {
NOTIFICATION_WS_EVENTS,
NOTIFICATION_WS_NAMESPACE,
NotificationDto,
} from "@edr/types";
import { Logger } from "@nestjs/common";
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from "@nestjs/websockets";
import { Server, Socket } from "socket.io";
import { WsAuthService } from "./ws-auth.service";
/**
* Server → client push for in-app notifications. Clients only *listen* (no
* `@SubscribeMessage` handlers), so the global HTTP JwtGuard never applies here;
* the handshake is authenticated in `handleConnection` and each socket joins a
* private `user:<id>` room the service targets.
*/
@WebSocketGateway({
namespace: NOTIFICATION_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class NotificationsGateway implements OnGatewayConnection {
private readonly logger = new Logger(NotificationsGateway.name);
@WebSocketServer()
private readonly server!: Server;
constructor(private readonly wsAuth: WsAuthService) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
if (!userId) {
this.logger.debug(`Rejected notifications handshake ${socket.id}`);
socket.disconnect(true);
return;
}
socket.data.userId = userId;
await socket.join(this.room(userId));
}
/** Push a freshly-created notification + the new unread count to a user. */
emitNew(userId: string, notification: NotificationDto, unreadCount: number): void {
const room = this.server.to(this.room(userId));
room.emit(NOTIFICATION_WS_EVENTS.NEW, notification);
room.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
}
/** Push only an updated unread count (e.g. after a read on another tab). */
emitUnreadCount(userId: string, unreadCount: number): void {
this.server
.to(this.room(userId))
.emit(NOTIFICATION_WS_EVENTS.UNREAD_COUNT, unreadCount);
}
private room(userId: string): string {
return `user:${userId}`;
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === "string") return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith("Bearer ")) return header.slice(7);
return undefined;
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable, Logger } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { Repository } from "typeorm";
import { verifyToken } from "@tria-plc/api-common/utils/token";
import { ESessionStatus } from "@tria-plc/api-common/utils/enums/user.enum";
import { Session } from "@tria-plc/iamapi-common/entities/iam/user/session.entity";
/**
* Authenticates a WebSocket handshake by mirroring the HTTP JwtGuard: the access
* token payload is only a *session* pointer (`{ id: <sessionId> }`), not the
* user — so we verify the signature (`verifyToken`), then load the IAM session
* and require it to be ACTIVE and unexpired, and read the real user id out of
* `session.userInfo`. There is no context-free verifier in the auth package, so
* this lookup is unavoidable; using the typed `Session` entity (rather than raw
* SQL) keeps it column-rename-safe and consistent with the package's own model.
*
* Returns the IAM user id, or null for any invalid/expired/revoked/malformed token.
*/
@Injectable()
export class WsAuthService {
private readonly logger = new Logger(WsAuthService.name);
constructor(
@InjectRepository(Session)
private readonly sessions: Repository<Session>,
) {}
async resolveUserId(token?: string): Promise<string | null> {
if (!token) return null;
try {
const payload = verifyToken(token) as { id?: string };
const sessionId = payload?.id;
if (!sessionId) return null;
const session = await this.sessions.findOne({
where: { id: sessionId },
});
if (!session) return null;
if (session.status !== ESessionStatus.ACTIVE) return null;
if (!session.expiryTime || new Date(session.expiryTime) <= new Date()) {
return null;
}
return session.userInfo?.id ?? null;
} catch (err) {
this.logger.debug(`WS auth rejected: ${(err as Error).message}`);
return null;
}
}
}

View File

@@ -479,7 +479,7 @@ export class PaymentService {
alreadyFinalized?: boolean;
reason?: string;
}> {
console.log(`Received payment event: ${JSON.stringify(event)}`);
this.logger.log(`Received payment event: ${JSON.stringify(event)}`);
if (event.eventType === "payment.succeeded") {
const intent = await this.paymentRepo.findOneBy({
refId: event.referenceId,
@@ -490,13 +490,12 @@ export class PaymentService {
reason: `No local intent for reference ${event.referenceId}`,
};
}
console.log(`Processing payment succeeded event for intent: }`, intent);
const { alreadyFinalized } = await this.markIntentSucceeded(intent.id, {
providerTxnId: event.providerTxnId,
paidAt: event.paidAt ? new Date(event.paidAt) : undefined,
notify: true,
});
console.log(
this.logger.log(
`Payment finalized for intent ${intent.id}, alreadyFinalized: ${alreadyFinalized}`,
);

View File

@@ -21,6 +21,14 @@ export class CreateCargoTypeDto {
@IsUUID()
parentGroupId?: string;
@ApiPropertyOptional({
description:
'Wagon type used to carry this (bulk) cargo. Drives train scheduling wagon-type resolution; required for bulk commodities that are scheduled.',
})
@IsOptional()
@IsUUID('4')
wagonTypeId?: string | null;
@ApiPropertyOptional({ default: false })
@IsOptional()
@IsBoolean()

View File

@@ -30,6 +30,14 @@ export class CreateContainerTypeDto {
@IsBoolean()
isOpenTop?: boolean;
@ApiPropertyOptional({
description:
'Wagon type used to carry this container. Drives train scheduling wagon-type resolution; required when this container type is scheduled.',
})
@IsOptional()
@IsUUID('4')
wagonTypeId?: string | null;
@ApiPropertyOptional({ default: true })
@IsOptional()
@IsBoolean()

View File

@@ -1,11 +1,13 @@
import { BaseEntity } from '@edr/api-common';
import { CargoUnitOfMeasure } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'cargo_types' })
@Index(['isActive'])
@Index(['displayOrder'])
@Index(['parentGroupId'])
@Index(['wagonTypeId'])
@Index(['code'])
export class CargoType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 50, unique: true, default: '' })
@@ -25,6 +27,19 @@ export class CargoType extends BaseEntity {
@Column({ name: 'unit_of_measure', type: 'varchar', length: 16, nullable: true })
unitOfMeasure?: CargoUnitOfMeasure | null;
/**
* Wagon type that carries this (bulk) cargo. Replaces the former hardcoded
* cargo-code → wagon-code map: train scheduling resolves the bulk wagon type
* through this FK. Nullable — grouping rows and container/legacy cargo never
* carry it; scheduling throws if a scheduled bulk cargo type leaves it unset.
*/
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
wagonTypeId?: string | null;
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
@Column({ name: 'requires_director_approval', type: 'boolean', default: false })
requiresDirectorApproval!: boolean;

View File

@@ -1,10 +1,12 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, OneToMany } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { WeightLimitRule } from './weight-limit-rule.entity';
import { WagonType } from '../../wagon-types/entities/wagon-type.entity';
@Entity({ schema: 'freight', name: 'container_types' })
@Index(['code'])
@Index(['isActive'])
@Index(['wagonTypeId'])
export class ContainerType extends BaseEntity {
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
code!: string;
@@ -24,6 +26,19 @@ export class ContainerType extends BaseEntity {
@Column({ name: 'is_open_top', type: 'boolean', default: false, nullable: true })
isOpenTop!: boolean;
/**
* Wagon type that carries this container. Replaces the former hardcoded
* container wagon-code default (NW5): train scheduling resolves the container
* wagon type through this FK. Nullable; scheduling throws if a scheduled
* container type leaves it unset.
*/
@Column({ name: 'wagon_type_id', type: 'uuid', nullable: true })
wagonTypeId?: string | null;
@ManyToOne(() => WagonType, { nullable: true, onDelete: 'RESTRICT' })
@JoinColumn({ name: 'wagon_type_id' })
wagonType?: WagonType | null;
@Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean;

View File

@@ -33,7 +33,7 @@ export class CargoTypesRepository implements ICargoTypesRepository {
}
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -33,7 +33,7 @@ export class ContainerTypesRepository implements IContainerTypesRepository {
}
async update(id: string, data: Partial<ContainerType>): Promise<ContainerType | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -74,7 +74,7 @@ export class RatesRepository implements IRatesRepository {
}
async update(id: string, data: Partial<Rate>): Promise<Rate | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -65,7 +65,7 @@ export class WeightLimitRulesRepository implements IWeightLimitRulesRepository {
}
async update(id: string, data: Partial<WeightLimitRule>): Promise<WeightLimitRule | null> {
await this.repo.update(id, data);
await this.repo.update(id, data as never);
return this.findById(id);
}

View File

@@ -82,6 +82,7 @@ export class CargoTypesService {
requiresDirectorApproval: dto.requiresDirectorApproval ?? false,
isActive: dto.isActive ?? true,
unitOfMeasure: dto.unitOfMeasure ?? null,
wagonTypeId: dto.wagonTypeId ?? null,
displayOrder,
});
}

View File

@@ -64,6 +64,7 @@ export class ContainerTypesService {
isReefer: dto.isReefer ?? false,
isOpenTop: dto.isOpenTop ?? false,
isActive: dto.isActive ?? true,
wagonTypeId: dto.wagonTypeId ?? null,
displayOrder,
});
}

View File

@@ -120,9 +120,17 @@ export class TrainSchedule extends BaseEntity {
@Column({ name: 'rule_window_open_hour', type: 'int', nullable: true })
ruleWindowOpenHour?: number | null;
/** EAT hour the daily booking desk shuts (equals open hour for a 24h desk). */
@Column({ name: 'rule_window_close_hour', type: 'int', nullable: true })
ruleWindowCloseHour?: number | null;
@Column({ name: 'rule_window_duration_hours', type: 'numeric', precision: 6, scale: 4, nullable: true })
ruleWindowDurationHours?: number | null;
/**
* Frozen reopen gap = doc-review + payment minutes at creation. The board
* projects each next cycle at close + this delay, then snaps it into office hours.
*/
@Column({ name: 'rule_reopen_delay_minutes', type: 'int', nullable: true })
ruleReopenDelayMinutes?: number | null;

View File

@@ -5,6 +5,7 @@ import {
BATCH_WINDOW_START_HOURS,
listConfigBookingWindows,
groupBookingsIntoBoardWindows,
computeImportWindowTimes,
type BoardWindowConfig,
} from './batch-window.util';
@@ -54,11 +55,145 @@ describe('batch-window.util', () => {
});
});
describe('computeImportWindowTimes — first-window open respects office hours', () => {
// Departs Mon 06 Jul 08:00 EAT (05:00 UTC). Lead 3 days → anchor 03 Jul 08:00
// EAT (05:00 UTC). Bounded desk 08:0017:00, 15h window.
const departure = new Date('2026-07-06T05:00:00.000Z');
const bounded = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 15,
};
it('opens at the morning anchor when now is before the lead window', () => {
// Now = 02 Jul 06:00 EAT (before the 03 Jul anchor).
const now = new Date('2026-07-02T03:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
// Anchor: 03 Jul 08:00 EAT = 05:00 UTC.
expect(windowOpensAt.toISOString()).toBe('2026-07-03T05:00:00.000Z');
});
it('opens NOW when inside the lead window and inside office hours (past the anchor)', () => {
// Now = 05 Jul 12:00 EAT (09:00 UTC): inside lead days, inside 08:0017:00,
// anchor already passed → open immediately.
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T09:00:00.000Z');
});
it('waits for next morning when now is after the desk closes', () => {
// Departs 06 Jul 14:00 EAT (11:00 UTC) so next-morning open sits before departure.
// Now = 05 Jul 18:00 EAT (15:00 UTC): after 17:00 close → open 06 Jul 08:00 EAT.
const lateDeparture = new Date('2026-07-06T11:00:00.000Z');
const now = new Date('2026-07-05T15:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(lateDeparture, bounded, now);
// 06 Jul 08:00 EAT = 05:00 UTC.
expect(windowOpensAt.toISOString()).toBe('2026-07-06T05:00:00.000Z');
});
it('opens this morning when now is before the desk opens on a lead day', () => {
// Now = 05 Jul 06:00 EAT (03:00 UTC): inside lead days but before 08:00 → 08:00 today.
const now = new Date('2026-07-05T03:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, bounded, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
});
it('24-hour desk opens NOW at any hour, day or night, once inside the lead window', () => {
// Round-the-clock desk (open === close). Now = 05 Jul 03:00 EAT (00:00 UTC),
// deep night, past the anchor → open immediately.
const roundClock = { ...bounded, windowOpenHour: 8, windowCloseHour: 8 };
const now = new Date('2026-07-05T00:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, roundClock, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T00:00:00.000Z');
});
it('caps the close at departure', () => {
// Opens now (05 Jul 12:00 EAT); a 24h duration would close 06 Jul 12:00 EAT,
// past the 06 Jul 08:00 departure → clamped to departure.
const now = new Date('2026-07-05T09:00:00.000Z');
const { windowClosesAt } = computeImportWindowTimes(
departure,
{ ...bounded, windowDurationHours: 24 },
now,
);
expect(windowClosesAt.toISOString()).toBe(departure.toISOString());
});
describe('overnight desk (open > close, wraps past midnight)', () => {
// Desk open 08:00, closes 05:00 next morning — open across midnight.
const overnight = { ...bounded, windowOpenHour: 8, windowCloseHour: 5 };
it('opens NOW at 00:00 (deep night is INSIDE the overnight window)', () => {
// Now = 05 Jul 00:00 EAT (04 Jul 21:00 UTC): after midnight, before 05:00 →
// inside the overnight desk → open immediately. This is the reported bug.
const now = new Date('2026-07-04T21:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-04T21:00:00.000Z');
});
it('opens NOW at 22:00 (evening is INSIDE the overnight window)', () => {
// Now = 05 Jul 22:00 EAT (19:00 UTC): after 08:00 open → inside → open now.
const now = new Date('2026-07-05T19:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T19:00:00.000Z');
});
it('waits to 08:00 when now is in the daytime gap [05:00, 08:00)', () => {
// Now = 05 Jul 06:00 EAT (03:00 UTC): desk shut (gap) → open 08:00 today.
const now = new Date('2026-07-05T03:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
// 05 Jul 08:00 EAT = 05:00 UTC.
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
});
});
});
describe('computeImportWindowTimes — overnight desk (open > close, wraps midnight)', () => {
// Overnight desk 08:00 → 07:00 next morning: open across [08:00, 24:00) and
// [00:00, 07:00). Only the daytime gap [07:00, 08:00) is shut.
const overnight = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 7,
windowDurationHours: 6,
};
it('opens NOW in the evening side of the window (after open hour)', () => {
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 20:00 EAT (17:00 UTC):
// ≥ 08:00 → desk open → open immediately.
const departure = new Date('2026-07-06T07:00:00.000Z');
const now = new Date('2026-07-05T17:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T17:00:00.000Z');
});
it('opens NOW after midnight (before close hour)', () => {
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 06 Jul 02:00 EAT (05 Jul 23:00
// UTC): < 07:00 → still inside the overnight window → open immediately.
const departure = new Date('2026-07-06T07:00:00.000Z');
const now = new Date('2026-07-05T23:00:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T23:00:00.000Z');
});
it('waits until open hour in the daytime gap [close, open)', () => {
// Departs 06 Jul 10:00 EAT (07:00 UTC). Now = 05 Jul 07:30 EAT (04:30 UTC):
// in the shut daytime gap → opens 05 Jul 08:00 EAT (05:00 UTC).
const departure = new Date('2026-07-06T07:00:00.000Z');
const now = new Date('2026-07-05T04:30:00.000Z');
const { windowOpensAt } = computeImportWindowTimes(departure, overnight, now);
expect(windowOpensAt.toISOString()).toBe('2026-07-05T05:00:00.000Z');
});
});
describe('batch-window board windows (config-driven booking cycles)', () => {
// Default rules: open 08:00 EAT, 3 days before departure, 3h long, reopen 90m later.
// Default rules: open 08:00 EAT, desk shuts 17:00, 3 days before departure,
// 3h long, reopen 90m later.
const cfg: BoardWindowConfig = {
importWindowLeadDays: 3,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
reopenDelayMinutes: 90,
exportBookingLeadHours: 24,
@@ -76,14 +211,43 @@ describe('batch-window board windows (config-driven booking cycles)', () => {
expect(windows[0].end.toISOString()).toBe('2026-06-05T08:00:00.000Z');
});
it('import: reopens reopenDelayMinutes after close, same booking day', () => {
it('import: reopens reopenDelayMinutes after close while inside office hours', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT
// cycle 1: 08:0011:00; reopen +90m → cycle 2 opens 12:30 EAT, same day
expect(windows.length).toBeGreaterThanOrEqual(2);
expect(windows[1].start.toISOString()).toBe('2026-06-05T09:30:00.000Z'); // 12:30 EAT
// all cycles stay on the same EAT booking day
expect(windows.every((w) => w.date === '2026-06-05')).toBe(true);
expect(windows[1].date).toBe('2026-06-05');
});
it('import: pauses at close hour and resumes next morning at open hour', () => {
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, cfg);
// Day 05 Jun: 08:00, 12:30, 17:00-clamped… the cycle whose reopen lands
// at/after 17:00 EAT rolls to 06 Jun 08:00 EAT (05:00 UTC).
const day6First = windows.find((w) => w.date === '2026-06-06');
expect(day6First).toBeDefined();
expect(day6First!.start.toISOString()).toBe('2026-06-06T05:00:00.000Z'); // 08:00 EAT
// Cycles span the office days between the window day and departure.
const days = new Set(windows.map((w) => w.date));
expect(days.has('2026-06-05')).toBe(true);
expect(days.has('2026-06-06')).toBe(true);
});
it('import: 24-hour desk (open hour === close hour) never breaks for the day', () => {
const roundClock: BoardWindowConfig = { ...cfg, windowOpenHour: 8, windowCloseHour: 8 };
const departure = new Date('2026-06-08T11:00:00.000Z');
const windows = listConfigBookingWindows('IMPORT', departure, roundClock);
// Reopen chains straight through midnight: an overnight cycle exists.
const crossesNight = windows.some(
(w, i) => i > 0 && windows[i - 1].date !== w.date,
);
expect(crossesNight).toBe(true);
// Cycles run continuously from the window day up to departure — the last one
// reaches departure, proving the runaway cap did not truncate the projection.
expect(windows[windows.length - 1].end.getTime()).toBe(departure.getTime());
// Spans the full lead (window day 05 Jun → departure 08 Jun).
expect(new Set(windows.map((w) => w.date)).size).toBeGreaterThanOrEqual(3);
});
it('export: single FCFS window exportBookingLeadHours before departure', () => {

View File

@@ -137,33 +137,145 @@ export function shiftEatDay(day: string, deltaDays: number): string {
).padStart(2, '0')}`;
}
/**
* The daily office window `[openHour, closeHour)` in EAT: after `closeHour` the
* booking desk is shut and reopens `openHour` the next morning. `openHour ===
* closeHour` means a 24-hour desk that never breaks for the day.
*/
export interface OfficeHours {
windowOpenHour: number;
windowCloseHour: number;
}
/** True when the desk runs round the clock (open hour equals close hour). */
export function isRoundTheClock(hours: OfficeHours): boolean {
return hours.windowOpenHour === hours.windowCloseHour;
}
/**
* Where the NEXT booking cycle opens after a cycle closes at `closedAt`, given a
* not-yet-full train and a daily office window. `earliestNextOpen` is the raw
* ready time (close + doc-review + payment); the desk honours it only while
* inside office hours:
*
* • round-the-clock desk → opens at `earliestNextOpen` (no day break)
* • ready time before closeHour → opens at `earliestNextOpen`, same day
* • ready time at/after closeHour → desk shut; opens next morning at openHour
*
* Returns `null` when the next open would fall on/after `departure` — the train
* leaves before another cycle could run, so the window is done.
*
* The desk may run within one EAT day (`closeHour > openHour`), round the clock
* (`openHour === closeHour`), or overnight across midnight (`openHour >
* closeHour`, e.g. 08:00 → 07:00). `officeHoursOpen` handles all three.
*/
/**
* The EAT instant a booking cycle would open if it became ready at `readyAt`,
* honouring the daily office window but WITHOUT any departure bound:
*
* • round-the-clock desk → opens at `readyAt` (no day break)
* • ready before openHour → opens at openHour that EAT morning
* • ready inside office hours → opens at `readyAt`
* • ready at/after closeHour → opens at openHour the next morning
*
* `nextCycleOpensAt` layers the "before departure" gate on top of this; the first
* import window uses it directly and lets its own departure cap apply.
*/
export function officeHoursOpen(readyAt: Date, hours: OfficeHours): Date {
if (isRoundTheClock(hours)) {
return readyAt;
}
const { hour, minute } = eatParts(readyAt);
const readyMinutes = hour * 60 + minute;
const openMinutes = hours.windowOpenHour * 60;
const closeMinutes = hours.windowCloseHour * 60;
if (hours.windowOpenHour > hours.windowCloseHour) {
// Overnight desk, e.g. open 08:00 → close 07:00 next morning. The desk is
// open across midnight: [openHour, 24:00) on this EAT day and [00:00,
// closeHour) on the next. Only the daytime gap [closeHour, openHour) is shut.
if (readyMinutes >= openMinutes || readyMinutes < closeMinutes) {
// Inside the overnight window (either side of midnight) → open when ready.
return readyAt;
}
// In the daytime gap → the desk opens again at openHour this EAT morning.
return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour);
}
if (readyMinutes < openMinutes) {
// Ready before the desk opens on its own EAT calendar day → open this morning.
return eatDayToUtc(eatDay(readyAt), hours.windowOpenHour);
}
if (readyMinutes < closeMinutes) {
// Inside office hours → open as soon as ready.
return readyAt;
}
// Desk shut for the day → open tomorrow morning.
return eatDayToUtc(shiftEatDay(eatDay(readyAt), 1), hours.windowOpenHour);
}
export function nextCycleOpensAt(
earliestNextOpen: Date,
hours: OfficeHours,
departure: Date,
): Date | null {
const opensAt = officeHoursOpen(earliestNextOpen, hours);
return opensAt.getTime() < departure.getTime() ? opensAt : null;
}
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.
* Import booking-day window. The natural anchor is `windowOpenHour` EAT on
* departure-day minus `importWindowLeadDays`. When `now` is at/before that anchor
* (we're still before the lead window) the window opens at the anchor — the normal
* morning wait.
*
* Once `now` is PAST the anchor we're already inside the lead window, so the desk's
* office hours decide the open the same way a reopen cycle does (via
* `nextCycleOpensAt`):
*
* • 24-hour desk (open === close) → opens at `now`, any hour, day or night
* • `now` inside [openHour, closeHour) → opens at `now` (desk is open right now)
* • `now` before openHour that EAT day → opens at openHour that morning
* • `now` at/after closeHour → desk shut; opens openHour next morning
*
* `windowDurationHours` extends from that open, capped at departure.
*/
export function computeImportWindowTimes(
departure: Date,
cfg: {
importWindowLeadDays: number;
windowOpenHour: number;
windowCloseHour: 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);
const anchor = eatDayToUtc(windowDay, cfg.windowOpenHour);
let opensAt: Date;
if (now.getTime() <= anchor.getTime()) {
// Before the lead window → normal morning wait at the anchor.
opensAt = anchor;
} else {
// Inside the lead window → the office-hours rule decides the open, exactly as a
// reopen cycle does: open now if the desk is open now (or round-the-clock),
// else at the next open hour. We use the same primitive as reopen cycles but
// WITHOUT its `< departure` null-gate — when the next open lands on/after
// departure the shared cap below clamps the (zero-length) window to departure,
// which is truthful, rather than masking it as "open now".
opensAt = officeHoursOpen(now, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
}
let closesAt = new Date(opensAt.getTime() + cfg.windowDurationHours * 3_600_000);
if (closesAt.getTime() > departure.getTime()) {
closesAt = departure;
}
@@ -269,7 +381,10 @@ export interface BoardWindow extends BatchWindow {
export interface BoardWindowConfig {
importWindowLeadDays: number;
windowOpenHour: number;
/** EAT hour the daily booking desk shuts; equals windowOpenHour for a 24h desk. */
windowCloseHour: number;
windowDurationHours: number;
/** Gap between a cycle's close and its reopen (doc review + payment minutes). */
reopenDelayMinutes: number;
exportBookingLeadHours: number;
}
@@ -328,25 +443,34 @@ export function listConfigBookingWindows(
const windows: BoardWindow[] = [];
const durationMs = cfg.windowDurationHours * 3_600_000;
// Post-close gap before the next cycle opens (doc review + payment), subject
// to office hours below.
const reopenMs = cfg.reopenDelayMinutes * 60_000;
const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const windowDay = shiftEatDay(eatDay(departure), -cfg.importWindowLeadDays);
let opensAt = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// Reopen stays on the same EAT booking day and before departure; cap at 12 cycles.
for (let cycle = 0; cycle < 12; cycle += 1) {
let opensAt: Date | null = anchorOpensAt ?? eatDayToUtc(windowDay, cfg.windowOpenHour);
// The loop terminates naturally: every cycle advances opensAt by at least
// (duration + reopen) > 0, and nextCycleOpensAt returns null once opensAt would
// reach departure. maxCycles is a derived runaway backstop sized to the real
// span (first open → departure) over the smallest possible advance, so a
// legitimate config is never silently truncated — only a pathological
// zero-length one would hit it.
const spanMs = departure.getTime() - opensAt.getTime();
const minAdvanceMs = Math.max(durationMs + reopenMs, 60_000);
const maxCycles = Math.ceil(spanMs / minAdvanceMs) + 2;
for (let cycle = 0; cycle < maxCycles; cycle += 1) {
if (opensAt.getTime() >= departure.getTime()) break;
let closesAt = new Date(opensAt.getTime() + durationMs);
if (closesAt.getTime() > departure.getTime()) closesAt = departure;
windows.push(boardWindowFromInterval(opensAt, closesAt));
const nextOpensAt = new Date(closesAt.getTime() + reopenMs);
if (
nextOpensAt.getTime() >= departure.getTime() ||
eatDay(nextOpensAt) !== eatDay(opensAt)
) {
break;
}
opensAt = nextOpensAt;
const earliestNextOpen = new Date(closesAt.getTime() + reopenMs);
opensAt = nextCycleOpensAt(earliestNextOpen, officeHours, departure);
if (opensAt == null) break;
}
// Degenerate config (no window before departure) — surface a single window

View File

@@ -82,6 +82,7 @@ describe('BookingBatchService — PAID reconcile', () => {
importWindowLeadDays: 3,
exportBookingLeadHours: 24,
windowOpenHour: 8,
windowCloseHour: 17,
windowDurationHours: 3,
docReviewMinutes: 30,
paymentWindowMinutes: 60,

View File

@@ -310,7 +310,10 @@ export class BookingBatchService implements OnModuleInit {
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
const open = (
await this.trainSchedulesRepository.findAll({
where: { bookingWindowStatus: "OPEN" },
where: [
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Draft },
{ bookingWindowStatus: "OPEN", status: TrainScheduleStatusEnum.Scheduled },
],
})
).filter((s) => s.windowPhase == null);
const groups = new Map<string, RouteDayGroup>();
@@ -733,6 +736,7 @@ export class BookingBatchService implements OnModuleInit {
};
const windowCfg = {
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour),
windowDurationHours: num(
s.ruleWindowDurationHours,
liveCfg.windowDurationHours,

View File

@@ -7,8 +7,14 @@ export interface BookingWindowConfig {
importWindowLeadDays: number;
/** Hours before departure an export booking becomes acceptable (FCFS). */
exportBookingLeadHours: number;
/** Local (Africa/Addis_Ababa) hour at which the import window opens. */
/** Local (Africa/Addis_Ababa) hour at which the import window opens each day. */
windowOpenHour: number;
/**
* Local (Africa/Addis_Ababa) hour the booking desk shuts for the day: once a
* cycle's reopen would fall at/after this hour, the window pauses and resumes
* next morning at windowOpenHour. Equal to windowOpenHour ⇒ 24-hour desk.
*/
windowCloseHour: number;
windowDurationHours: number;
/** Max staff document-review time after the window closes. */
docReviewMinutes: number;

View File

@@ -9,9 +9,9 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service';
import { BookingBatchService } from './booking-batch.service';
import { TrainSchedulingService } from './train-scheduling.service';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay } from './batch-window.util';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
import { type BookingWindowConfig } from './booking-window.config';
/**
@@ -54,7 +54,7 @@ export class BookingWindowService implements OnModuleInit {
this.ticking = true;
try {
const now = new Date();
const cfg = await this.trainSchedulingService.getWindowConfig();
const liveCfg = await this.trainSchedulingService.getWindowConfig();
const active = (
await this.trainSchedulesRepository.findAll({
@@ -64,12 +64,22 @@ export class BookingWindowService implements OnModuleInit {
],
})
).filter(
(s) => s.windowPhase != null && s.windowPhase !== 'DONE' && s.windowPhase !== 'CLOSED_FOR_DAY',
// CLOSED_FOR_DAY is legacy (the daily desk now reopens via PRE_WINDOW):
// still pick those rows up so advanceImport can revive them next morning.
(s) => s.windowPhase != null && s.windowPhase !== 'DONE',
);
for (const schedule of active) {
try {
await this.advanceSchedule(schedule, cfg, now);
// Each train runs under its OWN frozen rule snapshot, not the live global
// config — a later global-rules edit must not retro-change the window an
// existing train already advertised, and the reopen cycles must match the
// board (which is drawn from the same snapshot).
await this.advanceSchedule(
schedule,
effectiveWindowConfig(schedule, liveCfg),
now,
);
} catch (err) {
this.logger.error(
`Window transition failed for schedule ${schedule.id}: ${(err as Error).message}`,
@@ -100,7 +110,7 @@ export class BookingWindowService implements OnModuleInit {
return schedule;
}
const now = new Date();
const cfg = await this.trainSchedulingService.getWindowConfig();
const liveCfg = 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 = (
@@ -121,7 +131,7 @@ export class BookingWindowService implements OnModuleInit {
.getRepository(TrainSchedule)
.update(s.id, { docReviewCompletedAt: now });
s.docReviewCompletedAt = now;
await this.advanceSchedule(s, cfg, now);
await this.advanceSchedule(s, effectiveWindowConfig(s, liveCfg), now);
}
const fresh = await this.trainSchedulesRepository.findById(scheduleId);
return fresh ?? schedule;
@@ -185,6 +195,14 @@ export class BookingWindowService implements OnModuleInit {
): Promise<boolean> {
const { windowPhase, windowOpensAt, windowClosesAt } = schedule;
// Legacy rows parked at CLOSED_FOR_DAY predate the daily-desk reopen: revive
// them through the same not-full conclude path so they resume next morning
// (or finalize as DONE if no cycle fits before departure).
if (windowPhase === 'CLOSED_FOR_DAY') {
await this.concludeCycle(schedule, cfg, now);
return true;
}
if (windowPhase === 'PRE_WINDOW' && windowOpensAt && now >= windowOpensAt) {
await this.setPhase(schedule, {
windowPhase: 'OPEN',
@@ -268,34 +286,46 @@ export class BookingWindowService implements OnModuleInit {
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);
// Doc review + payment have already run, so the desk is ready to reopen NOW —
// office hours decide whether that is this afternoon or tomorrow morning. Past
// the last cycle before departure, nextCycleOpensAt returns null and we finish.
const officeHours: OfficeHours = {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
};
const nextOpensAt = nextCycleOpensAt(
now,
officeHours,
schedule.scheduledDepartureDate,
);
if (nextOpensAt == null) {
await this.setPhase(schedule, { windowPhase: 'DONE' });
this.logger.log(
`Schedule ${schedule.id} not full but no cycle fits before departure — window done`,
);
return;
}
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`,
);
}
// Stays PRE_WINDOW (not CLOSED_FOR_DAY): the tick reopens it at nextOpensAt,
// whether that is later today or next morning after the office-hours break.
await this.setPhase(schedule, {
windowPhase: 'PRE_WINDOW',
windowOpensAt: nextOpensAt,
windowClosesAt: nextClosesAt,
docReviewCompletedAt: null,
docReviewEndsAt: null,
paymentPhaseEndsAt: null,
});
const sameDay = eatDay(nextOpensAt) === eatDay(now);
this.logger.log(
`Schedule ${schedule.id} not full — window reopens ${sameDay ? 'today' : 'next booking day'} at ${nextOpensAt.toISOString()}`,
);
}
private async tryAutoFinalize(scheduleId: string): Promise<void> {

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsISO8601 } from 'class-validator';
/**
* Reschedule a train's departure date (staff action on the ops board). Only
* allowed before the booking window opens; the new date must still leave room
* for the booking lead window before departure.
*/
export class UpdateScheduleDateDto {
@ApiProperty({
example: '2026-07-20T05:00:00.000Z',
description: 'New scheduled departure date/time (ISO 8601)',
})
@IsISO8601()
scheduleDate!: string;
}

View File

@@ -0,0 +1,62 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNumber, IsOptional, Max, Min } from 'class-validator';
/**
* Per-schedule booking-window rule override (staff action on the ops board).
* Every field is optional — only the ones sent are changed; the rest keep the
* schedule's existing snapshot. Mirrors the window fields of the global rules DTO.
*/
export class UpdateScheduleWindowRuleDto {
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the booking desk opens each day' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowOpenHour?: number;
@ApiPropertyOptional({
example: 17,
description:
'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowCloseHour?: number;
@ApiPropertyOptional({ example: 3, description: 'How long each booking cycle stays open, in hours' })
@IsOptional()
@Type(() => Number)
@IsNumber()
@Min(0.0166)
@Max(12)
windowDurationHours?: number;
@ApiPropertyOptional({ example: 30, description: 'Max staff document-review minutes after the window closes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
docReviewMinutes?: number;
@ApiPropertyOptional({ example: 60, description: 'Customer payment window minutes' })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
paymentWindowMinutes?: number;
@ApiPropertyOptional({
example: 3,
description: 'Days before departure the booking window starts (re-derives the window start)',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
importWindowLeadDays?: number;
}

View File

@@ -52,7 +52,7 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@Min(1)
exportBookingLeadHours?: number;
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens' })
@ApiPropertyOptional({ example: 8, description: 'Local EAT hour the import window opens each day' })
@IsOptional()
@Type(() => Number)
@IsInt()
@@ -60,6 +60,18 @@ export class UpdateTrainSchedulingGlobalRulesDto {
@Max(23)
windowOpenHour?: number;
@ApiPropertyOptional({
example: 17,
description:
'Local EAT hour the booking desk shuts each day; a not-yet-full window resumes next morning at windowOpenHour. Equal to windowOpenHour = 24-hour desk',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
@Max(23)
windowCloseHour?: number;
// Stored in hours. The UI enters this in minutes/hours/days and converts to
// hours before sending, so the floor is 1 minute (0.0166h) — not 15 min.
@ApiPropertyOptional({ example: 3 })

View File

@@ -54,6 +54,14 @@ export class TrainSchedulingGlobalRules extends BaseEntity {
@Column({ name: 'window_open_hour', type: 'int', default: 8 })
windowOpenHour!: number;
/**
* Local (Africa/Addis_Ababa) hour the booking desk shuts each day. A not-yet-full
* train whose next cycle would reopen at/after this hour pauses until the next
* morning's windowOpenHour. Set equal to windowOpenHour for a 24-hour desk.
*/
@Column({ name: 'window_close_hour', type: 'int', default: 17 })
windowCloseHour!: number;
// Stored in hours; 4 decimals so sub-minute UI durations (4 min = 0.0667h)
// are exact. See WidenWindowDurationHoursPrecision migration.
@Column({

View File

@@ -42,6 +42,8 @@ import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
import { AvailableDaysForCargoQueryDto } from "./dto/available-days-for-cargo-query.dto";
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
import { UpdateScheduleWindowRuleDto } from "./dto/update-schedule-window-rule.dto";
import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service";
import { BookingWindowService } from "./booking-window.service";
@@ -370,6 +372,19 @@ export class TrainSchedulingController {
return this.trainSchedulingService.updateImportLoadingStatus(id, dto);
}
@Patch("schedules/:id/loading-status")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Mark bookings loaded/unloaded on this schedule (any direction, pre-dispatch only)",
})
setBookingLoadingStatus(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateImportLoadingStatusDto,
) {
return this.trainSchedulingService.setBookingLoadingStatus(id, dto);
}
@Post("schedules/:id/pin-wagons")
@TrainSchedulingManage()
@ApiOperation({ summary: "Pin physical wagons to train set slots" })
@@ -438,6 +453,19 @@ export class TrainSchedulingController {
return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto);
}
@Post("schedules/:id/confirm-loading")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Confirm cargo loaded on the train (any direction; unblocks import-Djibouti dispatch)",
})
confirmScheduleLoading(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: ImportDjiboutiActionDto,
) {
return this.trainSchedulingService.confirmScheduleLoading(id, dto);
}
@Post("schedules/:id/import-djibouti/depart")
@TrainSchedulingManage()
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
@@ -519,6 +547,34 @@ export class TrainSchedulingController {
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/window-rule")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Override the booking-window rule for one schedule (open/close hour, duration, doc-review, payment, lead days) — only before the window opens",
})
async updateScheduleWindowRule(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleWindowRuleDto,
) {
await this.trainSchedulingService.updateScheduleWindowRule(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Patch("schedules/:id/schedule-date")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Reschedule a train's departure date — only before the booking window opens, and only if the new date still leaves room for the booking lead window",
})
async updateScheduleDate(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: UpdateScheduleDateDto,
) {
await this.trainSchedulingService.updateScheduleDate(id, dto);
return this.trainSchedulingService.getContainerTrainScheduleById(id);
}
@Post("schedules/:id/doc-review-complete")
@TrainSchedulingManage()
@ApiOperation({

View File

@@ -15,11 +15,12 @@ import {
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In, Not } from 'typeorm';
import { DataSource, EntityManager, In, IsNull, Not } from 'typeorm';
import { BookingsRepository } from '../bookings/bookings.repository';
import { Booking } from '../bookings/entities/booking.entity';
import { BookingContainer } from '../bookings/entities/booking-container.entity';
import { ClearanceMilestone } from '../contracts/entities/clearance-milestone.entity';
import { Container } from '../container-management/entities/container.entity';
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { LocomotivesRepository } from '../locomotives/locomotives.repository';
@@ -38,6 +39,8 @@ import { WagonAllocationBulkLoadsRepository } from '../train-schedules/wagon-all
import { WagonAllocationContainerItemsRepository } from '../train-schedules/wagon-allocation-container-items.repository';
import { WagonBookingAllocationsRepository } from '../train-schedules/wagon-booking-allocations.repository';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
import { ContainerType } from '../rule-engine/entities/container-type.entity';
import { WagonTypesRepository } from '../wagon-types/wagon-types.repository';
import { Wagon } from '../wagons/entities/wagon.entity';
import { AssignBookingsDto } from './dto/assign-bookings.dto';
@@ -61,6 +64,8 @@ import {
UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto';
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
import { UpdateScheduleWindowRuleDto } from './dto/update-schedule-window-rule.dto';
import { UpdateScheduleDateDto } from './dto/update-schedule-date.dto';
import { type BookingWindowConfig } from './booking-window.config';
import {
buildCappedWagonPlan,
@@ -87,10 +92,6 @@ import {
type ContainerPlacementInput,
type WagonPlanSlot,
} from './wagon-plan.util';
import {
getDefaultContainerWagonTypeCode,
pickBulkWagonType,
} from './wagon-type-resolver.util';
import { deriveScheduleDirection } from './derive-schedule-direction.util';
import { pickLowestFreeNumber, pickTrainNumberPool } from './train-number.util';
import {
@@ -125,6 +126,66 @@ import {
const SCHEDULABLE_BOOKING_STATUSES = ['PAID'] as const;
/**
* The booking-window rule fields frozen onto a train schedule at creation (and
* refreshed by restampPendingWindows for not-yet-open schedules). The board draws
* its display cycles from this snapshot, so a later global-rules edit never redraws
* an already-open schedule's windows. The reopen gap is derived here — doc review +
* payment — because that is the real delay between a cycle closing and reopening.
*/
function windowRuleSnapshot(cfg: BookingWindowConfig) {
return {
ruleWindowOpenHour: cfg.windowOpenHour,
ruleWindowCloseHour: cfg.windowCloseHour,
ruleWindowDurationHours: cfg.windowDurationHours,
ruleReopenDelayMinutes: cfg.docReviewMinutes + cfg.paymentWindowMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
};
}
/**
* The booking-window config a specific schedule runs under: its frozen rule
* snapshot (open/close hour, duration, lead, reopen gap) overlaid on the live
* config, with the live config filling any snapshot field a legacy row lacks.
*
* The window SHAPE (hours, duration, lead, reopen gap) comes from the snapshot so
* the runtime cycle engine matches exactly what the board drew and the customer
* saw — a later global-rule edit must not retro-change an existing train. The
* doc-review / payment split is an internal process timing (not part of the
* window the customer sees) and is not stored split in the snapshot, so it always
* takes the live values; their sum is only used as a fallback reopen gap when the
* row predates `ruleReopenDelayMinutes`.
*/
export function effectiveWindowConfig(
schedule: {
ruleWindowOpenHour?: number | null;
ruleWindowCloseHour?: number | null;
ruleWindowDurationHours?: number | null;
ruleReopenDelayMinutes?: number | null;
ruleImportWindowLeadDays?: number | null;
ruleExportBookingLeadHours?: number | null;
},
liveCfg: BookingWindowConfig,
): BookingWindowConfig {
return {
importWindowLeadDays:
schedule.ruleImportWindowLeadDays ?? liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
windowOpenHour: schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour: schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours,
docReviewMinutes: liveCfg.docReviewMinutes,
paymentWindowMinutes: liveCfg.paymentWindowMinutes,
reopenDelayMinutes:
schedule.ruleReopenDelayMinutes ?? liveCfg.reopenDelayMinutes,
};
}
export type BookingWagonAllocationStatus =
| 'NOT_ATTEMPTED'
| 'ASSIGNED'
@@ -270,18 +331,27 @@ export class TrainSchedulingService {
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.windowCloseHour != null) row.windowCloseHour = dto.windowCloseHour;
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;
// The booking desk supports three shapes: a same-day range
// (closeHour > openHour), a 24-hour desk (openHour === closeHour), and an
// overnight range that wraps past midnight (openHour > closeHour, e.g.
// 08:00 → 07:00). officeHoursOpen handles all three, so no ordering guard.
// Fields that change the STAMPED open/close times of a schedule. docReview/
// payment/reopen are read live by the cron each tick, so they need no
// re-stamp; only the four below feed computeImport/ExportWindowTimes.
const windowTimingChanged =
dto.importWindowLeadDays != null ||
dto.windowOpenHour != null ||
dto.windowCloseHour != null ||
dto.windowDurationHours != null ||
dto.docReviewMinutes != null ||
dto.paymentWindowMinutes != null ||
dto.exportBookingLeadHours != null;
const saved = await this.dataSource
@@ -299,6 +369,156 @@ export class TrainSchedulingService {
return saved;
}
/**
* Override the booking-window rule for ONE schedule (staff action on the ops
* board). Only the fields provided are changed; the rest keep the schedule's
* existing snapshot (falling back to the live global config for legacy rows).
* The window must not have opened yet — an OPEN/past schedule stays frozen so
* customers keep the times they were shown. windowOpensAt/ClosesAt are
* re-derived from the merged rule, and the snapshot is updated so the board
* draws the new cycles.
*/
async updateScheduleWindowRule(
id: string,
dto: UpdateScheduleWindowRuleDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (schedule.windowPhase !== 'PRE_WINDOW') {
throw new BadRequestException(
'Booking window settings can only be changed before the window opens ' +
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
);
}
const now = new Date();
if (!schedule.scheduledDepartureDate || schedule.scheduledDepartureDate <= now) {
throw new BadRequestException(
'This schedule has already departed or has no departure date.',
);
}
// Merge the override onto the schedule's current effective rule (its snapshot,
// or the live config where a legacy row has no snapshot).
const liveCfg = await this.getWindowConfig();
const merged: BookingWindowConfig = {
importWindowLeadDays:
dto.importWindowLeadDays ??
schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays,
exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours,
windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour:
dto.windowCloseHour ?? schedule.ruleWindowCloseHour ?? liveCfg.windowCloseHour,
windowDurationHours:
dto.windowDurationHours ??
(schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: liveCfg.windowDurationHours),
// The reopen gap is doc review + payment; keep the config values unless the
// override changes them, so the derived snapshot delay stays consistent.
docReviewMinutes: dto.docReviewMinutes ?? liveCfg.docReviewMinutes,
paymentWindowMinutes: dto.paymentWindowMinutes ?? liveCfg.paymentWindowMinutes,
reopenDelayMinutes: liveCfg.reopenDelayMinutes,
};
// Same-day, 24-hour, and overnight (openHour > closeHour) desks are all valid
// — officeHoursOpen resolves each, so no close-vs-open ordering guard here.
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
...windowRuleSnapshot(merged),
});
this.logger.log(
`Booking-window rule overridden for schedule ${id} — reopens ${times.windowOpensAt.toISOString()}`,
);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Reschedule ONE train's departure date (staff action on the ops board). Only
* allowed while the booking window has not opened yet — an OPEN/past schedule
* stays frozen so customers keep the times they were shown. The new date must
* still leave room for the booking lead window before departure (same floor as
* schedule creation); INTERCITY/DOMESTIC uses the import lead. The window
* open/close times are re-derived from the schedule's existing rule snapshot.
*/
async updateScheduleDate(
id: string,
dto: UpdateScheduleDateDto,
): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findById(id);
if (!schedule) {
throw new NotFoundException(`Train schedule ${id} not found`);
}
if (schedule.windowPhase !== 'PRE_WINDOW') {
throw new BadRequestException(
'The departure date can only be changed before the booking window opens ' +
`(this schedule is "${schedule.windowPhase ?? 'not window-managed'}").`,
);
}
const now = new Date();
const departure = new Date(dto.scheduleDate);
if (Number.isNaN(departure.getTime())) {
throw new BadRequestException('Invalid departure date.');
}
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT days;
// EXPORT lead is in hours. Mirrors the create-schedule check.
const windowCfg = await this.getWindowConfig();
const earliest = earliestSchedulableDeparture(
schedule.direction,
windowCfg,
now,
);
if (departure.getTime() < earliest.getTime()) {
const detail =
schedule.direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${schedule.direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()}).`,
);
}
// Re-derive the window from the schedule's own rule snapshot (falling back to
// the live config where a legacy row has no snapshot) against the new date.
const merged = effectiveWindowConfig(schedule, windowCfg);
const times =
schedule.direction === 'EXPORT'
? computeExportWindowTimes(departure, merged)
: computeImportWindowTimes(departure, merged, now);
await this.dataSource.getRepository(TrainSchedule).update(id, {
scheduledDepartureDate: departure,
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
});
this.logger.log(
`Departure date changed for schedule ${id}${departure.toISOString()} ` +
`(window reopens ${times.windowOpensAt.toISOString()})`,
);
const fresh = await this.trainSchedulesRepository.findById(id);
return fresh ?? schedule;
}
/**
* Re-derive windowOpensAt/windowClosesAt for schedules whose booking window has
* not opened yet (windowPhase === 'PRE_WINDOW', still Draft/Scheduled, departure
@@ -330,11 +550,7 @@ export class TrainSchedulingService {
await repo.update(s.id, {
windowOpensAt: times.windowOpensAt,
windowClosesAt: times.windowClosesAt,
ruleWindowOpenHour: cfg.windowOpenHour,
ruleWindowDurationHours: cfg.windowDurationHours,
ruleReopenDelayMinutes: cfg.reopenDelayMinutes,
ruleImportWindowLeadDays: cfg.importWindowLeadDays,
ruleExportBookingLeadHours: cfg.exportBookingLeadHours,
...windowRuleSnapshot(cfg),
});
restamped += 1;
}
@@ -360,6 +576,7 @@ export class TrainSchedulingService {
importWindowLeadDays: num(row?.importWindowLeadDays, 3),
exportBookingLeadHours: num(row?.exportBookingLeadHours, 24),
windowOpenHour: num(row?.windowOpenHour, 8),
windowCloseHour: num(row?.windowCloseHour, 17),
windowDurationHours: num(row?.windowDurationHours, 3),
docReviewMinutes: num(row?.docReviewMinutes, 30),
paymentWindowMinutes: num(row?.paymentWindowMinutes, 60),
@@ -504,13 +721,7 @@ export class TrainSchedulingService {
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
// already-open schedule keeps this snapshot, and the batch board draws its
// windows from it rather than the live config.
const ruleSnapshot = {
ruleWindowOpenHour: windowCfg.windowOpenHour,
ruleWindowDurationHours: windowCfg.windowDurationHours,
ruleReopenDelayMinutes: windowCfg.reopenDelayMinutes,
ruleImportWindowLeadDays: windowCfg.importWindowLeadDays,
ruleExportBookingLeadHours: windowCfg.exportBookingLeadHours,
};
const ruleSnapshot = windowRuleSnapshot(windowCfg);
const windowFields =
direction === 'EXPORT'
? {
@@ -594,11 +805,49 @@ export class TrainSchedulingService {
const setLocomotives = this.locomotivesOfTrainSet(schedule.trainSet);
const limitLoco = minLocomotiveLimits(setLocomotives) ?? undefined;
const limits = await this.resolveTrainLimitConfig(previewDto, limitLoco);
// Callers that add bookings without hand-picking container slots (the
// workspace "Add from pool" button, re-adding a removed booking) send no
// containerPlacements. Auto-fill them the same way the batch engine does:
// preview the wagon plan first, then lay containers into the plan's slots.
// Without this the placement validator rejects container bookings outright
// ("Container placements are required for container bookings").
let containerPlacements = dto.containerPlacements;
if (!containerPlacements?.length) {
const preview = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
[],
false,
limits,
scheduleId,
);
const containerBookings = preview.bookings.filter(
(b) => b.freightType === 'CONTAINER',
);
if (containerBookings.length) {
const units = expandBookingContainerUnits(containerBookings);
const slots = getContainerSlotSequenceNos(preview.wagonPlan);
const generated = autoFillPlacements(units, slots);
const missing = findMissingContainerNumberIssues(units, generated);
if (missing.length) {
throw new BadRequestException({
message: `Booking validation failed: ${missing
.map((m) => m.issue)
.join('; ')}`,
violations: missing.map((m) => m.issue),
});
}
containerPlacements = generated;
}
}
const validation = await this.validateBookingsForScheduling(
previewDto,
freightType ?? null,
dto.forceAssign,
dto.containerPlacements,
containerPlacements,
true,
limits,
scheduleId,
@@ -694,7 +943,7 @@ export class TrainSchedulingService {
savedWagons,
wagonPlan,
bookings,
dto.containerPlacements ?? [],
containerPlacements ?? [],
);
for (const booking of bookings) {
@@ -810,32 +1059,34 @@ export class TrainSchedulingService {
}
private async runWarehouseArrivalAutomation(scheduleId: string) {
const [schedule]: Array<{
originCountry: string | null;
destinationCountry: string | null;
destinationCode: string | null;
destinationName: string | null;
}> = await this.dataSource.query(
`SELECT oy.country AS "originCountry",
dy.country AS "destinationCountry",
dy.code AS "destinationCode",
dy.name AS "destinationName"
FROM freight.train_schedules ts
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.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
const direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
// Runs after the arrival transaction has committed — must never throw, or a
// successfully arrived train reports a 500 and looks stuck to the operator.
let direction: string | undefined;
try {
const [schedule]: Array<{
originCountry: string | null;
destinationCountry: string | null;
destinationCode: string | null;
destinationName: string | null;
}> = await this.dataSource.query(
`SELECT oy.country AS "originCountry",
dy.country AS "destinationCountry",
dy.code AS "destinationCode",
dy.label AS "destinationName"
FROM freight.train_schedules ts
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.id = $1 AND ts.deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!schedule) return { status: 'SKIPPED', reason: 'Train schedule not found' };
direction = deriveTradeDirection(
{ country: schedule.originCountry },
{ country: schedule.destinationCountry },
);
if (direction === 'IMPORT') {
return {
direction,
@@ -958,6 +1209,50 @@ export class TrainSchedulingService {
return this.getImportLoadingBookings(scheduleId);
}
/**
* Flip loaded/unloaded on the schedule↔booking link from the workspace, for any
* direction (import/export/domestic). Distinct from unassign: the booking stays
* on its wagon; this only records whether cargo is physically loaded. Allowed
* only before dispatch — once the train is DISPATCHED/ARRIVED the on-arrival
* warehouse automation owns unload, so staff can no longer hand-edit the flag.
*/
async setBookingLoadingStatus(scheduleId: string, dto: UpdateImportLoadingStatusDto) {
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
if (!['DRAFT', 'SCHEDULED'].includes(schedule.status)) {
throw new BadRequestException(
`Loading status can only be changed before dispatch (schedule is ${schedule.status})`,
);
}
const [scheduleBookings, allocations] = await Promise.all([
this.trainScheduleBookingsRepository.findByScheduleId(scheduleId),
this.wagonBookingAllocationsRepository.findByScheduleId(scheduleId),
]);
const scheduledIds = new Set(scheduleBookings.map((sb) => sb.bookingId));
const allocatedIds = new Set(allocations.map((a) => a.bookingId));
// Only bookings that are on this train AND pinned to a wagon can be loaded —
// no direction/payment filter, staff load whatever is physically on the set.
const invalid = dto.bookingIds.filter(
(id) => !scheduledIds.has(id) || !allocatedIds.has(id),
);
if (invalid.length) {
throw new BadRequestException(
`Not allocated to a wagon on this schedule: ${invalid.join(', ')}`,
);
}
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
dto.bookingIds,
dto.loadingStatus,
);
return this.getTrainScheduleById(scheduleId);
}
async pinWagons(scheduleId: string, dto: PinWagonsDto) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
@@ -1170,12 +1465,47 @@ export class TrainSchedulingService {
notes: dto.notes ?? operation.notes ?? null,
});
await this.completeGatepassMilestoneForSchedule(scheduleId, securedAt);
console.log(
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
);
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Bridge write: also flips the legacy clearance-side GATEPASS_GRANTED
* milestone for every customs booking on this schedule, so contract/booking
* clearance views still reading that milestone (older deployed builds) see
* the gate pass as done. Drop once every clearance-api deployment reads
* ImportDjiboutiOperation.gatepassGrantedAt directly.
*/
private async completeGatepassMilestoneForSchedule(
scheduleId: string,
securedAt: Date,
): Promise<void> {
const bookings = await this.dataSource.getRepository(Booking).find({
where: { trainScheduleId: scheduleId, customsClearingEnabled: true },
});
if (bookings.length === 0) return;
const milestoneRepo = this.dataSource.getRepository(ClearanceMilestone);
const rows = await milestoneRepo.find({
where: {
bookingId: In(bookings.map((b) => b.id)),
milestoneCode: 'GATEPASS_GRANTED',
},
});
for (const row of rows) {
if (row.status === 'COMPLETED') continue;
row.status = 'COMPLETED';
row.triggeredAt = securedAt;
row.metadata = { ...(row.metadata ?? {}), gatepassAt: securedAt.toISOString() };
await milestoneRepo.save(row);
}
}
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
@@ -1205,13 +1535,41 @@ export class TrainSchedulingService {
return this.getImportDjiboutiOperation(schedule.id);
}
/**
* Confirm cargo is loaded on the train from the workspace, for any direction.
* For import-from-Djibouti trains this stamps the ImportDjiboutiOperation's
* loadedOnTrainAt (the flag dispatch checks) — gatepass must already be granted.
* For every other schedule there is no departure loading gate, so this is a
* success no-op and simply returns the current detail.
*/
async confirmScheduleLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
// Import-Djibouti trains gate dispatch on the operation's loadedOnTrainAt.
if (this.isImportDjiboutiSchedule(schedule)) {
await this.confirmImportLoadedOnTrain(scheduleId, dto);
}
// Confirming loading also marks every wagon-assigned booking LOADED, so the
// per-booking loading flag and the dispatch gate agree (otherwise the
// dispatch pre-check keeps reporting these bookings as unloaded).
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
if (wagonAssignedIds.size) {
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
scheduleId,
[...wagonAssignedIds],
LoadingStatus.Loaded,
);
}
return this.getTrainScheduleById(scheduleId);
}
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
this.assertImportDjiboutiGatepassGranted(operation);
if (!operation.loadedOnTrainAt) {
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
}
// Loading confirmation does not block departure (see assertImportDjiboutiMayDepart).
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
await this.dispatchSchedule(schedule.id);
@@ -1267,7 +1625,9 @@ export class TrainSchedulingService {
performedBy: 'DOCUMENT_GENERATION',
});
const html = this.buildImportLoadListHtml(loadList);
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
// Generic render — NOT the release-order fallback (would mislabel this as a
// gate-clearance / release order when Chromium is unavailable).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Import marshalling / load list');
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
return {
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1285,7 +1645,8 @@ export class TrainSchedulingService {
}
const html = this.buildExportLoadListHtml(schedule);
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
// Generic render — NOT the release-order fallback (see importLoadListDocument).
const buffer = await this.pdfDocuments.renderDocumentHtml(html, 'Export marshalling / load list');
const reference = schedule.trainNumber ?? schedule.id;
return {
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
@@ -1585,9 +1946,9 @@ export class TrainSchedulingService {
where: { trainScheduleId: schedule.id },
});
this.assertImportDjiboutiGatepassGranted(operation);
if (!operation?.loadedOnTrainAt) {
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
}
// Loading confirmation does NOT gate dispatch. Per-booking loading is
// tracking only and the loaded-on-train step is optional — a scheduled train
// dispatches without waiting on loading.
}
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
@@ -2051,7 +2412,9 @@ export class TrainSchedulingService {
await this.trainSchedulesRepository.updateStatus(
id,
TrainScheduleStatusEnum.Cancelled,
{},
// Retire the booking window so a canceled schedule never lingers as an
// "open window" in booking-window lists or the legacy batch fill.
{ bookingWindowStatus: 'CLOSED', windowPhase: 'DONE' },
manager,
);
if (schedule.trainSetId) {
@@ -2725,28 +3088,98 @@ export class TrainSchedulingService {
return violations;
}
/**
* Resolve the wagon type for a batch through the cargo-type / container-type
* `wagon_type_id` FK (replaces the former load-type string matching). Throws
* when the relevant type has no wagon type configured — scheduling is blocked
* until an admin assigns one on the cargo-type / container-type config screen.
*/
private async resolveWagonType(
freightType: 'CONTAINER' | 'BULK',
bookingIds: string[],
): Promise<WagonType> {
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
if (freightType === 'CONTAINER') {
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { code: getDefaultContainerWagonTypeCode(), isActive: true },
});
if (!wagonType) {
throw new NotFoundException(`Wagon type ${getDefaultContainerWagonTypeCode()} not found`);
// First container type present on the batch drives the container wagon
// type (matches the prior single-wagon-type-per-consist behavior).
const containerType = bookings
.flatMap((b) => b.bookingContainers ?? [])
.map((line) => line.containerType)
.find((ct): ct is NonNullable<typeof ct> => Boolean(ct));
if (!containerType) {
throw new BadRequestException('No container type found on the container booking(s)');
}
const wagonType = await this.loadWagonTypeForType(
containerType.wagonTypeId ?? null,
`Container type "${containerType.label ?? containerType.code}"`,
);
return wagonType;
}
const bookings = await this.bookingsRepository.findByIdsForScheduling(bookingIds);
const cargoCode = bookings[0]?.cargoType?.code ?? null;
const wagonTypes = await this.wagonTypesRepository.findAll({ where: { isActive: true } });
const picked = pickBulkWagonType(wagonTypes, cargoCode);
if (!picked) {
throw new NotFoundException('No suitable bulk wagon type found');
const cargoType = bookings.map((b) => b.cargoType).find((ct) => Boolean(ct));
if (!cargoType) {
throw new BadRequestException('No cargo type found on the bulk booking(s)');
}
return picked;
return this.loadWagonTypeForType(
cargoType.wagonTypeId ?? null,
`Cargo type "${cargoType.cargoTypeName ?? cargoType.code}"`,
);
}
/**
* Load an active wagon type by FK id, throwing a clear error when the id is
* unset (type not configured) or points at a missing/inactive wagon type.
*/
private async loadWagonTypeForType(
wagonTypeId: string | null,
typeLabel: string,
): Promise<WagonType> {
if (!wagonTypeId) {
throw new BadRequestException(
`${typeLabel} has no wagon type configured — set one on its configuration before scheduling.`,
);
}
const [wagonType] = await this.wagonTypesRepository.findAll({
where: { id: wagonTypeId, isActive: true },
});
if (!wagonType) {
throw new NotFoundException(
`${typeLabel} references wagon type ${wagonTypeId}, which was not found or is inactive.`,
);
}
return wagonType;
}
/**
* Soft wagon-type resolution for the customer-facing availability preview
* (getAvailableDaysForCargo). Reads the configured FK by cargo/container type;
* returns null (→ "no days") instead of throwing when nothing is configured,
* since this only estimates which days have wagons and creates no booking.
*/
private async resolveWagonTypeForPreview(
freightType: 'CONTAINER' | 'BULK',
cargoTypeCode: string | null,
): Promise<WagonType | null> {
if (freightType === 'BULK') {
if (!cargoTypeCode) return null;
const cargoType = await this.dataSource.getRepository(CargoType).findOne({
where: { code: cargoTypeCode },
relations: { wagonType: true },
});
return cargoType?.wagonType?.isActive ? cargoType.wagonType : null;
}
// Container preview: the input carries no specific container type, so use the
// wagon type of the first configured (active) container type.
const containerType = await this.dataSource
.getRepository(ContainerType)
.findOne({
where: { isActive: true, wagonTypeId: Not(IsNull()) },
relations: { wagonType: true },
order: { displayOrder: 'ASC' },
});
return containerType?.wagonType?.isActive ? containerType.wagonType : null;
}
private async persistTrainSetWagons(
@@ -3419,15 +3852,12 @@ export class TrainSchedulingService {
);
if (schedules.length === 0) return { days: [] };
const wagonTypes = await this.dataSource.getRepository(WagonType).find();
// Resolve the wagon type this cargo needs.
const requiredType =
input.freightType === 'BULK'
? pickBulkWagonType(wagonTypes, input.cargoTypeCode)
: wagonTypes.find(
(wt) => wt.code === getDefaultContainerWagonTypeCode() && wt.isActive,
);
// Resolve the wagon type this cargo needs via the cargo/container-type FK.
// Soft (customer availability preview): no days if unresolved, never throws.
const requiredType = await this.resolveWagonTypeForPreview(
input.freightType,
input.cargoTypeCode ?? null,
);
if (!requiredType) return { days: [] };
// How many wagons of that type the cargo needs.
@@ -3543,9 +3973,25 @@ export class TrainSchedulingService {
private async mapScheduleDetail(
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
) {
const allocationIds = (schedule.trainSet?.wagons ?? [])
.flatMap((w) => w.allocations ?? [])
.map((a) => a.id);
const allocations = (schedule.trainSet?.wagons ?? []).flatMap(
(w) => w.allocations ?? [],
);
const allocationIds = allocations.map((a) => a.id);
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
// Import-from-Djibouti trains can only dispatch once loading is confirmed
// (loadedOnTrainAt on the operation). Other directions have no departure
// loading gate, so the workspace shows the confirm button as already done.
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
let loadingConfirmed = !requiresLoadingConfirmation;
if (requiresLoadingConfirmation) {
const op = await this.dataSource
.getRepository(ImportDjiboutiOperation)
.findOne({ where: { trainScheduleId: schedule.id } });
loadingConfirmed = Boolean(op?.loadedOnTrainAt);
}
const windowCfg = await this.getWindowConfig();
const [containerItems, bulkLoads] = await Promise.all([
allocationIds.length
@@ -3578,6 +4024,8 @@ export class TrainSchedulingService {
freightType: this.resolveScheduleFreightType(schedule),
trainNumber: schedule.trainNumber ?? null,
direction: schedule.direction ?? null,
requiresLoadingConfirmation,
loadingConfirmed,
// Booking-window phase + phase deadlines drive the countdown timers in the
// operations workspace (display only — the window engine enforces them).
windowPhase: schedule.windowPhase ?? null,
@@ -3593,6 +4041,22 @@ export class TrainSchedulingService {
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt
? schedule.paymentPhaseEndsAt.toISOString()
: null,
// Per-schedule booking-window rule snapshot — powers the "Booking window
// settings" editor on the ops board (prefill + save one schedule's
// override). docReview/payment are not snapshotted per schedule (only their
// sum, as reopenDelayMinutes), so the editor prefills them from live config.
windowRule: {
windowOpenHour: schedule.ruleWindowOpenHour ?? null,
windowCloseHour: schedule.ruleWindowCloseHour ?? null,
windowDurationHours:
schedule.ruleWindowDurationHours != null
? Number(schedule.ruleWindowDurationHours)
: null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes,
},
route: schedule.route
? { id: schedule.route.id, name: formatRouteLabel(schedule.route) }
: null,
@@ -3686,6 +4150,11 @@ export class TrainSchedulingService {
status: sb.booking?.status ?? null,
schedulingStatus: sb.booking?.schedulingStatus ?? null,
freightType: sb.booking?.freightType ?? null,
// Loaded/unloaded is tracked on the schedule↔booking link, not the
// booking itself — staff flip it per booking in the workspace before
// dispatch. Defaults UNLOADED for links written before the column.
loadingStatus: sb.loadingStatus ?? LoadingStatus.Unloaded,
wagonAssigned: allocatedBookingIds.has(sb.booking?.id ?? sb.bookingId),
})) ?? [],
};
}

View File

@@ -1,49 +0,0 @@
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
const CARGO_CODE_TO_WAGON_TYPE: Record<string, string> = {
COFFEE: 'KW2',
GRAIN: 'KW2',
WHEAT: 'KW2',
SORGHUM: 'KW2',
CORN: 'KW2',
FERTILIZER: 'PW2',
SUGAR: 'PW2',
COAL: 'KW3',
STEEL: 'CW3',
ORE: 'CW3',
};
const DEFAULT_BULK_WAGON_TYPE = 'CW3';
const DEFAULT_CONTAINER_WAGON_TYPE = 'NW5';
/**
* Resolve wagon type code from cargo type code for bulk freight.
*/
export function resolveBulkWagonTypeCode(cargoTypeCode?: string | null): string {
if (!cargoTypeCode) return DEFAULT_BULK_WAGON_TYPE;
const normalized = cargoTypeCode.trim().toUpperCase();
return CARGO_CODE_TO_WAGON_TYPE[normalized] ?? DEFAULT_BULK_WAGON_TYPE;
}
/**
* Pick the best matching wagon type entity for bulk cargo.
*/
export function pickBulkWagonType(
wagonTypes: WagonType[],
cargoTypeCode?: string | null,
): WagonType | undefined {
const preferredCode = resolveBulkWagonTypeCode(cargoTypeCode);
const direct = wagonTypes.find((wt) => wt.code === preferredCode && wt.isActive);
if (direct) return direct;
return wagonTypes.find(
(wt) =>
wt.isActive &&
!wt.supportsContainer &&
wt.code !== DEFAULT_CONTAINER_WAGON_TYPE,
);
}
export function getDefaultContainerWagonTypeCode(): string {
return DEFAULT_CONTAINER_WAGON_TYPE;
}

View File

@@ -920,6 +920,23 @@ export class WarehouseInventoryService {
}),
);
// Receiving the booking flags every container unit as received into the
// port (self-haul export: the delivering truck's goods are now in) so
// staff can raise the per-container GRN over what's received.
await manager.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND bc.deleted_at IS NULL
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = false`,
[bookingId],
);
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
@@ -1774,6 +1791,26 @@ export class WarehouseInventoryService {
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
// Per-container receive: flag this container's unit as received into the
// port so staff can raise the GRN over what's received.
if (dto.bookingId && dto.containerId) {
await manager.query(
`UPDATE freight.booking_container_units bcu
SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW()
FROM freight.booking_containers bc, freight.containers cont
WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1
AND bc.deleted_at IS NULL
AND cont.id = $2
AND cont.container_number = bcu.container_number
AND bcu.deleted_at IS NULL
AND bcu.received_to_port = false`,
[dto.bookingId, dto.containerId],
);
}
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
@@ -2068,6 +2105,29 @@ export class WarehouseInventoryService {
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
});
if (!isTruckLeaving && item.bookingId) {
// Per-truck arrival: mark the customer truck carrying THIS item's
// container as arrived (matched via the physical container number).
if (item.containerId) {
await manager.query(
`UPDATE freight.customer_truck_assignments a
SET arrived_at = COALESCE(a.arrived_at, NOW()), updated_at = NOW()
FROM freight.customer_truck_containers c
JOIN freight.containers cont ON cont.container_number = c.container_number
WHERE c.assignment_id = a.id
AND c.deleted_at IS NULL
AND c.booking_id = $1
AND cont.id = $2
AND a.arrived_at IS NULL
AND a.deleted_at IS NULL`,
[item.bookingId, item.containerId],
);
// NB: import arrival changes nothing on the goods — received_to_port is
// an EXPORT concept (set when a truck delivers into the port). Import
// load + weight are captured on truck departure, not arrival.
}
// Booking-level flag stamped on the FIRST truck arrival. The import
// handover is signed ONCE (before the first truck leaves), even though
// trucks pick up per-container — COALESCE keeps the first timestamp.
await manager.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
@@ -2147,6 +2207,48 @@ export class WarehouseInventoryService {
}
await this.invoices.assertClearanceAllowed(id);
// Import self-haul: the exit paper names the pickup truck + all containers it
// carries, so gate staff can verify the goods leaving on that truck.
let truck: {
plateNumber: string;
driverName: string;
truckType: string;
containerNumbers: string;
truckWeightTons: string | number | null;
grossWeightKg: string | number | null;
departedAt: string | null;
} | null = null;
if (row?.tradeDirection === 'IMPORT' && row?.containerNumber && row?.bookingId) {
const [truckRow] = await this.dataSource.query(
`SELECT a.plate_number AS "plateNumber",
a.driver_name AS "driverName",
a.truck_type AS "truckType",
a.gross_weight_kg AS "grossWeightKg",
a.departed_at AS "departedAt",
string_agg(DISTINCT c2.container_number, ', ' ORDER BY c2.container_number) AS "containerNumbers",
COALESCE((
SELECT SUM(bcu.vgm_tons)
FROM freight.customer_truck_containers cc
JOIN freight.booking_container_units bcu
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
JOIN freight.booking_containers bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
AND bc.booking_id = c.booking_id
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
), 0) AS "truckWeightTons"
FROM freight.customer_truck_containers c
JOIN freight.customer_truck_assignments a
ON a.id = c.assignment_id AND a.deleted_at IS NULL
JOIN freight.customer_truck_containers c2
ON c2.assignment_id = a.id AND c2.deleted_at IS NULL
WHERE c.booking_id = $1 AND c.container_number = $2 AND c.deleted_at IS NULL
GROUP BY a.id, a.plate_number, a.driver_name, a.truck_type, c.booking_id
LIMIT 1`,
[row.bookingId, row.containerNumber],
);
truck = truckRow ?? null;
}
const bookingReference = row?.bookingReference || 'N/A';
const reference =
row?.releaseOrderReference ||
@@ -2170,6 +2272,17 @@ export class WarehouseInventoryService {
inventoryStatus: row?.status ?? null,
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
truckPlateNumber: truck?.plateNumber ?? null,
truckDriverName: truck?.driverName ?? null,
truckType: truck?.truckType ?? null,
truckGateOut: truck?.departedAt ?? null,
// Prefer the weighed gross captured on departure; fall back to the summed
// container VGM when the truck hasn't been weighed yet.
truckWeightKg: truck
? Number(truck.grossWeightKg ?? 0) > 0
? Number(truck.grossWeightKg)
: Number(truck.truckWeightTons ?? 0) * 1000
: null,
});
return {
@@ -3099,6 +3212,11 @@ export class WarehouseInventoryService {
inventoryStatus: string | null;
clearanceStatus: string;
exitInspectionSummary?: string | null;
truckPlateNumber?: string | null;
truckDriverName?: string | null;
truckType?: string | null;
truckGateOut?: string | null;
truckWeightKg?: number | null;
}): string {
const esc = (value: unknown) =>
String(value ?? '-')
@@ -3123,12 +3241,37 @@ export class WarehouseInventoryService {
['Container Number', data.containerNumber],
['Cargo / Goods Description', data.cargoDescription],
['Quantity', data.quantity],
['Declared Weight', `${data.weight.toLocaleString()} kg`],
[
data.truckPlateNumber ? 'Gross Weight (Loaded on Truck)' : 'Declared Weight',
`${(data.truckPlateNumber && data.truckWeightKg
? data.truckWeightKg
: data.weight
).toLocaleString()} kg`,
],
['Warehouse', data.warehouse],
['Yard', data.yard],
['Zone', data.zone],
['Inventory Status', data.inventoryStatus],
['Clearance Status', data.clearanceStatus],
...(data.truckPlateNumber
? ([
['Pickup Truck Plate', data.truckPlateNumber],
['Truck Driver', data.truckDriverName],
['Truck Type', data.truckType],
[
'Gate-Out Time',
data.truckGateOut
? new Date(data.truckGateOut).toLocaleString('en-GB', {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
: null,
],
] as [string, string | null][])
: []),
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
];

View File

@@ -20,6 +20,18 @@ export class WarehouseReleaseDocumentService {
});
}
/**
* Render arbitrary document HTML to PDF via the shared renderer WITHOUT the
* release-order fallback. Non-release documents (e.g. the import/export
* marshalling load list) must use this so a Chromium-less fallback degrades to
* a plain-text dump of *their own* content — instead of masquerading as a
* "Warehouse Gate Clearance / Release Order", which the release-specific
* fallback would otherwise draw regardless of the input HTML.
*/
renderDocumentHtml(html: string, label = 'Document'): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, { label });
}
private htmlToBasicPdfBuffer(html: string): Buffer {
const doc = this.extractReleaseDocument(html);
const body: string[] = [