mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -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 1–2 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",
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -62,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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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 1–2 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 1–2 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());
|
||||
}
|
||||
}
|
||||
@@ -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 1–2 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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 1–2 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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -146,7 +146,11 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
// 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,
|
||||
@@ -180,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') {
|
||||
@@ -825,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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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')}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -244,9 +244,10 @@ 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.
|
||||
* 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);
|
||||
@@ -269,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(
|
||||
@@ -287,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.');
|
||||
}
|
||||
@@ -303,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,
|
||||
@@ -337,10 +346,9 @@ export class GlOperationsService {
|
||||
'The transport document must be uploaded before T1 can be closed.',
|
||||
);
|
||||
}
|
||||
const gatepass = await this.gatepassForBooking(bookingId);
|
||||
if (!gatepass.granted) {
|
||||
if (!state.trainArrivedAt) {
|
||||
throw new BadRequestException(
|
||||
'Secure the Djibouti gate pass on the train schedule before closing T1.',
|
||||
'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.
|
||||
|
||||
@@ -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",
|
||||
}));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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:00–17: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:00–17: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:00–11:00; reopen +90m → cycle 2 opens 12:30 EAT
|
||||
// cycle 1: 08:00–11: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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,6 +82,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
importWindowLeadDays: 3,
|
||||
exportBookingLeadHours: 24,
|
||||
windowOpenHour: 8,
|
||||
windowCloseHour: 17,
|
||||
windowDurationHours: 3,
|
||||
docReviewMinutes: 30,
|
||||
paymentWindowMinutes: 60,
|
||||
|
||||
@@ -736,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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -64,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,
|
||||
@@ -124,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'
|
||||
@@ -269,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
|
||||
@@ -298,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
|
||||
@@ -329,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;
|
||||
}
|
||||
@@ -359,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),
|
||||
@@ -503,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'
|
||||
? {
|
||||
@@ -593,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,
|
||||
@@ -693,7 +943,7 @@ export class TrainSchedulingService {
|
||||
savedWagons,
|
||||
wagonPlan,
|
||||
bookings,
|
||||
dto.containerPlacements ?? [],
|
||||
containerPlacements ?? [],
|
||||
);
|
||||
|
||||
for (const booking of bookings) {
|
||||
@@ -809,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,
|
||||
@@ -957,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) {
|
||||
@@ -1239,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);
|
||||
@@ -1622,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> {
|
||||
@@ -3649,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
|
||||
@@ -3684,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,
|
||||
@@ -3699,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,
|
||||
@@ -3792,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),
|
||||
})) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]] : []),
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user