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

This commit is contained in:
Nathnael
2026-07-06 13:46:27 +00:00
62 changed files with 2620 additions and 129 deletions

View File

@@ -1,20 +1,33 @@
import type { ScheduleTradeDirection } from '@edr/types'; import { YardCountry, type ScheduleTradeDirection } from '@edr/types';
type YardLike = { country?: string | null }; type YardLike = { country?: string | null };
/** Derive booking/schedule trade direction from origin and destination yard countries. */ /**
* Derive trade direction from origin and destination yard countries.
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT, same country =
* DOMESTIC (shown as "Intercity"; scheduling/contracts reject it for now).
* Comparison is strict against the YardCountry enum values the yards table is
* constrained to; the trim/case fold only shields legacy rows.
*/
export function deriveTradeDirection( export function deriveTradeDirection(
originYard: YardLike, originYard: YardLike,
destinationYard: YardLike, destinationYard: YardLike,
): ScheduleTradeDirection { ): ScheduleTradeDirection {
const originCountry = originYard.country?.trim().toLowerCase(); const origin = normalizeCountry(originYard.country);
const destinationCountry = destinationYard.country?.trim().toLowerCase(); const destination = normalizeCountry(destinationYard.country);
if (originCountry === 'djibouti') { if (origin === YardCountry.DJIBOUTI && destination === YardCountry.ETHIOPIA) {
return 'IMPORT'; return 'IMPORT';
} }
if (destinationCountry === 'djibouti' && originCountry !== 'djibouti') { if (origin === YardCountry.ETHIOPIA && destination === YardCountry.DJIBOUTI) {
return 'EXPORT'; return 'EXPORT';
} }
return 'DOMESTIC'; return 'DOMESTIC';
} }
function normalizeCountry(country: string | null | undefined): YardCountry | null {
const folded = country?.trim().toLowerCase();
if (folded === YardCountry.ETHIOPIA.toLowerCase()) return YardCountry.ETHIOPIA;
if (folded === YardCountry.DJIBOUTI.toLowerCase()) return YardCountry.DJIBOUTI;
return null;
}

View File

@@ -0,0 +1,47 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Structured import handover records. Replaces the ad-hoc handover notes so a
* booking can carry one handover (single truck) or several (one per truck when
* multiple trucks are used). Timing differs by mile type:
* - SELF_HAUL: generated on first truck arrival, signed before the truck leaves.
* - EDR_LAST_MILE: generated at delivery (after exit); signed on delivery.
*/
export class AddBookingHandovers1980000000000 implements MigrationInterface {
name = 'AddBookingHandovers1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.booking_handovers (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
booking_id uuid NOT NULL REFERENCES freight.bookings(id) ON DELETE CASCADE,
truck_assignment_id uuid REFERENCES freight.customer_truck_assignments(id) ON DELETE SET NULL,
truck_plate varchar(32),
mile_type varchar(20) NOT NULL,
reference varchar(100) NOT NULL,
generated_at timestamptz NOT NULL DEFAULT now(),
signed_at timestamptz,
signed_by_user_id uuid,
delivered_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_booking_handovers_booking" ON freight.booking_handovers (booking_id);`,
);
// At most one live handover per (booking, customer truck). EDR trucks (which
// aren't customer_truck_assignments) and per-booking handovers are de-duped
// in the service, since a NULL truck_assignment_id can't be uniquely indexed.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS "UQ_booking_handovers_booking_truck"
ON freight.booking_handovers (booking_id, truck_assignment_id)
WHERE deleted_at IS NULL AND truck_assignment_id IS NOT NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.booking_handovers;`);
}
}

View File

@@ -0,0 +1,69 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Yard country becomes a two-value enum (Ethiopia | Djibouti) and every route
* freezes its trade direction from the yard countries:
* Ethiopia → Djibouti = EXPORT, Djibouti → Ethiopia = IMPORT,
* same country = DOMESTIC (shown as "Intercity"; disabled for scheduling
* and contracts for now).
*
* Existing yard rows are normalized case-insensitively; anything mentioning
* Djibouti maps there, everything else maps to Ethiopia (the line only serves
* these two countries). A CHECK constraint keeps future writes honest.
*/
export class YardCountryEnumAndRouteDirection1980000000000 implements MigrationInterface {
name = 'YardCountryEnumAndRouteDirection1980000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.yards
SET country = CASE
WHEN lower(trim(country)) LIKE '%djib%' THEN 'Djibouti'
ELSE 'Ethiopia'
END
`);
await queryRunner.query(`
ALTER TABLE freight.yards
DROP CONSTRAINT IF EXISTS chk_yards_country,
ADD CONSTRAINT chk_yards_country CHECK (country IN ('Ethiopia', 'Djibouti'))
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
UPDATE freight.routes r
SET direction = CASE
WHEN o.country = 'Djibouti' AND d.country = 'Ethiopia' THEN 'IMPORT'
WHEN o.country = 'Ethiopia' AND d.country = 'Djibouti' THEN 'EXPORT'
ELSE 'DOMESTIC'
END
FROM freight.yards o, freight.yards d
WHERE o.id = r.origin_yard_id
AND d.id = r.destination_yard_id
`);
// Orphan origin/destination (deleted yard) — no way to classify; park as
// DOMESTIC, which is blocked everywhere, so nothing can schedule on it.
await queryRunner.query(`
UPDATE freight.routes SET direction = 'DOMESTIC' WHERE direction IS NULL
`);
await queryRunner.query(`
ALTER TABLE freight.routes
ALTER COLUMN direction SET NOT NULL,
DROP CONSTRAINT IF EXISTS chk_routes_direction,
ADD CONSTRAINT chk_routes_direction CHECK (direction IN ('IMPORT', 'EXPORT', 'DOMESTIC'))
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.routes
DROP CONSTRAINT IF EXISTS chk_routes_direction,
DROP COLUMN IF EXISTS direction
`);
await queryRunner.query(`
ALTER TABLE freight.yards DROP CONSTRAINT IF EXISTS chk_yards_country
`);
}
}

View File

@@ -64,6 +64,7 @@ import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto'; import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { AddCustomerTruckDto } from './dto/add-customer-truck.dto'; import { AddCustomerTruckDto } from './dto/add-customer-truck.dto';
import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto'; import { DepartCustomerTruckDto } from './dto/depart-customer-truck.dto';
import { LoadCustomerTruckDto } from './dto/load-customer-truck.dto';
import { CustomerTruckService } from './customer-truck.service'; import { CustomerTruckService } from './customer-truck.service';
import { GenerateGrnDto } from './dto/generate-grn.dto'; import { GenerateGrnDto } from './dto/generate-grn.dto';
import { ContainerReceiptService } from './container-receipt.service'; import { ContainerReceiptService } from './container-receipt.service';
@@ -358,6 +359,33 @@ export class BookingsController {
return this.customerTruckService.removeTruck(id, assignmentId); return this.customerTruckService.removeTruck(id, assignmentId);
} }
@Get(':id/customer-trucks/loadable-containers')
@ApiOperation({ summary: 'Booking containers not yet loaded onto a truck' })
async loadableContainers(
@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.getLoadableContainers(id);
}
@Post(':id/customer-trucks/:assignmentId/load')
@ApiOperation({ summary: 'Truck_dispatch: load selected containers onto a truck (staff)' })
async loadCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Body() dto: LoadCustomerTruckDto,
@CurrentUser() user: TCurrentUser,
) {
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
throw new ForbiddenException('Only warehouse staff can load a truck');
}
return this.customerTruckService.loadTruck(id, assignmentId, dto);
}
@Post(':id/customer-trucks/:assignmentId/depart') @Post(':id/customer-trucks/:assignmentId/depart')
@ApiOperation({ @ApiOperation({
summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)', summary: 'Register an import truck leaving: containers loaded + weighed gross (staff)',

View File

@@ -176,6 +176,44 @@ export class BookingsService {
} }
/** Resolve trade direction from yard countries; reject client mismatch. */ /** Resolve trade direction from yard countries; reject client mismatch. */
/**
* An intercity corridor is valid when both yards are Ethiopian and at least
* one non-retired route passes the origin strictly before the destination in
* its milestone order — that is the corridor an import/export train can
* serve the booking on.
*/
private async assertIntercityCorridorExists(
originYardId: string,
destinationYardId: string,
): Promise<void> {
const yards = await this.dataSource.getRepository(Yard).find({
where: { id: In([originYardId, destinationYardId]) },
});
if (yards.some((y) => y.country !== 'Ethiopia')) {
throw new BadRequestException(
'Intercity bookings only run between Ethiopian yards',
);
}
const rows: Array<{ id: string }> = await this.dataSource.query(
`SELECT r.id
FROM freight.routes r
JOIN freight.route_milestones mo
ON mo.route_id = r.id AND mo.yard_id = $1 AND mo.deleted_at IS NULL
JOIN freight.route_milestones md
ON md.route_id = r.id AND md.yard_id = $2 AND md.deleted_at IS NULL
WHERE mo.sequence_no < md.sequence_no
AND r.status = 'AVAILABLE'
AND r.deleted_at IS NULL
LIMIT 1`,
[originYardId, destinationYardId],
);
if (rows.length === 0) {
throw new BadRequestException(
'No route passes through this origin and destination in order — intercity service is not available on this corridor',
);
}
}
private async resolveTradeDirectionForBooking( private async resolveTradeDirectionForBooking(
originYardId: string, originYardId: string,
destinationYardId: string, destinationYardId: string,
@@ -607,6 +645,23 @@ export class BookingsService {
dto.tradeDirection, dto.tradeDirection,
); );
// Intercity (DOMESTIC) bookings never get their own train — they ride on a
// passing import/export train, so there is no booking window and no date to
// pin. All we require at creation is that the corridor actually lies on a
// route (origin before destination in some route's milestone order); staff
// accept the booking onto a concrete train at finalize time.
if (tradeDirection === 'DOMESTIC') {
if (dto.scheduledDate || dto.trainScheduleId) {
throw new BadRequestException(
'Intercity bookings cannot pin a date or schedule — staff assign them to a passing train later',
);
}
await this.assertIntercityCorridorExists(
dto.originYardId,
dto.destinationYardId,
);
}
// Stamp the operational profile this booking belongs to (importer/exporter) // Stamp the operational profile this booking belongs to (importer/exporter)
// so the customer portal can scope lists/KPIs to the active mode. Best-effort // so the customer portal can scope lists/KPIs to the active mode. Best-effort
// for non-government bookings with a resolved company; never blocks creation. // for non-government bookings with a resolved company; never blocks creation.

View File

@@ -38,7 +38,7 @@ export class ContainerReceiptService {
SET received_to_port = true, SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()), received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW() updated_at = NOW()
FROM freight.booking_containers bc, FROM freight.booking_container bc,
freight.customer_truck_containers ctc freight.customer_truck_containers ctc
WHERE bc.id = bcu.booking_container_id WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1 AND bc.booking_id = $1
@@ -60,7 +60,7 @@ export class ContainerReceiptService {
bcu.received_at AS "receivedAt", bcu.received_at AS "receivedAt",
bcu.grn_number AS "grnNumber" bcu.grn_number AS "grnNumber"
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL AND bcu.deleted_at IS NULL
@@ -92,7 +92,7 @@ export class ContainerReceiptService {
const pending: ReceivedUnitRow[] = await manager.query( const pending: ReceivedUnitRow[] = await manager.query(
`SELECT bcu.id, bcu.container_number AS "containerNumber" `SELECT bcu.id, bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 WHERE bc.booking_id = $1
AND bcu.deleted_at IS NULL AND bcu.deleted_at IS NULL
@@ -109,7 +109,7 @@ export class ContainerReceiptService {
const [{ batches }]: Array<{ batches: string }> = await manager.query( const [{ batches }]: Array<{ batches: string }> = await manager.query(
`SELECT COUNT(DISTINCT bcu.grn_number) AS batches `SELECT COUNT(DISTINCT bcu.grn_number) AS batches
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL 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`, WHERE bc.booking_id = $1 AND bcu.grn_number IS NOT NULL AND bcu.deleted_at IS NULL`,
[bookingId], [bookingId],
@@ -129,7 +129,7 @@ export class ContainerReceiptService {
const [{ remaining }]: Array<{ remaining: string }> = await manager.query( const [{ remaining }]: Array<{ remaining: string }> = await manager.query(
`SELECT COUNT(*) AS remaining `SELECT COUNT(*) AS remaining
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL 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`, WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL AND bcu.grn_number IS NULL`,
[bookingId], [bookingId],

View File

@@ -198,6 +198,87 @@ export class CustomerTruckService {
return this.listTrucks(bookingId); return this.listTrucks(bookingId);
} }
/** Booking container numbers not yet loaded onto any truck. */
async getLoadableContainers(bookingId: string): Promise<string[]> {
const [all, assigned] = await Promise.all([
this.bookingContainerNumbers(bookingId),
this.assignedContainerNumbers(bookingId),
]);
const taken = new Set(assigned);
return all.filter((n) => !taken.has(n));
}
/**
* Truck_dispatch (load): assign the selected containers to a truck after it has
* arrived, and set a provisional gross weight from their VGM. The truck is
* weighed for real on departure. Locked once the truck has left.
*/
async loadTruck(
bookingId: string,
assignmentId: string,
dto: { containerNumbers: string[] },
): Promise<CustomerTruckAssignment[]> {
await this.loadBookingGuard(bookingId);
const assignment = await this.assignments.findByIdWithContainers(assignmentId);
if (!assignment || assignment.bookingId !== bookingId) {
throw new NotFoundException('Truck assignment not found for this booking');
}
if (assignment.departedAt) {
throw new ConflictException('This truck has already left — its load is locked');
}
const requested = (dto.containerNumbers ?? []).map((n) => n.trim().toUpperCase());
if (!requested.length) {
throw new BadRequestException('Select at least one container to load onto the truck');
}
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`);
}
}
const grossKg = await this.vgmKgForContainers(bookingId, requested);
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(CustomerTruckContainer).softDelete({ assignmentId });
await manager.getRepository(CustomerTruckContainer).save(
requested.map((containerNumber) =>
manager.getRepository(CustomerTruckContainer).create({
assignmentId,
bookingId,
containerNumber,
}),
),
);
// Provisional gross from the loaded containers' VGM — overridden by the
// weighed gross on departure.
await manager.getRepository(CustomerTruckAssignment).update(assignmentId, {
grossWeightKg: grossKg,
});
});
return this.listTrucks(bookingId);
}
private async vgmKgForContainers(bookingId: string, numbers: string[]): Promise<number> {
const [row]: Array<{ kg: string }> = await this.dataSource.query(
`SELECT COALESCE(SUM(bcu.vgm_tons), 0) * 1000 AS kg
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1
AND bcu.container_number = ANY($2::varchar[])
AND bcu.deleted_at IS NULL`,
[bookingId, numbers],
);
return Number(row?.kg ?? 0);
}
/** /**
* Mark the truck carrying `containerNumber` as arrived. Called by the warehouse * Mark the truck carrying `containerNumber` as arrived. Called by the warehouse
* receive flow. When every truck on the booking has arrived, the booking-level * receive flow. When every truck on the booking has arrived, the booking-level
@@ -288,7 +369,7 @@ export class CustomerTruckService {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query( const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber" `SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu FROM freight.booking_container_units bcu
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`, WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId], [bookingId],

View File

@@ -0,0 +1,13 @@
import { ArrayMinSize, ArrayUnique, IsArray, Matches } from 'class-validator';
/** Containers loaded onto a truck at Truck_dispatch (after arrival, before it leaves). */
export class LoadCustomerTruckDto {
@IsArray()
@ArrayMinSize(1)
@ArrayUnique()
@Matches(/^[A-Z]{4}\d{7}$/, {
each: true,
message: 'each container number must match ISO container format, e.g. ABCD1234567',
})
containerNumbers!: string[];
}

View File

@@ -119,12 +119,27 @@ export class ContractBookingService {
const generalCustoms = const generalCustoms =
contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled); contract.contractKind === 'GENERAL' && Boolean(contract.customsClearingEnabled);
// Intercity (DOMESTIC) bookings ride on a passing import/export train:
// there is no window and no date — staff accept them onto a train at
// finalize time, so both the window gate and scheduledDate are skipped.
const isIntercity = contract.tradeDirection === 'DOMESTIC';
if (isIntercity && dto.scheduledDate) {
throw new BadRequestException(
'Intercity bookings do not pick a date — staff assign them to a passing train',
);
}
// Every other direction keeps the binding shipment day (the DTO field went
// optional only for intercity).
if (!isIntercity && !dto.scheduledDate) {
throw new BadRequestException('A binding shipment day is required');
}
// Booking-window gate (config-driven): an operations booking may only be // Booking-window gate (config-driven): an operations booking may only be
// created while the route's booking window is open — import: the day's window // created while the route's booking window is open — import: the day's window
// (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours); // (windowOpenHour EAT, importWindowLeadDays before departure, windowDurationHours);
// export: within exportBookingLeadHours of departure. Customs Path B bookings // export: within exportBookingLeadHours of departure. Customs Path B bookings
// enter clearance first and are scheduled later, so they are not gated here. // enter clearance first and are scheduled later, so they are not gated here.
if (!generalCustoms) { if (!generalCustoms && !isIntercity) {
await this.trainSchedulingService.assertBookingWindowOpen({ await this.trainSchedulingService.assertBookingWindowOpen({
originYardId: route?.originYardId ?? null, originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null, destinationYardId: route?.destinationYardId ?? null,

View File

@@ -8,10 +8,14 @@ import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { insertWithGeneratedReference } from '@edr/api-common'; import { insertWithGeneratedReference } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { CompaniesService } from '../companies/companies.service'; import { CompaniesService } from '../companies/companies.service';
import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity'; import { CompanyProfile, ProfileType } from '../companies/entities/company-profile.entity';
import { CompanyStatus } from '../companies/entities/company.entity'; import { CompanyStatus } from '../companies/entities/company.entity';
import { ServiceType } from '../rule-engine/entities/service-type.entity'; import { ServiceType } from '../rule-engine/entities/service-type.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { FilesService } from '../files/files.service'; import { FilesService } from '../files/files.service';
import { MinioService } from '../minio/minio.service'; import { MinioService } from '../minio/minio.service';
import { ContractsRepository } from './contracts.repository'; import { ContractsRepository } from './contracts.repository';
@@ -110,6 +114,49 @@ export class ContractsService {
} }
} }
/**
* Every route must match the contract's declared trade direction as derived
* from the yard countries (IMPORT = DJ→ET, EXPORT = ET→DJ, DOMESTIC =
* intercity). Intercity is Ethiopian-domestic only: both yards must be in
* Ethiopia — a Djibouti-internal pair is rejected. Direction mismatches
* (e.g. an export lane on an import contract) are rejected for every kind.
*/
private async assertRoutesMatchDirection(
tradeDirection: string,
routes: CreateContractDto['routes'],
): Promise<void> {
const yardIds = [
...new Set(routes.flatMap((r) => [r.originYardId, r.destinationYardId])),
];
const yards = await this.dataSource
.getRepository(Yard)
.find({ where: yardIds.map((id) => ({ id })) });
const yardById = new Map(yards.map((y) => [y.id, y]));
for (const route of routes) {
const origin = yardById.get(route.originYardId);
const destination = yardById.get(route.destinationYardId);
if (!origin || !destination) {
throw new BadRequestException('Route references a yard that does not exist');
}
const derived = deriveTradeDirection(origin, destination);
if (derived !== tradeDirection) {
throw new BadRequestException(
`Route ${origin.label}${destination.label} is ${derived === 'DOMESTIC' ? 'an intercity' : `an ${derived.toLowerCase()}`} lane and does not match the contract's ${tradeDirection === 'DOMESTIC' ? 'intercity' : tradeDirection.toLowerCase()} direction`,
);
}
if (
derived === 'DOMESTIC' &&
(origin.country !== YardCountry.ETHIOPIA ||
destination.country !== YardCountry.ETHIOPIA)
) {
throw new BadRequestException(
`Route ${origin.label}${destination.label}: intercity service only runs between Ethiopian yards`,
);
}
}
}
/** Create a new contract (DRAFT) with its routes and cargo-scope rows. */ /** Create a new contract (DRAFT) with its routes and cargo-scope rows. */
async create( async create(
dto: CreateContractDto, dto: CreateContractDto,
@@ -144,6 +191,7 @@ export class ContractsService {
this.assertCargoScopeShape(dto.freightType, dto.cargoScope); this.assertCargoScopeShape(dto.freightType, dto.cargoScope);
this.assertRouteShape(dto.contractKind, dto.routes); this.assertRouteShape(dto.contractKind, dto.routes);
await this.assertRoutesMatchDirection(dto.tradeDirection, dto.routes);
// Stamp the operational profile (importer/exporter) for portal scoping. // Stamp the operational profile (importer/exporter) for portal scoping.
let companyProfileId: string | null = null; let companyProfileId: string | null = null;
@@ -175,6 +223,13 @@ export class ContractsService {
// Customs clearing is owned by the service type, not the customer. // Customs clearing is owned by the service type, not the customer.
const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId); const includesCustoms = await this.resolveIncludesCustoms(dto.serviceTypeId);
// Intercity never crosses a border, so a customs-including service type is
// a contradiction — the wizard hides them, the API enforces it.
if (dto.tradeDirection === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
// An explicit reference is caller-chosen — a collision there is a real // An explicit reference is caller-chosen — a collision there is a real
// conflict and should surface. Auto-generated references retry past a // conflict and should surface. Auto-generated references retry past a
@@ -360,6 +415,12 @@ export class ContractsService {
if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope); if (dto.cargoScope) this.assertCargoScopeShape(freightType, dto.cargoScope);
if (dto.routes) this.assertRouteShape(contractKind, dto.routes); if (dto.routes) this.assertRouteShape(contractKind, dto.routes);
if (dto.routes) {
await this.assertRoutesMatchDirection(
dto.tradeDirection ?? existing.tradeDirection,
dto.routes,
);
}
const updates: Record<string, unknown> = { const updates: Record<string, unknown> = {
contractKind, contractKind,
@@ -385,6 +446,11 @@ export class ContractsService {
const includesCustoms = await this.resolveIncludesCustoms( const includesCustoms = await this.resolveIncludesCustoms(
dto.serviceTypeId ?? existing.serviceTypeId, dto.serviceTypeId ?? existing.serviceTypeId,
); );
if ((dto.tradeDirection ?? existing.tradeDirection) === 'DOMESTIC' && includesCustoms) {
throw new BadRequestException(
'Intercity contracts cannot use a service type that includes customs clearing',
);
}
updates.customsClearingEnabled = includesCustoms; updates.customsClearingEnabled = includesCustoms;
updates.customsClearingAgent = includesCustoms updates.customsClearingAgent = includesCustoms
? null ? null

View File

@@ -120,9 +120,14 @@ export class CreateBookingUnderContractDto {
@IsUUID() @IsUUID()
contractRouteId?: string; contractRouteId?: string;
@ApiProperty({ description: 'Binding shipment day.', example: '2026-07-15' }) @ApiPropertyOptional({
description:
'Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later.',
example: '2026-07-15',
})
@IsOptional()
@IsDateString() @IsDateString()
scheduledDate!: string; scheduledDate?: string;
@ApiPropertyOptional({ type: [CreateBookingContainerLineDto] }) @ApiPropertyOptional({ type: [CreateBookingContainerLineDto] })
@IsOptional() @IsOptional()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import type { ScheduleTradeDirection } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity'; import { Yard } from '../../rule-engine/entities/yard.entity';
@@ -26,6 +27,14 @@ export class Route extends BaseEntity {
@Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' }) @Column({ name: 'status', type: 'varchar', length: 32, default: 'AVAILABLE' })
status!: RouteStatus; status!: RouteStatus;
/**
* Trade direction frozen from the yard countries at create/update
* (ET→DJ = EXPORT, DJ→ET = IMPORT, same country = DOMESTIC/"Intercity").
* Consumers (scheduling, booking windows) read this instead of re-deriving.
*/
@Column({ name: 'direction', type: 'varchar', length: 10 })
direction!: ScheduleTradeDirection;
@OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false }) @OneToMany(() => RouteMilestone, (milestone) => milestone.route, { cascade: false })
milestones?: RouteMilestone[]; milestones?: RouteMilestone[];
} }

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Yard } from '../rule-engine/entities/yard.entity'; import { Yard } from '../rule-engine/entities/yard.entity';
import { CreateRouteDto } from './dto/create-route.dto'; import { CreateRouteDto } from './dto/create-route.dto';
import { FilterRoutesDto } from './dto/filter-routes.dto'; import { FilterRoutesDto } from './dto/filter-routes.dto';
@@ -83,6 +84,7 @@ export class RoutesService {
originYardId: validated.originYardId, originYardId: validated.originYardId,
destinationYardId: validated.destinationYardId, destinationYardId: validated.destinationYardId,
status: dto.status ?? 'AVAILABLE', status: dto.status ?? 'AVAILABLE',
direction: validated.direction,
}), }),
); );
@@ -115,6 +117,7 @@ export class RoutesService {
originYardId: milestoneInput?.originYardId ?? existing.originYardId, originYardId: milestoneInput?.originYardId ?? existing.originYardId,
destinationYardId: destinationYardId:
milestoneInput?.destinationYardId ?? existing.destinationYardId, milestoneInput?.destinationYardId ?? existing.destinationYardId,
...(milestoneInput ? { direction: milestoneInput.direction } : {}),
...(dto.status !== undefined ? { status: dto.status } : {}), ...(dto.status !== undefined ? { status: dto.status } : {}),
}); });
@@ -187,9 +190,18 @@ export class RoutesService {
throw new BadRequestException('Origin and destination yards must be different'); throw new BadRequestException('Origin and destination yards must be different');
} }
const originYardId = normalized[0].yardId;
const destinationYardId = normalized[normalized.length - 1].yardId;
const yardById = new Map(yards.map((yard) => [yard.id, yard]));
const direction = deriveTradeDirection(
yardById.get(originYardId) ?? { country: null },
yardById.get(destinationYardId) ?? { country: null },
);
return { return {
originYardId: normalized[0].yardId, originYardId,
destinationYardId: normalized[normalized.length - 1].yardId, destinationYardId,
direction,
milestones: normalized, milestones: normalized,
}; };
} }

View File

@@ -1,5 +1,6 @@
import { YardCountry } from '@edr/types';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator'; import { IsBoolean, IsEnum, IsInt, IsOptional, IsUUID, MaxLength, Min, IsString } from 'class-validator';
export class CreateYardDto { export class CreateYardDto {
@ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 }) @ApiProperty({ description: 'Customer-facing yard label', maxLength: 100 })
@@ -7,10 +8,9 @@ export class CreateYardDto {
@MaxLength(100) @MaxLength(100)
label!: string; label!: string;
@ApiProperty({ description: 'Country where the yard is located, e.g. Ethiopia, Djibouti', maxLength: 50 }) @ApiProperty({ enum: YardCountry, description: 'Country where the yard is located' })
@IsString() @IsEnum(YardCountry)
@MaxLength(50) country!: YardCountry;
country!: string;
@ApiPropertyOptional({ default: true }) @ApiPropertyOptional({ default: true })
@IsOptional() @IsOptional()

View File

@@ -1,4 +1,5 @@
import { BaseEntity } from '@edr/api-common'; import { BaseEntity } from '@edr/api-common';
import { YardCountry } from '@edr/types';
import { Column, Entity, Index } from 'typeorm'; import { Column, Entity, Index } from 'typeorm';
@Entity({ schema: 'freight', name: 'yards' }) @Entity({ schema: 'freight', name: 'yards' })
@@ -12,8 +13,11 @@ export class Yard extends BaseEntity {
@Column({ name: 'label', type: 'varchar', length: 100 }) @Column({ name: 'label', type: 'varchar', length: 100 })
label!: string; label!: string;
// Constrained to YardCountry by DTO validation + a DB CHECK constraint;
// route/schedule trade direction is derived from this value. Typed as the
// enum's literal values so plain strings from seeds/queries still fit.
@Column({ name: 'country', type: 'varchar', length: 50 }) @Column({ name: 'country', type: 'varchar', length: 50 })
country!: string; country!: `${YardCountry}`;
@Column({ name: 'is_active', type: 'boolean', default: true }) @Column({ name: 'is_active', type: 'boolean', default: true })
isActive!: boolean; isActive!: boolean;

View File

@@ -282,15 +282,33 @@ export function computeImportWindowTimes(
return { windowOpensAt: opensAt, windowClosesAt: closesAt }; return { windowOpensAt: opensAt, windowClosesAt: closesAt };
} }
/** Export booking window: FCFS from `exportBookingLeadHours` before departure until departure. */ /**
* Export booking window: a single FCFS window from `exportBookingLeadHours`
* before departure until departure. The open honours the daily desk hours —
* when the raw lead instant lands while the desk is shut, the window opens at
* the next desk opening instead (capped at departure, so a config whose desk
* never opens before the train leaves yields a zero-length window rather than
* one that outlives the train).
*/
export function computeExportWindowTimes( export function computeExportWindowTimes(
departure: Date, departure: Date,
cfg: { exportBookingLeadHours: number }, cfg: {
exportBookingLeadHours: number;
windowOpenHour: number;
windowCloseHour: number;
},
): InitialWindowTimes { ): InitialWindowTimes {
return { const rawOpen = new Date(
windowOpensAt: new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000), departure.getTime() - cfg.exportBookingLeadHours * 3_600_000,
windowClosesAt: departure, );
}; let opensAt = officeHoursOpen(rawOpen, {
windowOpenHour: cfg.windowOpenHour,
windowCloseHour: cfg.windowCloseHour,
});
if (opensAt.getTime() > departure.getTime()) {
opensAt = departure;
}
return { windowOpensAt: opensAt, windowClosesAt: departure };
} }
/** /**
@@ -421,7 +439,9 @@ function boardWindowFromInterval(start: Date, end: Date): BoardWindow {
* after each close, on the same booking day, until departure. This mirrors * after each close, on the same booking day, until departure. This mirrors
* `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the * `computeImportWindowTimes` + `concludeCycle`'s reopen math so the board shows the
* exact windows the engine runs. * exact windows the engine runs.
* EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure. * EXPORT: a single FCFS window from `departure exportBookingLeadHours` to departure,
* with the open shifted to the next desk opening when it lands outside office hours
* (same math as `computeExportWindowTimes`).
* *
* `anchorOpensAt` pins the FIRST window's open time to the schedule's stored * `anchorOpensAt` pins the FIRST window's open time to the schedule's stored
* `windowOpensAt` instead of recomputing it from config. Pass it so the board * `windowOpensAt` instead of recomputing it from config. Pass it so the board
@@ -436,8 +456,7 @@ export function listConfigBookingWindows(
): BoardWindow[] { ): BoardWindow[] {
if (direction === 'EXPORT') { if (direction === 'EXPORT') {
const start = const start =
anchorOpensAt ?? anchorOpensAt ?? computeExportWindowTimes(departure, cfg).windowOpensAt;
new Date(departure.getTime() - cfg.exportBookingLeadHours * 3_600_000);
return [boardWindowFromInterval(start, departure)]; return [boardWindowFromInterval(start, departure)];
} }

View File

@@ -43,7 +43,7 @@ import { BookingSplitService } from './booking-split.service';
import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util'; import { MAX_TEU_SLOTS_PER_WAGON } from './wagon-plan.util';
/** A train's remaining capacity along the three physical limits the batch enforces. */ /** A train's remaining capacity along the three physical limits the batch enforces. */
interface Capacity { export interface Capacity {
wagons: number; wagons: number;
weightTons: number; weightTons: number;
lengthMeters: number; lengthMeters: number;
@@ -1444,6 +1444,48 @@ export class BookingBatchService implements OnModuleInit {
await this.fillSchedule(booking.trainScheduleId); await this.fillSchedule(booking.trainScheduleId);
} }
// ---- intercity ride-along API ---------------------------------------------
/**
* Remaining capacity budget (wagons / weight / length) for a schedule, and
* the per-booking need calculator — exposed for the intercity accept flow,
* which reserves ride-along bookings onto import/export trains outside the
* batch engine.
*/
async intercityCapacity(scheduleId: string): Promise<{
budget: Capacity;
needFor: (booking: Booking) => Capacity;
} | null> {
const schedule =
await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
const locomotive = schedule?.trainSet?.locomotive;
if (!schedule || !locomotive) return null;
const rules = await this.loadGlobalRules();
const wagonLengths = await this.loadWagonLengths();
const limits = await this.capacityLimits(locomotive, rules);
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
return { budget, needFor: (booking) => this.needFor(booking, wagonLengths) };
}
/**
* Accept an intercity booking onto the given train. Commercial bookings get
* the same pay-window lifecycle as a batch reservation (deadline, invoice
* due-date sync, pay-now notify, settle on the window tick), so payment →
* allocation needs no special path. Government bookings allocate directly.
*/
async acceptIntercity(booking: Booking, scheduleId: string): Promise<void> {
if (booking.isGovernment) {
await this.dataSource
.getRepository(Booking)
.update(booking.id, { trainScheduleId: scheduleId });
booking.trainScheduleId = scheduleId;
await this.allocate(scheduleId, booking, 'gov');
return;
}
await this.reserve(booking, scheduleId);
this.armSettle(scheduleId);
}
// ---- mutations ------------------------------------------------------------ // ---- mutations ------------------------------------------------------------
/** /**

View File

@@ -0,0 +1,77 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from '@edr/types';
import { Logger } from '@nestjs/common';
import {
OnGatewayConnection,
WebSocketGateway,
WebSocketServer,
} from '@nestjs/websockets';
import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
/**
* Server → client push for booking-window state changes. Same handshake model
* as the notifications gateway: clients only listen, the token is verified on
* connect. Events are broadcast namespace-wide — window state is route-scoped
* public information for signed-in users, and clients filter/invalidate their
* own queries.
*/
@WebSocketGateway({
namespace: BOOKING_WINDOW_WS_NAMESPACE,
cors: { origin: true, credentials: true },
})
export class BookingWindowGateway implements OnGatewayConnection {
private readonly logger = new Logger(BookingWindowGateway.name);
@WebSocketServer()
private readonly server!: Server;
constructor(private readonly wsAuth: WsAuthService) {}
async handleConnection(socket: Socket): Promise<void> {
const userId = await this.wsAuth.resolveUserId(this.extractToken(socket));
if (!userId) {
this.logger.debug(`Rejected booking-window handshake ${socket.id}`);
socket.disconnect(true);
return;
}
socket.data.userId = userId;
}
/** Push a schedule's current window state to every connected client. */
emitPhase(schedule: TrainSchedule): void {
const payload: BookingWindowPhaseEvent = {
scheduleId: schedule.id,
originYardId: schedule.originStationId,
destinationYardId: schedule.destinationStationId,
direction: schedule.direction ?? null,
phase: (schedule.windowPhase ?? 'PRE_WINDOW') as BookingWindowPhaseEvent['phase'],
bookingWindowStatus: schedule.bookingWindowStatus ?? null,
bookingCycleNo: schedule.bookingCycleNo,
windowOpensAt: schedule.windowOpensAt?.toISOString() ?? null,
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);
}
private extractToken(socket: Socket): string | undefined {
const authToken = socket.handshake.auth?.token as string | undefined;
if (authToken) return authToken;
const queryToken = socket.handshake.query?.token;
if (typeof queryToken === 'string') return queryToken;
const header = socket.handshake.headers?.authorization;
if (header?.startsWith('Bearer ')) return header.slice(7);
return undefined;
}
}

View File

@@ -9,6 +9,7 @@ import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity
import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository'; import { TrainSchedulesRepository } from '../train-schedules/train-schedules.repository';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { BookingBatchService } from './booking-batch.service'; import { BookingBatchService } from './booking-batch.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service'; import { TrainSchedulingService, effectiveWindowConfig } from './train-scheduling.service';
import { BATCH_TIMEZONE } from './booking-batch.constants'; import { BATCH_TIMEZONE } from './booking-batch.constants';
import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util'; import { eatDay, nextCycleOpensAt, type OfficeHours } from './batch-window.util';
@@ -40,6 +41,7 @@ export class BookingWindowService implements OnModuleInit {
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly gateway: BookingWindowGateway,
) {} ) {}
async onModuleInit(): Promise<void> { async onModuleInit(): Promise<void> {
@@ -48,7 +50,10 @@ export class BookingWindowService implements OnModuleInit {
); );
} }
@Cron('* * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE }) // 10-second cadence: every transition is derived from persisted timestamps
// and applied idempotently, so a finer tick only shrinks the lag between a
// deadline passing and the phase actually moving (was a full minute).
@Cron('*/10 * * * * *', { name: 'booking-window-tick', timeZone: BATCH_TIMEZONE })
async tick(): Promise<void> { async tick(): Promise<void> {
if (this.ticking) return; if (this.ticking) return;
this.ticking = true; this.ticking = true;
@@ -89,9 +94,10 @@ export class BookingWindowService implements OnModuleInit {
await this.settleOverdueReservations(); await this.settleOverdueReservations();
// Legacy fill (DOMESTIC / pre-migration schedules) every 5th tick. // Legacy fill (DOMESTIC / pre-migration schedules) every 5 minutes
// (30 ticks at the 10-second cadence).
this.tickCount += 1; this.tickCount += 1;
if (this.tickCount % 5 === 0) { if (this.tickCount % 30 === 0) {
await this.bookingBatchService.runBatchFill(); await this.bookingBatchService.runBatchFill();
} }
} finally { } finally {
@@ -151,6 +157,9 @@ export class BookingWindowService implements OnModuleInit {
? await this.advanceExport(schedule, now) ? await this.advanceExport(schedule, now)
: await this.advanceImport(schedule, cfg, now); : await this.advanceImport(schedule, cfg, now);
if (!advanced) return; if (!advanced) return;
// Push the new window state to portal home / backoffice GL sections so
// they refresh instantly instead of waiting out their poll interval.
this.gateway.emitPhase(schedule);
} }
} }
@@ -169,7 +178,9 @@ export class BookingWindowService implements OnModuleInit {
await this.bookingBatchService.setWindow(schedule.id, 'OPEN'); await this.bookingBatchService.setWindow(schedule.id, 'OPEN');
schedule.bookingWindowStatus = 'OPEN'; schedule.bookingWindowStatus = 'OPEN';
} }
await this.notifyWindowOpened(schedule); // Fire-and-forget: a slow SMS/email gateway must not stall the tick loop
// (the `ticking` guard would otherwise delay every schedule's transition).
void this.notifyWindowOpened(schedule);
this.logger.log(`Export booking window opened for schedule ${schedule.id}`); this.logger.log(`Export booking window opened for schedule ${schedule.id}`);
return true; return true;
} }
@@ -216,7 +227,8 @@ export class BookingWindowService implements OnModuleInit {
schedule.bookingWindowStatus = 'OPEN'; schedule.bookingWindowStatus = 'OPEN';
} }
// Only announce the first opening of the day; reopen cycles don't re-notify. // Only announce the first opening of the day; reopen cycles don't re-notify.
if (schedule.bookingCycleNo === 1) await this.notifyWindowOpened(schedule); // Fire-and-forget so a slow SMS/email gateway never stalls the tick loop.
if (schedule.bookingCycleNo === 1) void this.notifyWindowOpened(schedule);
this.logger.log( this.logger.log(
`Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`, `Import booking window opened for schedule ${schedule.id} (cycle ${schedule.bookingCycleNo})`,
); );

View File

@@ -0,0 +1,14 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class AcceptIntercityBookingsDto {
@ApiProperty({
type: [String],
description:
'Waiting intercity booking ids to accept onto this train, in priority order',
})
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
bookingIds!: string[];
}

View File

@@ -59,4 +59,15 @@ export class UpdateScheduleWindowRuleDto {
@IsInt() @IsInt()
@Min(0) @Min(0)
importWindowLeadDays?: number; importWindowLeadDays?: number;
@ApiPropertyOptional({
example: 24,
description:
'Hours before departure the single FCFS export window opens (EXPORT schedules; re-derives the window start)',
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
exportBookingLeadHours?: number;
} }

View File

@@ -0,0 +1,367 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { Booking } from '../bookings/entities/booking.entity';
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { BookingBatchService, type Capacity } from './booking-batch.service';
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
/**
* Intercity (DOMESTIC) ride-along: intercity bookings never get their own
* train — they ride a passing import/export schedule whose route milestones
* contain the booking's origin strictly before its destination.
*
* Flow: the customer books a corridor with no date; at finalize time staff see
* every waiting intercity booking whose corridor lies on the schedule's route,
* with its wagon/weight/length need against the train's remaining capacity;
* accepting reserves it (pay window → payment → allocation, same lifecycle as
* a batch reservation). Cargo is loaded manually when the train reaches the
* booking's origin yard and unloaded at its destination yard.
*/
@Injectable()
export class IntercityService {
private readonly logger = new Logger(IntercityService.name);
constructor(
@InjectDataSource() private readonly dataSource: DataSource,
private readonly bookingBatchService: BookingBatchService,
) {}
/**
* Waiting intercity bookings this schedule could carry, with the train's
* remaining capacity along all three axes (wagons, weight, length) and each
* booking's need, so staff can pick what fits.
*/
async listCandidates(scheduleId: string) {
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
const waiting = milestoneSeq
? await this.findWaitingIntercityBookings(milestoneSeq)
: [];
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
return {
scheduleId,
routeId: schedule.routeId ?? null,
remaining: capacity?.budget ?? null,
candidates: waiting.map((booking) => {
const need = capacity?.needFor(booking) ?? null;
return {
...this.mapBooking(booking),
need,
fits: need && capacity ? fits(need, capacity.budget) : false,
};
}),
accepted: accepted.map((booking) => ({
...this.mapBooking(booking),
need: capacity?.needFor(booking) ?? null,
})),
};
}
/**
* Accept selected waiting intercity bookings onto this train, in the given
* order, each re-checked against the shrinking capacity budget. Commercial
* bookings open a pay window (payment → allocation runs on the existing
* settle lifecycle); government bookings allocate immediately.
*/
async acceptBookings(scheduleId: string, bookingIds: string[]) {
if (bookingIds.length === 0) {
throw new BadRequestException('Select at least one intercity booking');
}
const schedule = await this.getSchedule(scheduleId);
const milestoneSeq = await this.routeMilestoneSequence(schedule);
if (!milestoneSeq) {
throw new BadRequestException(
'Schedule has no route milestones — cannot serve intercity corridors',
);
}
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
if (!capacity) {
throw new BadRequestException(
'Schedule has no locomotive/train set — capacity unknown',
);
}
const accepted: string[] = [];
const rejected: Array<{ bookingId: string; reason: string }> = [];
let budget = capacity.budget;
for (const bookingId of bookingIds) {
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId }, relations: { bookingContainers: true } });
if (!booking) {
rejected.push({ bookingId, reason: 'Booking not found' });
continue;
}
const notWaiting = this.whyNotWaiting(booking, milestoneSeq);
if (notWaiting) {
rejected.push({ bookingId, reason: notWaiting });
continue;
}
const need = capacity.needFor(booking);
if (!fits(need, budget)) {
rejected.push({
bookingId,
reason: 'Does not fit the remaining wagon/weight/length capacity',
});
continue;
}
await this.bookingBatchService.acceptIntercity(booking, scheduleId);
budget = subtract(budget, need);
accepted.push(bookingId);
this.logger.log(
`Intercity booking ${booking.reference ?? bookingId} accepted onto schedule ${scheduleId}`,
);
}
return { accepted, rejected, remaining: budget };
}
/**
* Mark an accepted intercity booking's cargo as loaded. Only allowed while
* the train is physically at the booking's origin yard: either it has not
* departed yet and the booking boards at the train's own origin, or the
* latest recorded checkpoint is at the booking's origin yard.
*/
async loadBooking(scheduleId: string, bookingId: string) {
const { schedule, booking } = await this.getAcceptedBooking(
scheduleId,
bookingId,
);
if (booking.status !== 'PAID') {
throw new BadRequestException(
`Booking must be paid before loading (currently ${booking.status})`,
);
}
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
await this.dataSource
.getRepository(Booking)
.update(bookingId, { status: 'IN_TRANSIT' });
return { bookingId, status: 'IN_TRANSIT' as const };
}
/**
* Mark an intercity booking's cargo as unloaded at its destination yard —
* requires the latest checkpoint to be at that yard. Completes the booking.
*/
async unloadBooking(scheduleId: string, bookingId: string) {
const { schedule, booking } = await this.getAcceptedBooking(
scheduleId,
bookingId,
);
if (booking.status !== 'IN_TRANSIT') {
throw new BadRequestException(
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
);
}
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
await this.dataSource
.getRepository(Booking)
.update(bookingId, { status: 'COMPLETED' });
return { bookingId, status: 'COMPLETED' as const };
}
// ---- helpers ---------------------------------------------------------------
private async getSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.dataSource
.getRepository(TrainSchedule)
.findOne({ where: { id: scheduleId } });
if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
}
return schedule;
}
/**
* yardId → sequenceNo for the schedule's route. Falls back to a two-stop
* origin/destination pseudo-route for legacy schedules without a routeId,
* so an intercity booking exactly matching the train's own corridor still
* qualifies.
*/
private async routeMilestoneSequence(
schedule: TrainSchedule,
): Promise<Map<string, number> | null> {
if (schedule.routeId) {
const milestones = await this.dataSource
.getRepository(RouteMilestone)
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
if (milestones.length >= 2) {
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
}
}
if (schedule.originStationId && schedule.destinationStationId) {
return new Map([
[schedule.originStationId, 1],
[schedule.destinationStationId, 2],
]);
}
return null;
}
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
private async findWaitingIntercityBookings(
milestoneSeq: Map<string, number>,
): Promise<Booking[]> {
const pool = await this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id IS NULL')
.andWhere(
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
OR (booking.is_government = true AND booking.status = 'APPROVED'))`,
)
.orderBy('booking.is_government', 'DESC')
.addOrderBy('booking.priority_score', 'DESC')
.addOrderBy('booking.created_at', 'ASC')
.getMany();
return pool.filter((b) => this.corridorOnRoute(b, milestoneSeq));
}
/** Intercity bookings already reserved/allocated on this schedule. */
private async findAcceptedIntercityBookings(
scheduleId: string,
): Promise<Booking[]> {
return this.dataSource
.getRepository(Booking)
.createQueryBuilder('booking')
.leftJoinAndSelect('booking.company', 'company')
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
.leftJoinAndSelect('booking.originYard', 'originYard')
.leftJoinAndSelect('booking.destinationYard', 'destinationYard')
.where(`booking.trade_direction = 'DOMESTIC'`)
.andWhere('booking.train_schedule_id = :scheduleId', { scheduleId })
.orderBy('booking.created_at', 'ASC')
.getMany();
}
private corridorOnRoute(
booking: Booking,
milestoneSeq: Map<string, number>,
): boolean {
const originSeq = milestoneSeq.get(booking.originYardId);
const destinationSeq = milestoneSeq.get(booking.destinationYardId);
return (
originSeq != null && destinationSeq != null && originSeq < destinationSeq
);
}
private whyNotWaiting(
booking: Booking,
milestoneSeq: Map<string, number>,
): string | null {
if (booking.tradeDirection !== 'DOMESTIC') {
return 'Not an intercity booking';
}
if (booking.trainScheduleId) {
return 'Already assigned to a train';
}
const readyStatus = booking.isGovernment ? 'APPROVED' : 'FULLY_EXECUTED';
if (booking.status !== readyStatus) {
return `Not ready to board (status ${booking.status})`;
}
if (!this.corridorOnRoute(booking, milestoneSeq)) {
return "Corridor is not on this schedule's route";
}
return null;
}
private async getAcceptedBooking(scheduleId: string, bookingId: string) {
const schedule = await this.getSchedule(scheduleId);
const booking = await this.dataSource
.getRepository(Booking)
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not assigned to this schedule');
}
if (booking.tradeDirection !== 'DOMESTIC') {
throw new BadRequestException('Not an intercity booking');
}
return { schedule, booking };
}
/**
* The train is "at" a yard when the latest recorded checkpoint is that yard,
* or — for a booking boarding at the train's own origin — when the train has
* not recorded any checkpoint yet (still sitting at its origin).
*/
private async assertTrainAtYard(
schedule: TrainSchedule,
yardId: string,
side: 'origin' | 'destination',
): Promise<void> {
const latest = await this.dataSource
.getRepository(TrainCheckpointEvent)
.findOne({
where: { trainScheduleId: schedule.id },
order: { occurredAt: 'DESC', createdAt: 'DESC' },
});
if (!latest) {
if (side === 'origin' && schedule.originStationId === yardId) return;
throw new BadRequestException(
'Train has not reached this yard yet — record its checkpoint first',
);
}
if (latest.yardId !== yardId) {
throw new BadRequestException(
`Train's last recorded position is not at the booking's ${side} yard`,
);
}
}
private mapBooking(booking: Booking) {
return {
id: booking.id,
reference: booking.reference,
status: booking.status,
freightType: booking.freightType,
isGovernment: booking.isGovernment,
customer: booking.company?.name ?? 'Unknown customer',
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
origin:
booking.originYard?.label ?? booking.originYard?.code ?? 'Unknown origin',
destination:
booking.destinationYard?.label ??
booking.destinationYard?.code ??
'Unknown destination',
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
};
}
}
function fits(need: Capacity, budget: Capacity): boolean {
return (
need.wagons <= budget.wagons &&
need.weightTons <= budget.weightTons &&
need.lengthMeters <= budget.lengthMeters
);
}
function subtract(budget: Capacity, need: Capacity): Capacity {
return {
wagons: budget.wagons - need.wagons,
weightTons: budget.weightTons - need.weightTons,
lengthMeters: budget.lengthMeters - need.lengthMeters,
};
}

View File

@@ -20,6 +20,7 @@ import {
TrainSchedulingManage, TrainSchedulingManage,
TrainSchedulingView, TrainSchedulingView,
} from "../../common/booking-guards"; } from "../../common/booking-guards";
import { AcceptIntercityBookingsDto } from "./dto/accept-intercity-bookings.dto";
import { AssignBookingsDto } from "./dto/assign-bookings.dto"; import { AssignBookingsDto } from "./dto/assign-bookings.dto";
import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto"; import { AssignUnassignedBookingDto } from "./dto/assign-unassigned-booking.dto";
import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto"; import { CreateContainerTrainScheduleDto } from "./dto/create-container-train-schedule.dto";
@@ -47,6 +48,7 @@ import { UpdateScheduleDateDto } from "./dto/update-schedule-date.dto";
import { TrainSchedulingService } from "./train-scheduling.service"; import { TrainSchedulingService } from "./train-scheduling.service";
import { BookingBatchService } from "./booking-batch.service"; import { BookingBatchService } from "./booking-batch.service";
import { BookingWindowService } from "./booking-window.service"; import { BookingWindowService } from "./booking-window.service";
import { IntercityService } from "./intercity.service";
import { BillingService } from "../billing/billing.service"; import { BillingService } from "../billing/billing.service";
@ApiTags("train-scheduling") @ApiTags("train-scheduling")
@@ -57,6 +59,7 @@ export class TrainSchedulingController {
private readonly trainSchedulingService: TrainSchedulingService, private readonly trainSchedulingService: TrainSchedulingService,
private readonly bookingBatchService: BookingBatchService, private readonly bookingBatchService: BookingBatchService,
private readonly bookingWindowService: BookingWindowService, private readonly bookingWindowService: BookingWindowService,
private readonly intercityService: IntercityService,
private readonly billingService: BillingService, private readonly billingService: BillingService,
) { } ) { }
@@ -406,6 +409,54 @@ export class TrainSchedulingController {
return this.trainSchedulingService.dispatchSchedule(id); return this.trainSchedulingService.dispatchSchedule(id);
} }
@Get("schedules/:id/intercity-candidates")
@TrainSchedulingView()
@ApiOperation({
summary:
"Waiting intercity bookings this train could carry (corridor on route) + remaining wagon/weight/length capacity",
})
getIntercityCandidates(@Param("id", ParseUUIDPipe) id: string) {
return this.intercityService.listCandidates(id);
}
@Post("schedules/:id/intercity/accept")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Accept intercity bookings onto this train (opens their pay window; capacity re-checked per booking)",
})
acceptIntercityBookings(
@Param("id", ParseUUIDPipe) id: string,
@Body() dto: AcceptIntercityBookingsDto,
) {
return this.intercityService.acceptBookings(id, dto.bookingIds);
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingManage()
@ApiOperation({
summary: "Confirm intercity cargo loaded (train must be at the booking's origin yard)",
})
loadIntercityBooking(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.intercityService.loadBooking(id, bookingId);
}
@Post("schedules/:id/intercity/:bookingId/unload")
@TrainSchedulingManage()
@ApiOperation({
summary:
"Confirm intercity cargo unloaded at the booking's destination yard (completes the booking)",
})
unloadIntercityBooking(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
) {
return this.intercityService.unloadBooking(id, bookingId);
}
@Get("schedules/:id/import-djibouti") @Get("schedules/:id/import-djibouti")
@TrainSchedulingView() @TrainSchedulingView()
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" }) @ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })

View File

@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { Session } from '@tria-plc/iamapi-common/entities/iam/user/session.entity';
import { BillingModule } from '../billing/billing.module'; import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module'; import { BookingsModule } from '../bookings/bookings.module';
@@ -25,7 +26,10 @@ import { TrainSchedulingController } from './train-scheduling.controller';
import { TrainSchedulingService } from './train-scheduling.service'; import { TrainSchedulingService } from './train-scheduling.service';
import { BookingBatchService } from './booking-batch.service'; import { BookingBatchService } from './booking-batch.service';
import { BookingNotifierService } from './booking-notifier.service'; import { BookingNotifierService } from './booking-notifier.service';
import { BookingWindowGateway } from './booking-window.gateway';
import { BookingWindowService } from './booking-window.service'; import { BookingWindowService } from './booking-window.service';
import { IntercityService } from './intercity.service';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { BookingSplitService } from './booking-split.service'; import { BookingSplitService } from './booking-split.service';
import { BookingBatchOffer } from './entities/booking-batch-offer.entity'; import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
import { NotificationsModule } from '../notifications/notifications.module'; import { NotificationsModule } from '../notifications/notifications.module';
@@ -46,6 +50,8 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEvent, TrainCheckpointEvent,
ImportDjiboutiOperation, ImportDjiboutiOperation,
BookingBatchOffer, BookingBatchOffer,
// WsAuthService (booking-window gateway handshake) verifies IAM sessions.
Session,
]), ]),
forwardRef(() => BookingsModule), forwardRef(() => BookingsModule),
BillingModule, BillingModule,
@@ -64,8 +70,11 @@ import { ContractsModule } from '../contracts/contracts.module';
TrainCheckpointEventsRepository, TrainCheckpointEventsRepository,
BookingBatchService, BookingBatchService,
BookingNotifierService, BookingNotifierService,
BookingWindowGateway,
WsAuthService,
BookingWindowService, BookingWindowService,
BookingSplitService, BookingSplitService,
IntercityService,
], ],
exports: [TrainSchedulingService, BookingBatchService, BookingWindowService], exports: [TrainSchedulingService, BookingBatchService, BookingWindowService],
}) })

View File

@@ -408,7 +408,9 @@ export class TrainSchedulingService {
schedule.ruleImportWindowLeadDays ?? schedule.ruleImportWindowLeadDays ??
liveCfg.importWindowLeadDays, liveCfg.importWindowLeadDays,
exportBookingLeadHours: exportBookingLeadHours:
schedule.ruleExportBookingLeadHours ?? liveCfg.exportBookingLeadHours, dto.exportBookingLeadHours ??
schedule.ruleExportBookingLeadHours ??
liveCfg.exportBookingLeadHours,
windowOpenHour: windowOpenHour:
dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour, dto.windowOpenHour ?? schedule.ruleWindowOpenHour ?? liveCfg.windowOpenHour,
windowCloseHour: windowCloseHour:
@@ -432,6 +434,12 @@ export class TrainSchedulingService {
schedule.direction === 'EXPORT' schedule.direction === 'EXPORT'
? computeExportWindowTimes(schedule.scheduledDepartureDate, merged) ? computeExportWindowTimes(schedule.scheduledDepartureDate, merged)
: computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now); : computeImportWindowTimes(schedule.scheduledDepartureDate, merged, now);
if (times.windowOpensAt.getTime() >= times.windowClosesAt.getTime()) {
throw new BadRequestException(
'These settings leave no booking window before departure — with the ' +
'desk hours applied, the window would only open once the train has left.',
);
}
await this.dataSource.getRepository(TrainSchedule).update(id, { await this.dataSource.getRepository(TrainSchedule).update(id, {
windowOpensAt: times.windowOpensAt, windowOpensAt: times.windowOpensAt,
@@ -686,10 +694,9 @@ export class TrainSchedulingService {
lockedLocomotives.push(locked); lockedLocomotives.push(locked);
} }
const direction = deriveScheduleDirection( // Frozen on the route at create/update from the yard-country enum;
route.originYard ?? { country: null }, // getSchedulableRoute already rejected DOMESTIC (intercity).
route.destinationYard ?? { country: null }, const direction = this.resolveRouteDirection(route);
);
const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives); const trainSet = await this.buildEmptyTrainSet(manager, lockedLocomotives);
// Effective capacity is capped by the weakest locomotive in the set. // Effective capacity is capped by the weakest locomotive in the set.
@@ -3447,9 +3454,27 @@ export class TrainSchedulingService {
`Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`, `Route ${formatRouteLabel(route)} is not available for scheduling (${route.status})`,
); );
} }
// Intercity (same-country) service is not offered yet — only import/export
// trains can be scheduled.
if (this.resolveRouteDirection(route) === 'DOMESTIC') {
throw new BadRequestException(
`Route ${formatRouteLabel(route)} is an intercity route; intercity scheduling is not available yet`,
);
}
return route; return route;
} }
/** Stored route direction, deriving from yard countries for pre-migration rows. */
private resolveRouteDirection(route: Route) {
return (
route.direction ??
deriveScheduleDirection(
route.originYard ?? { country: null },
route.destinationYard ?? { country: null },
)
);
}
private mapEligibleBooking(booking: Booking) { private mapEligibleBooking(booking: Booking) {
return { return {
id: booking.id, id: booking.id,
@@ -4054,6 +4079,7 @@ export class TrainSchedulingService {
: null, : null,
reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null, reopenDelayMinutes: schedule.ruleReopenDelayMinutes ?? null,
importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null, importWindowLeadDays: schedule.ruleImportWindowLeadDays ?? null,
exportBookingLeadHours: schedule.ruleExportBookingLeadHours ?? null,
docReviewMinutes: windowCfg.docReviewMinutes, docReviewMinutes: windowCfg.docReviewMinutes,
paymentWindowMinutes: windowCfg.paymentWindowMinutes, paymentWindowMinutes: windowCfg.paymentWindowMinutes,
}, },

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index } from 'typeorm';
export const HANDOVER_MILE_TYPES = ['SELF_HAUL', 'EDR_LAST_MILE'] as const;
export type HandoverMileType = (typeof HANDOVER_MILE_TYPES)[number];
/**
* One import handover. A booking has a single handover when one truck takes the
* whole booking (`truckAssignmentId` null = per-booking), or one per truck when
* multiple trucks are used. Self-haul handovers are generated on truck arrival
* and signed before the truck leaves; EDR last-mile handovers are generated at
* delivery (after exit).
*/
@Entity({ schema: 'freight', name: 'booking_handovers' })
@Index(['bookingId'])
export class BookingHandover extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
/** Customer self-haul truck this handover belongs to; null = per-booking. */
@Column({ name: 'truck_assignment_id', type: 'uuid', nullable: true })
truckAssignmentId?: string | null;
/** Denormalised plate for display / EDR trucks (which aren't customer trucks). */
@Column({ name: 'truck_plate', type: 'varchar', length: 32, nullable: true })
truckPlate?: string | null;
@Column({ name: 'mile_type', type: 'varchar', length: 20 })
mileType!: HandoverMileType;
@Column({ name: 'reference', type: 'varchar', length: 100 })
reference!: string;
@Column({ name: 'generated_at', type: 'timestamptz', default: () => 'now()' })
generatedAt!: Date;
@Column({ name: 'signed_at', type: 'timestamptz', nullable: true })
signedAt?: Date | null;
@Column({ name: 'signed_by_user_id', type: 'uuid', nullable: true })
signedByUserId?: string | null;
/** EDR last-mile: when the goods were delivered to the customer. */
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
deliveredAt?: Date | null;
}

View File

@@ -0,0 +1,123 @@
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, EntityManager, IsNull } from 'typeorm';
import { BookingHandover } from './entities/booking-handover.entity';
/**
* Import handover records. A booking has one handover per truck (single truck ⇒
* one, effectively per-booking; multiple trucks ⇒ one each). Timing by mile type:
* - SELF_HAUL: generated when the customer truck arrives, signed before it leaves.
* - EDR_LAST_MILE: generated at delivery (after exit).
*/
@Injectable()
export class HandoverService {
private readonly logger = new Logger(HandoverService.name);
constructor(private readonly dataSource: DataSource) {}
list(bookingId: string): Promise<BookingHandover[]> {
return this.dataSource.getRepository(BookingHandover).find({
where: { bookingId },
order: { generatedAt: 'ASC' },
});
}
/**
* Self-haul: ensure a handover exists for a customer truck that just arrived.
* Idempotent — one per (booking, truck). Runs inside the caller's transaction
* when a manager is supplied.
*/
async ensureForArrivedTruck(
bookingId: string,
opts: { truckAssignmentId?: string | null; truckPlate?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
const saved = await repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'SELF_HAUL',
reference,
generatedAt: new Date(),
}),
);
this.logger.log(`Handover ${reference} generated on arrival for booking ${bookingId}`);
return saved;
}
/**
* EDR last-mile: generate a handover at delivery (after exit). One per EDR
* truck (by plate) or per booking. Idempotent by (booking, plate).
*/
async ensureAtDelivery(
bookingId: string,
opts: { truckPlate?: string | null; truckAssignmentId?: string | null },
manager?: EntityManager,
): Promise<BookingHandover> {
const m = manager ?? this.dataSource.manager;
const repo = m.getRepository(BookingHandover);
const existing = await repo.findOne({
where: {
bookingId,
truckPlate: opts.truckPlate ?? IsNull(),
truckAssignmentId: opts.truckAssignmentId ?? IsNull(),
},
});
if (existing) return existing;
const reference = await this.generateReference(bookingId, m);
return repo.save(
repo.create({
bookingId,
truckAssignmentId: opts.truckAssignmentId ?? null,
truckPlate: opts.truckPlate ?? null,
mileType: 'EDR_LAST_MILE',
reference,
generatedAt: new Date(),
deliveredAt: new Date(),
}),
);
}
/** Sign all unsigned handovers on a booking (self-haul: before the truck leaves). */
async signForBooking(bookingId: string, userId?: string | null): Promise<void> {
await this.dataSource
.getRepository(BookingHandover)
.update(
{ bookingId, signedAt: IsNull() },
{ signedAt: new Date(), signedByUserId: userId ?? null },
);
}
/** True when every handover on the booking is signed (and at least one exists). */
async isFullySigned(bookingId: string): Promise<boolean> {
const repo = this.dataSource.getRepository(BookingHandover);
const [total, unsigned] = await Promise.all([
repo.count({ where: { bookingId } }),
repo.count({ where: { bookingId, signedAt: IsNull() } }),
]);
return total > 0 && unsigned === 0;
}
private async generateReference(bookingId: string, manager: EntityManager): Promise<string> {
const [booking] = await manager.query(
`SELECT reference FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
const ref = String(booking?.reference ?? bookingId).replace(/^BK-?/i, '');
const count = await manager.getRepository(BookingHandover).count({ where: { bookingId } });
return `HND-${ref}-${String(count + 1).padStart(2, '0')}`;
}
}

View File

@@ -15,6 +15,7 @@ import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto'; import { UnloadBookingDto } from './dto/unload-booking.dto';
import { SchedulingReadFacade } from './scheduling-read.facade'; import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseInventoryService } from './warehouse-inventory.service'; import { WarehouseInventoryService } from './warehouse-inventory.service';
import { HandoverService } from './handover.service';
@ApiTags('warehouse-inventory') @ApiTags('warehouse-inventory')
@ApiBearerAuth() @ApiBearerAuth()
@@ -23,6 +24,7 @@ export class WarehouseInventoryController {
constructor( constructor(
private readonly inventoryService: WarehouseInventoryService, private readonly inventoryService: WarehouseInventoryService,
private readonly scheduling: SchedulingReadFacade, private readonly scheduling: SchedulingReadFacade,
private readonly handoverService: HandoverService,
) {} ) {}
@Get() @Get()
@@ -283,6 +285,19 @@ export class WarehouseInventoryController {
return res.send(buffer); return res.send(buffer);
} }
@Get('customer-truck-exit-paper/:assignmentId')
@ApiOperation({ summary: 'Per-truck exit paper PDF (containers loaded on one customer truck)' })
async truckExitPaper(
@Param('assignmentId', ParseUUIDPipe) assignmentId: string,
@Res() res: Response,
) {
const { filename, buffer } = await this.inventoryService.truckExitPaper(assignmentId);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${filename}"`);
res.setHeader('Content-Length', buffer.length);
return res.send(buffer);
}
@Get(':id/grn-document') @Get(':id/grn-document')
@ApiOperation({ summary: 'View goods received note PDF' }) @ApiOperation({ summary: 'View goods received note PDF' })
async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) { async grnDocument(@Param('id', ParseUUIDPipe) id: string, @Res() res: Response) {
@@ -312,6 +327,12 @@ export class WarehouseInventoryController {
return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub); return this.inventoryService.approveDeliveryForBooking(bookingId, req.user?.id ?? req.user?.sub);
} }
@Get('bookings/:bookingId/handovers')
@ApiOperation({ summary: 'Handover records for a booking (per-booking or per-truck)' })
bookingHandovers(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.handoverService.list(bookingId);
}
@Post(':id/deliver') @Post(':id/deliver')
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' }) @ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) { deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {

View File

@@ -38,6 +38,7 @@ import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository'; import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository'; import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseReleaseDocumentService } from './warehouse-release-document.service'; import { WarehouseReleaseDocumentService } from './warehouse-release-document.service';
import { HandoverService } from './handover.service';
/** Wagon states that may receive a load (besides being part of an existing schedule). */ /** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED']; const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
@@ -342,6 +343,7 @@ export class WarehouseInventoryService {
private readonly lastMileService: LastMileService, private readonly lastMileService: LastMileService,
private readonly notifications: NotificationsService, private readonly notifications: NotificationsService,
private readonly signatures: SignaturesService, private readonly signatures: SignaturesService,
private readonly handover: HandoverService,
) {} ) {}
/** /**
@@ -928,7 +930,7 @@ export class WarehouseInventoryService {
SET received_to_port = true, SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()), received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW() updated_at = NOW()
FROM freight.booking_containers bc FROM freight.booking_container bc
WHERE bc.id = bcu.booking_container_id WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1 AND bc.booking_id = $1
AND bc.deleted_at IS NULL AND bc.deleted_at IS NULL
@@ -1799,7 +1801,7 @@ export class WarehouseInventoryService {
SET received_to_port = true, SET received_to_port = true,
received_at = COALESCE(bcu.received_at, NOW()), received_at = COALESCE(bcu.received_at, NOW()),
updated_at = NOW() updated_at = NOW()
FROM freight.booking_containers bc, freight.containers cont FROM freight.booking_container bc, freight.containers cont
WHERE bc.id = bcu.booking_container_id WHERE bc.id = bcu.booking_container_id
AND bc.booking_id = $1 AND bc.booking_id = $1
AND bc.deleted_at IS NULL AND bc.deleted_at IS NULL
@@ -2080,9 +2082,14 @@ export class WarehouseInventoryService {
[item.bookingId], [item.bookingId],
); );
const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt); const usesCustomerTruck = Boolean(truckInfo?.customerTruckAssignedAt);
if (usesCustomerTruck && !this.extractCustomerDeliveryApproval(item.notes)) { // Self-haul: the handover must be signed before the exit paper is issued.
// Prefer the structured handover record; fall back to the legacy note.
const handoverSigned =
(await this.handover.isFullySigned(item.bookingId)) ||
Boolean(this.extractCustomerDeliveryApproval(item.notes));
if (usesCustomerTruck && !handoverSigned) {
throw new BadRequestException( throw new BadRequestException(
'Customer must approve delivery (sign the handover) before the exit paper can be generated', 'Customer must sign the handover before the exit paper can be generated',
); );
} }
} }
@@ -2137,6 +2144,16 @@ export class WarehouseInventoryService {
AND deleted_at IS NULL`, AND deleted_at IS NULL`,
[item.bookingId], [item.bookingId],
); );
// Self-haul: generate the per-booking handover on first truck arrival
// (idempotent). It must be signed before the truck leaves.
const [selfHaul]: Array<{ ok: number }> = await manager.query(
`SELECT 1 AS ok FROM freight.bookings
WHERE id = $1 AND customer_truck_assigned_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (selfHaul) {
await this.handover.ensureForArrivedTruck(item.bookingId, {}, manager);
}
} }
await this.activityLog.record( await this.activityLog.record(
{ {
@@ -2231,7 +2248,7 @@ export class WarehouseInventoryService {
FROM freight.customer_truck_containers cc FROM freight.customer_truck_containers cc
JOIN freight.booking_container_units bcu JOIN freight.booking_container_units bcu
ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL ON bcu.container_number = cc.container_number AND bcu.deleted_at IS NULL
JOIN freight.booking_containers bc JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
AND bc.booking_id = c.booking_id AND bc.booking_id = c.booking_id
WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL WHERE cc.assignment_id = a.id AND cc.deleted_at IS NULL
@@ -2291,6 +2308,115 @@ export class WarehouseInventoryService {
}; };
} }
/**
* Per-truck exit paper: one paper covering the containers loaded on a specific
* customer truck (used when multiple trucks leave separately). Gated on the
* handover being signed and warehouse fees paid.
*/
async truckExitPaper(assignmentId: string): Promise<{ filename: string; buffer: Buffer }> {
const [truck] = await this.dataSource.query(
`SELECT a.booking_id AS "bookingId", 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",
b.reference AS "bookingReference", company.name AS "customerName"
FROM freight.customer_truck_assignments a
JOIN freight.bookings b ON b.id = a.booking_id AND b.deleted_at IS NULL
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE a.id = $1 AND a.deleted_at IS NULL`,
[assignmentId],
);
if (!truck) throw new NotFoundException(`Truck assignment ${assignmentId} not found`);
if (!(await this.handover.isFullySigned(truck.bookingId))) {
throw new BadRequestException('Handover must be signed before the exit paper can be generated');
}
const [inv]: Array<{ id: string }> = await this.dataSource.query(
`SELECT id FROM freight.warehouse_inventory
WHERE booking_id = $1 AND deleted_at IS NULL ORDER BY created_at LIMIT 1`,
[truck.bookingId],
);
if (inv?.id) await this.invoices.assertClearanceAllowed(inv.id);
const containers: Array<{ containerNumber: string; goods: string | null }> =
await this.dataSource.query(
`SELECT c.container_number AS "containerNumber",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS goods
FROM freight.customer_truck_containers c
JOIN freight.bookings b ON b.id = c.booking_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
WHERE c.assignment_id = $1 AND c.deleted_at IS NULL
ORDER BY c.container_number`,
[assignmentId],
);
const html = this.buildTruckExitPaperHtml({
reference: `REL-${String(truck.bookingReference).replace(/^BK-?/i, '')}-${truck.plateNumber}`,
bookingReference: truck.bookingReference,
customerName: truck.customerName,
plateNumber: truck.plateNumber,
driverName: truck.driverName,
truckType: truck.truckType,
grossWeightKg: Number(truck.grossWeightKg ?? 0),
gateOut: truck.departedAt,
containers,
});
return {
filename: `exit-${String(truck.plateNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.renderDocumentHtml(html, 'Warehouse exit paper'),
};
}
private buildTruckExitPaperHtml(data: {
reference: string;
bookingReference: string;
customerName: string | null;
plateNumber: string;
driverName: string;
truckType: string;
grossWeightKg: number;
gateOut: string | Date | null;
containers: Array<{ containerNumber: string; goods: string | null }>;
}): string {
const esc = (v: unknown) =>
String(v ?? '-').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const gateOut = data.gateOut ? new Date(data.gateOut).toLocaleString('en-GB') : '-';
const rows: Array<[string, string]> = [
['Booking Reference', data.bookingReference],
['Customer / Consignee', data.customerName ?? '-'],
['Pickup Truck Plate', data.plateNumber],
['Driver', data.driverName],
['Truck Type', data.truckType],
['Gross Weight (Loaded on Truck)', `${data.grossWeightKg.toLocaleString()} kg`],
['Gate-Out Time', gateOut],
['Clearance Status', 'CLEARED FOR WAREHOUSE EXIT'],
];
const containerRows = data.containers.length
? data.containers
.map((c) => `<tr><td>${esc(c.containerNumber)}</td><td>${esc(c.goods)}</td></tr>`)
.join('')
: '<tr><td colspan="2">No containers loaded on this truck.</td></tr>';
return `<!doctype html><html><head><meta charset="utf-8" /><title>Warehouse Exit Paper</title>
<style>
body { font-family: "Times New Roman", Georgia, serif; color: #061323; margin: 24px; }
h1 { font-size: 24px; text-transform: uppercase; margin: 0 0 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 8px; }
th, td { border: 1px solid #b9c7d8; padding: 8px 10px; font-size: 12px; text-align: left; vertical-align: top; }
th { background: #f8fafc; width: 34%; font-weight: 800; }
.section { margin-top: 18px; font-weight: 800; color: #064c27; text-transform: uppercase; letter-spacing: .1em; }
.ref strong { font-size: 16px; }
</style></head>
<body>
<div style="color:#064c27;font-weight:800;text-transform:uppercase;">Ethio-Djibouti Railway S.C.</div>
<h1>Warehouse Release / Exit Paper</h1>
<div class="ref">Document / Release No. <strong>${esc(data.reference)}</strong></div>
<div class="section">Release Particulars</div>
<table><tbody>${rows.map(([l, v]) => `<tr><th>${esc(l)}</th><td>${esc(v)}</td></tr>`).join('')}</tbody></table>
<div class="section">Containers Leaving on This Truck</div>
<table><thead><tr><th style="width:40%">Container Number</th><th>Goods</th></tr></thead>
<tbody>${containerRows}</tbody></table>
</body></html>`;
}
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */ /** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> { async grnDocument(id: string): Promise<{ filename: string; buffer: Buffer }> {
const [row] = await this.dataSource.query( const [row] = await this.dataSource.query(
@@ -2388,7 +2514,17 @@ export class WarehouseInventoryService {
return { return {
filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, filename: `grn-${String(row.grnNumber).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html), // Styled fallback titled as a GRN (not a release order) for Chromium-less render.
buffer: await this.releaseDocuments.renderStyledDocument(
html,
{
titleLines: ['GOODS RECEIVED', 'NOTE'],
subtitle: 'OFFICIAL WAREHOUSE GOODS RECEIVED NOTE',
sectionTitle: 'RECEIVED PARTICULARS',
refLabel: 'GRN No.',
},
'Goods Received Note',
),
}; };
} }
@@ -2462,6 +2598,10 @@ export class WarehouseInventoryService {
); );
}); });
// Sign the structured handover record(s) for this booking (self-haul: before
// the truck leaves). Kept alongside the legacy approval note.
await this.handover.signForBooking(bookingId, userId);
return { return {
bookingId, bookingId,
inventoryId: item.id, inventoryId: item.id,
@@ -2589,7 +2729,17 @@ export class WarehouseInventoryService {
return { return {
filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`, filename: `handover-${String(reference).replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer: await this.releaseDocuments.htmlToPdfBuffer(html), // Styled fallback titled as a handover (not a release order) for Chromium-less render.
buffer: await this.releaseDocuments.renderStyledDocument(
html,
{
titleLines: ['IMPORT GOODS', 'HANDOVER', 'DOCUMENT'],
subtitle: 'EDR TO CUSTOMER WAREHOUSE HANDOVER',
sectionTitle: 'HANDOVER PARTICULARS',
refLabel: 'Document / Handover No.',
},
'Import Goods Handover',
),
}; };
} }
@@ -2601,6 +2751,29 @@ export class WarehouseInventoryService {
throw new BadRequestException('A release order must be issued before the goods can be delivered'); throw new BadRequestException('A release order must be issued before the goods can be delivered');
} }
// Self-haul: the customer's own truck delivers — deliver only after the
// handover is signed AND the truck has left the warehouse holding the goods.
if (item.bookingId) {
const [sh]: Array<{ assignedAt: string | null }> = await this.dataSource.query(
`SELECT customer_truck_assigned_at AS "assignedAt"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (sh?.assignedAt) {
if (!(await this.handover.isFullySigned(item.bookingId))) {
throw new BadRequestException('Handover must be signed before delivery');
}
const [left]: Array<{ n: string }> = await this.dataSource.query(
`SELECT COUNT(*) AS n FROM freight.customer_truck_assignments
WHERE booking_id = $1 AND departed_at IS NOT NULL AND deleted_at IS NULL`,
[item.bookingId],
);
if (Number(left?.n ?? 0) === 0) {
throw new BadRequestException('Deliver is available only after the customer truck has left');
}
}
}
const receiverName = dto.receiverName.trim(); const receiverName = dto.receiverName.trim();
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date(); const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
const weight = Number(item.weight) || 0; const weight = Number(item.weight) || 0;
@@ -2645,6 +2818,27 @@ export class WarehouseInventoryService {
}, },
manager, manager,
); );
// Handover on delivery. EDR last-mile generates its handover HERE (after
// exit, on delivery). Self-haul handovers were generated on arrival —
// stamp them delivered.
if (item.bookingId) {
const [b]: Array<{ selfHaul: string | null }> = await manager.query(
`SELECT customer_truck_assigned_at AS "selfHaul"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
if (b?.selfHaul) {
await manager.query(
`UPDATE freight.booking_handovers
SET delivered_at = COALESCE(delivered_at, NOW()), updated_at = NOW()
WHERE booking_id = $1 AND deleted_at IS NULL`,
[item.bookingId],
);
} else {
await this.handover.ensureAtDelivery(item.bookingId, {}, manager);
}
}
}); });
return this.findById(id); return this.findById(id);

View File

@@ -32,16 +32,38 @@ export class WarehouseReleaseDocumentService {
return this.pdf.htmlToPdfBuffer(html, { label }); return this.pdf.htmlToPdfBuffer(html, { label });
} }
private htmlToBasicPdfBuffer(html: string): Buffer { /**
* Render document HTML with a STYLED hand-built fallback (the release layout,
* but with a custom title + section heading) for when Chromium is unavailable.
* Handover / GRN use this so their fallback looks like a proper document —
* not a plain-text dump, and not mislabelled as a release order.
*/
renderStyledDocument(
html: string,
fallbackOpts: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
label = 'Document',
): Promise<Buffer> {
return this.pdf.htmlToPdfBuffer(html, {
label,
fallback: (preparedHtml) => this.htmlToBasicPdfBuffer(preparedHtml, fallbackOpts),
});
}
private htmlToBasicPdfBuffer(
html: string,
opts?: { titleLines?: string[]; subtitle?: string; sectionTitle?: string; refLabel?: string },
): Buffer {
const doc = this.extractReleaseDocument(html); const doc = this.extractReleaseDocument(html);
const titleLines = (opts?.titleLines ?? ['WAREHOUSE GATE', 'CLEARANCE / RELEASE', 'ORDER']).slice(0, 3);
const subtitle = opts?.subtitle ?? 'OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION';
const sectionTitle = opts?.sectionTitle ?? 'RELEASE PARTICULARS';
const refLabel = opts?.refLabel ?? 'Document / Release No.';
const body: string[] = [ const body: string[] = [
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2), this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'), this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'), ...titleLines.map((line, i) => this.textOp(line, 36, 764 - i * 22, 24, 'F2', '0.02 0.08 0.16')),
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'), this.textOp(subtitle, 36, 764 - titleLines.length * 22 + 2, 8.5, 'F1', '0.25 0.34 0.45'),
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'), this.textOp(refLabel, 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'), this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'), this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2), this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
@@ -50,7 +72,7 @@ export class WarehouseReleaseDocumentService {
...this.wrapLines(doc.notice, 68) ...this.wrapLines(doc.notice, 68)
.slice(0, 4) .slice(0, 4)
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')), .map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'), this.textOp(sectionTitle, 36, 604, 10, 'F2', '0.08 0.32 0.18'),
]; ];
let y = 586; let y = 586;

View File

@@ -15,6 +15,8 @@ import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.en
import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { WarehouseFeeRule } from './entities/warehouse-fee-rule.entity';
import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity'; import { WarehouseInspectionReport } from './entities/warehouse-inspection-report.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { BookingHandover } from './entities/booking-handover.entity';
import { HandoverService } from './handover.service';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity'; import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity'; import { WarehouseLoading } from './entities/warehouse-loading.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity';
@@ -65,6 +67,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInspectionReport, WarehouseInspectionReport,
WarehouseAllocationRule, WarehouseAllocationRule,
WarehouseFeeRule, WarehouseFeeRule,
BookingHandover,
]), ]),
BillingModule, BillingModule,
DocumentsModule, DocumentsModule,
@@ -113,6 +116,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseSchedulingAdapterService, WarehouseSchedulingAdapterService,
WarehouseReleaseDocumentService, WarehouseReleaseDocumentService,
SchedulingReadFacade, SchedulingReadFacade,
HandoverService,
], ],
exports: [ exports: [
WarehousesService, WarehousesService,

View File

@@ -20,8 +20,8 @@ const COMPANY_TIN = 'FLMDEMO001';
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local'; const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
const YARDS = [ const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
]; ];
const CONTAINER_TYPES = [ const CONTAINER_TYPES = [

View File

@@ -28,17 +28,22 @@ const COMPANY_EMAIL = "train-scheduling-demo@edr.local";
const COMPANY_TIN = "1234567890"; const COMPANY_TIN = "1234567890";
const YARDS = [ const YARDS = [
{ code: "DJIBOUTI", label: "Djibouti", country: "Djibouti", displayOrder: 1 }, {
code: "DJIBOUTI",
label: "Djibouti",
country: "Djibouti" as const,
displayOrder: 1,
},
{ {
code: "ADDIS_ABABA", code: "ADDIS_ABABA",
label: "Addis Ababa", label: "Addis Ababa",
country: "Ethiopia", country: "Ethiopia" as const,
displayOrder: 2, displayOrder: 2,
}, },
{ {
code: "DIRE_DAWA", code: "DIRE_DAWA",
label: "Dire Dawa", label: "Dire Dawa",
country: "Ethiopia", country: "Ethiopia" as const,
displayOrder: 3, displayOrder: 3,
}, },
]; ];

View File

@@ -16,8 +16,8 @@ const COMPANY_TIN = 'PAIDMILE001';
const COMPANY_EMAIL = 'paid-mile-demo@edr.local'; const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
const YARDS = [ const YARDS = [
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 }, { code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti' as const, displayOrder: 1 },
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 }, { code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia' as const, displayOrder: 2 },
]; ];
const CONTAINER_TYPES = [ const CONTAINER_TYPES = [

View File

@@ -19,6 +19,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { CountdownTimer } from "@edr/ui-common"; import { CountdownTimer } from "@edr/ui-common";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api"; import { api } from "@/services/api";
import type { StaffBookingWindow } from "@/types/trainScheduling"; import type { StaffBookingWindow } from "@/types/trainScheduling";
@@ -224,6 +225,9 @@ function WindowCard({ w }: { w: StaffBookingWindow }) {
* Hidden when nothing is pending. * Hidden when nothing is pending.
*/ */
export function GlUpcomingWindowsSection() { export function GlUpcomingWindowsSection() {
// Live pushes flip cards the moment the window engine transitions a phase;
// the 60s poll below stays only as a fallback.
useBookingWindowSocket();
const { data, isLoading } = useQuery( const { data, isLoading } = useQuery(
api.trainScheduling.allBookingWindows.queryOptions({ api.trainScheduling.allBookingWindows.queryOptions({
refetchInterval: 60_000, refetchInterval: 60_000,

View File

@@ -32,6 +32,7 @@ const DEFAULTS = {
docReviewMinutes: 30, docReviewMinutes: 30,
paymentWindowMinutes: 60, paymentWindowMinutes: 60,
importWindowLeadDays: 3, importWindowLeadDays: 3,
exportBookingLeadHours: 24,
}; };
/** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */ /** 12-hour label for an EAT hour 023, e.g. 8 → "8:00 AM", 17 → "5:00 PM". */
@@ -53,6 +54,7 @@ interface FormState {
docReviewMinutes: number | ""; docReviewMinutes: number | "";
paymentWindowMinutes: number | ""; paymentWindowMinutes: number | "";
importWindowLeadDays: number | ""; importWindowLeadDays: number | "";
exportBookingLeadHours: number | "";
} }
function parseError(error: unknown, fallback: string): string { function parseError(error: unknown, fallback: string): string {
@@ -112,6 +114,8 @@ export default function BookingWindowSettingsModal({
r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes, r?.paymentWindowMinutes ?? DEFAULTS.paymentWindowMinutes,
importWindowLeadDays: importWindowLeadDays:
r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays, r?.importWindowLeadDays ?? DEFAULTS.importWindowLeadDays,
exportBookingLeadHours:
r?.exportBookingLeadHours ?? DEFAULTS.exportBookingLeadHours,
}); });
}, [opened, schedule]); }, [opened, schedule]);
@@ -142,15 +146,20 @@ export default function BookingWindowSettingsModal({
const doc = Number(form.docReviewMinutes); const doc = Number(form.docReviewMinutes);
const pay = Number(form.paymentWindowMinutes); const pay = Number(form.paymentWindowMinutes);
const lead = Number(form.importWindowLeadDays); const lead = Number(form.importWindowLeadDays);
const exportLead = Number(form.exportBookingLeadHours);
const leadInvalid = isExport
? form.exportBookingLeadHours === "" ||
!Number.isFinite(exportLead) ||
exportLead < 1
: form.importWindowLeadDays === "" || !Number.isFinite(lead);
if ( if (
form.windowDurationHours === "" || form.windowDurationHours === "" ||
form.docReviewMinutes === "" || form.docReviewMinutes === "" ||
form.paymentWindowMinutes === "" || form.paymentWindowMinutes === "" ||
form.importWindowLeadDays === "" ||
!Number.isFinite(duration) || !Number.isFinite(duration) ||
!Number.isFinite(doc) || !Number.isFinite(doc) ||
!Number.isFinite(pay) || !Number.isFinite(pay) ||
!Number.isFinite(lead) leadInvalid
) { ) {
toast({ toast({
title: "Fill every field before saving", title: "Fill every field before saving",
@@ -164,7 +173,9 @@ export default function BookingWindowSettingsModal({
windowDurationHours: duration, windowDurationHours: duration,
docReviewMinutes: doc, docReviewMinutes: doc,
paymentWindowMinutes: pay, paymentWindowMinutes: pay,
importWindowLeadDays: lead, ...(isExport
? { exportBookingLeadHours: exportLead }
: { importWindowLeadDays: lead }),
}; };
try { try {
@@ -223,8 +234,10 @@ export default function BookingWindowSettingsModal({
<Stack gap="lg"> <Stack gap="lg">
{isExport ? ( {isExport ? (
<Alert variant="light" color="blue" icon={<Info size={16} />}> <Alert variant="light" color="blue" icon={<Info size={16} />}>
Export schedules use a single FCFS lead window the daily desk Export schedules use a single first-come-first-served window: it
hours below don't apply, only the lead time does. opens the export lead time before departure shifted to the next
desk opening if that lands outside desk hours and stays open
until departure. Cycle timing below doesn't apply.
</Alert> </Alert>
) : null} ) : null}
@@ -263,7 +276,6 @@ export default function BookingWindowSettingsModal({
} }
allowDeselect={false} allowDeselect={false}
comboboxProps={{ withinPortal: true }} comboboxProps={{ withinPortal: true }}
disabled={isExport}
/> />
<Select <Select
label="Closes" label="Closes"
@@ -275,7 +287,6 @@ export default function BookingWindowSettingsModal({
} }
allowDeselect={false} allowDeselect={false}
comboboxProps={{ withinPortal: true }} comboboxProps={{ withinPortal: true }}
disabled={isExport}
/> />
</Group> </Group>
{isOvernight && !is24h ? ( {isOvernight && !is24h ? (
@@ -290,7 +301,6 @@ export default function BookingWindowSettingsModal({
color="grape" color="grape"
label="Run 24 hours a day (never pause overnight)" label="Run 24 hours a day (never pause overnight)"
checked={is24h} checked={is24h}
disabled={isExport}
onChange={(e) => { onChange={(e) => {
const checked = e.currentTarget.checked; const checked = e.currentTarget.checked;
setForm((f) => { setForm((f) => {
@@ -304,12 +314,11 @@ export default function BookingWindowSettingsModal({
}); });
}} }}
/> />
{!isExport ? ( <Text size="xs" c="dimmed" mt={6}>
<Text size="xs" c="dimmed" mt={6}> {isExport
A not-yet-full train pauses at the close hour and resumes the next ? "If the export lead time lands while the desk is shut, booking opens at the next desk opening instead."
morning at the open hour, every day until it fills or departs. : "A not-yet-full train pauses at the close hour and resumes the next morning at the open hour, every day until it fills or departs."}
</Text> </Text>
) : null}
</Box> </Box>
<Divider /> <Divider />
@@ -367,27 +376,43 @@ export default function BookingWindowSettingsModal({
<Divider /> <Divider />
{/* ── Lead time ────────────────────────────────────────────────── */} {/* ── Lead time ────────────────────────────────────────────────── */}
<NumberInput {isExport ? (
label={isExport ? "Booking lead (days)" : "Window lead (days)"} <NumberInput
description={ label="Export booking lead (hours)"
isExport description="How many hours before departure the export booking window opens"
? "How many days before departure export booking opens" value={form.exportBookingLeadHours}
: "How many days before departure the booking window starts" onChange={(v) =>
} setForm(
value={form.importWindowLeadDays} (f) =>
onChange={(v) => f && {
setForm( ...f,
(f) => exportBookingLeadHours: v === "" ? "" : Number(v),
f && { },
...f, )
importWindowLeadDays: v === "" ? "" : Number(v), }
}, min={1}
) clampBehavior="none"
} allowDecimal={false}
min={0} />
clampBehavior="none" ) : (
allowDecimal={false} <NumberInput
/> label="Window lead (days)"
description="How many days before departure the booking window starts"
value={form.importWindowLeadDays}
onChange={(v) =>
setForm(
(f) =>
f && {
...f,
importWindowLeadDays: v === "" ? "" : Number(v),
},
)
}
min={0}
clampBehavior="none"
allowDecimal={false}
/>
)}
<Group justify="flex-end" mt="xs"> <Group justify="flex-end" mt="xs">
<Button variant="default" onClick={onClose} disabled={save.isPending}> <Button variant="default" onClick={onClose} disabled={save.isPending}>

View File

@@ -0,0 +1,376 @@
import { useState } from "react";
import {
Alert,
Badge,
Button,
Checkbox,
Group,
Loader,
Paper,
Stack,
Table,
Text,
Tooltip,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, ArrowRight, PackageCheck, PackageOpen, TrainFront } from "lucide-react";
import { api } from "@/services/api";
import { useToast } from "@/hooks/use-toast";
import type {
IntercityBookingRow,
IntercityCapacity,
} from "@/types/trainScheduling";
const parseError = (error: unknown, fallback: string) => {
const message = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data?.message;
if (Array.isArray(message)) return message.join("; ");
return message || (error as Error)?.message || fallback;
};
function fmt(n: number): string {
return Number.isInteger(n) ? String(n) : n.toFixed(1);
}
function CapacityBadges({ capacity }: { capacity: IntercityCapacity | null }) {
if (!capacity) {
return (
<Text size="sm" c="dimmed">
Capacity unknown schedule has no locomotive/train set yet.
</Text>
);
}
return (
<Group gap="xs">
<Badge variant="light" color={capacity.wagons > 0 ? "teal" : "red"}>
{fmt(capacity.wagons)} wagons free
</Badge>
<Badge variant="light" color={capacity.weightTons > 0 ? "teal" : "red"}>
{fmt(capacity.weightTons)} t free
</Badge>
<Badge variant="light" color={capacity.lengthMeters > 0 ? "teal" : "red"}>
{fmt(capacity.lengthMeters)} m free
</Badge>
</Group>
);
}
function NeedCells({ need }: { need: IntercityCapacity | null }) {
if (!need) return <Table.Td colSpan={3}></Table.Td>;
return (
<>
<Table.Td>{fmt(need.wagons)}</Table.Td>
<Table.Td>{fmt(need.weightTons)} t</Table.Td>
<Table.Td>{fmt(need.lengthMeters)} m</Table.Td>
</>
);
}
function CorridorCell({ row }: { row: IntercityBookingRow }) {
return (
<Group gap={6} wrap="nowrap">
<Text size="sm">{row.origin}</Text>
<ArrowRight size={13} />
<Text size="sm">{row.destination}</Text>
</Group>
);
}
/**
* Intercity ride-along desk for one import/export schedule: waiting intercity
* bookings whose corridor lies on this train's route, checked against the
* remaining wagon/weight/length budget. Accepting opens the customer's pay
* window; after payment the booking is allocated. Loading/unloading is
* confirmed manually when the train is physically at the booking's origin /
* destination yard (the server validates against recorded checkpoints).
*/
export function IntercityRideAlongPanel({
scheduleId,
direction,
}: {
scheduleId: string;
direction: string | null | undefined;
}) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const candidatesQuery = useQuery(
api.trainScheduling.intercityCandidates.queryOptions({
input: { scheduleId },
refetchInterval: 60_000,
}),
);
const invalidate = () =>
queryClient.invalidateQueries({
queryKey: api.trainScheduling.intercityCandidates.queryKey({ scheduleId }),
});
const accept = useMutation(
api.trainScheduling.acceptIntercityBookings.mutationOptions({
onSuccess: (result) => {
setSelected([]);
void invalidate();
if (result.accepted.length > 0) {
toast({
title: `${result.accepted.length} intercity booking(s) accepted`,
description: "Customers have been asked to pay.",
});
}
for (const r of result.rejected) {
toast({
title: "Booking skipped",
description: r.reason,
variant: "destructive",
});
}
},
onError: (err) =>
toast({
title: "Accept failed",
description: parseError(err, "Could not accept intercity bookings"),
variant: "destructive",
}),
}),
);
const load = useMutation(
api.trainScheduling.loadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo loaded" });
},
onError: (err) =>
toast({
title: "Load failed",
description: parseError(err, "Could not confirm loading"),
variant: "destructive",
}),
}),
);
const unload = useMutation(
api.trainScheduling.unloadIntercityBooking.mutationOptions({
onSuccess: () => {
void invalidate();
toast({ title: "Cargo unloaded — booking completed" });
},
onError: (err) =>
toast({
title: "Unload failed",
description: parseError(err, "Could not confirm unloading"),
variant: "destructive",
}),
}),
);
// Intercity bookings only ride import/export trains.
if (direction !== "IMPORT" && direction !== "EXPORT") return null;
const data = candidatesQuery.data;
const candidates = data?.candidates ?? [];
const accepted = data?.accepted ?? [];
if (candidatesQuery.isLoading) {
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Group gap="xs">
<Loader size="xs" />
<Text size="sm" c="dimmed">
Loading intercity ride-along bookings
</Text>
</Group>
</Paper>
);
}
if (candidates.length === 0 && accepted.length === 0) return null;
return (
<Paper withBorder radius="lg" p="lg" mt="md">
<Stack gap="md">
<Group justify="space-between" wrap="wrap">
<Group gap="xs">
<TrainFront size={18} />
<Text fw={700}>Intercity ride-along</Text>
</Group>
<CapacityBadges capacity={data?.remaining ?? null} />
</Group>
{candidates.length > 0 && (
<>
<Text size="sm" c="dimmed">
Waiting intercity bookings whose corridor lies on this train's
route. Accepting opens the customer's payment window against the
free capacity above.
</Text>
<Table.ScrollContainer minWidth={720}>
<Table verticalSpacing="xs" highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th w={36} />
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Wagons</Table.Th>
<Table.Th>Weight</Table.Th>
<Table.Th>Length</Table.Th>
<Table.Th>Fits</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{candidates.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Checkbox
size="xs"
checked={selected.includes(row.id)}
onChange={(e) =>
setSelected((prev) =>
e.currentTarget.checked
? [...prev, row.id]
: prev.filter((id) => id !== row.id),
)
}
/>
</Table.Td>
<Table.Td>
<Group gap={6}>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
{row.isGovernment && (
<Badge size="xs" variant="light" color="grape">
GOV
</Badge>
)}
</Group>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<NeedCells need={row.need} />
<Table.Td>
{row.fits ? (
<Badge size="sm" variant="light" color="teal">
Fits
</Badge>
) : (
<Tooltip label="Exceeds the remaining wagon/weight/length budget">
<Badge size="sm" variant="light" color="red">
No room
</Badge>
</Tooltip>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Group justify="flex-end">
<Button
size="xs"
color="edr-green"
loading={accept.isPending}
disabled={selected.length === 0}
onClick={() => accept.mutate({ scheduleId, bookingIds: selected })}
>
Accept {selected.length > 0 ? `${selected.length} ` : ""}onto this train
</Button>
</Group>
</>
)}
{accepted.length > 0 && (
<>
<Text size="sm" fw={600}>
On this train
</Text>
<Table.ScrollContainer minWidth={680}>
<Table verticalSpacing="xs">
<Table.Thead>
<Table.Tr>
<Table.Th>Booking</Table.Th>
<Table.Th>Customer</Table.Th>
<Table.Th>Corridor</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{accepted.map((row) => (
<Table.Tr key={row.id}>
<Table.Td>
<Text size="sm" fw={600}>
{row.reference ?? row.id.slice(0, 8)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{row.customer}</Text>
</Table.Td>
<Table.Td>
<CorridorCell row={row} />
</Table.Td>
<Table.Td>
<Badge size="sm" variant="light">
{row.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end">
{row.status === "PAID" && (
<Tooltip label="Train must be at the booking's origin yard">
<Button
size="compact-xs"
variant="light"
leftSection={<PackageCheck size={13} />}
loading={load.isPending}
onClick={() =>
load.mutate({ scheduleId, bookingId: row.id })
}
>
Load
</Button>
</Tooltip>
)}
{row.status === "IN_TRANSIT" && (
<Tooltip label="Train must be at the booking's destination yard">
<Button
size="compact-xs"
variant="light"
color="orange"
leftSection={<PackageOpen size={13} />}
loading={unload.isPending}
onClick={() =>
unload.mutate({ scheduleId, bookingId: row.id })
}
>
Unload
</Button>
</Tooltip>
)}
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</>
)}
{candidatesQuery.isError && (
<Alert color="red" variant="light" icon={<AlertCircle size={16} />}>
{parseError(candidatesQuery.error, "Could not load intercity candidates")}
</Alert>
)}
</Stack>
</Paper>
);
}

View File

@@ -61,6 +61,7 @@ import type {
} from '@/types/warehouse'; } from '@/types/warehouse';
import { BookingSelect } from './BookingSelect'; import { BookingSelect } from './BookingSelect';
import { DeliverInventoryModal } from './DeliverInventoryModal'; import { DeliverInventoryModal } from './DeliverInventoryModal';
import { TruckDispatchModal } from './TruckDispatchModal';
import { FeePreviewModal } from './FeePreviewModal'; import { FeePreviewModal } from './FeePreviewModal';
import { InspectionReportModal } from './InspectionReportModal'; import { InspectionReportModal } from './InspectionReportModal';
import { InventoryDetailModal } from './InventoryDetailModal'; import { InventoryDetailModal } from './InventoryDetailModal';
@@ -2151,7 +2152,6 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
); );
const storeMutation = useMutation(api.warehouses.store.mutationOptions()); const storeMutation = useMutation(api.warehouses.store.mutationOptions());
const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions()); const readyMutation = useMutation(api.warehouses.markReadyForPickup.mutationOptions());
const dispatchMutation = useMutation(api.warehouses.dispatch.mutationOptions());
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [inspectId, setInspectId] = useState<string | null>(null); const [inspectId, setInspectId] = useState<string | null>(null);
const [busyId, setBusyId] = useState<string | null>(null); const [busyId, setBusyId] = useState<string | null>(null);
@@ -2160,6 +2160,7 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null); const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null); const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null); const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
const [loadTruckItem, setLoadTruckItem] = useState<WarehouseInventoryItem | null>(null);
const allSelected = rows.length > 0 && selected.size === rows.length; const allSelected = rows.length > 0 && selected.size === rows.length;
const someSelected = selected.size > 0 && !allSelected; const someSelected = selected.size > 0 && !allSelected;
@@ -2419,9 +2420,9 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
variant="light" variant="light"
color="green" color="green"
loading={busyId === r.id} loading={busyId === r.id}
onClick={() => runRowAction(r, 'Inventory dispatched', () => dispatchMutation.mutateAsync(r.id))} onClick={() => setLoadTruckItem(toInventoryItem(r))}
> >
Dispatch Truck_dispatch
</Button> </Button>
)} )}
{r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && ( {r.currentStatus === 'READY_FOR_PICKUP' && r.releaseDate && (
@@ -2493,6 +2494,12 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
/> />
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} /> <ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} /> <DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
<TruckDispatchModal
opened={Boolean(loadTruckItem)}
onClose={() => setLoadTruckItem(null)}
bookingId={loadTruckItem?.booking?.id ?? null}
bookingReference={loadTruckItem?.booking?.reference ?? null}
/>
</Stack> </Stack>
); );
} }

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core'; import { Alert, Button, Group, Modal, NumberInput, Select, SimpleGrid, Stack, Text, TextInput } from '@mantine/core';
import { Info, Scale } from 'lucide-react'; import { Info, Scale } from 'lucide-react';
import { useMutation } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { api } from '@/services/api'; import { api } from '@/services/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
@@ -134,6 +134,13 @@ const parseInspectionNote = (notes: string | null | undefined) => {
export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) { export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: ReleaseOrderModalProps) {
const { toast } = useToast(); const { toast } = useToast();
const releaseMutation = useMutation(api.warehouses.release.mutationOptions()); const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
const bookingId = item?.booking?.id;
// Customer self-haul trucks assigned to this booking via the portal.
const { data: customerTrucks = [] } = useQuery({
queryKey: ['release-customer-trucks', bookingId],
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const [reference, setReference] = useState(''); const [reference, setReference] = useState('');
const [truckPlateNumber, setTruckPlateNumber] = useState(''); const [truckPlateNumber, setTruckPlateNumber] = useState('');
const [trailerPlateNumber, setTrailerPlateNumber] = useState(''); const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
@@ -178,6 +185,44 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
const isEntranceLocked = isExitStep; const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt); const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber); const hasLastMileTruckPrefill = Boolean(truckPrefill?.truckPlateNumber || truckPrefill?.trailerPlateNumber);
// Registered trucks for THIS booking, from both sources: EDR last-mile
// (truckPrefill) and the customer portal (customer_truck_assignments).
const assignedTruckOptions = [
...(truckPrefill?.truckPlateNumber
? [
{
value: truckPrefill.truckPlateNumber,
label: `Last-mile · ${truckPrefill.truckPlateNumber}`,
trailerPlate: truckPrefill.trailerPlateNumber ?? '',
driverName: truckPrefill.driverName ?? '',
driverPhone: truckPrefill.driverPhone ?? '',
truckType: truckPrefill.truckType ?? '',
},
]
: []),
...customerTrucks.map((t) => ({
value: t.plateNumber,
label: `Customer · ${t.plateNumber}${t.driverName}`,
trailerPlate: '',
driverName: t.driverName,
driverPhone: '',
truckType: t.truckType,
})),
];
const truckSelectOptions = [
...assignedTruckOptions,
...REGISTERED_FIRST_LAST_MILE_TRUCKS.map((t) => ({
value: t.value,
label: t.label,
trailerPlate: t.trailerPlate,
driverName: '',
driverPhone: '',
truckType: '',
})),
];
// Neither a last-mile truck nor a customer truck has been assigned yet.
const noTruckAssigned = assignedTruckOptions.length === 0 && !isCustomerAssignedTruck;
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill; const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck || hasLastMileTruckPrefill;
const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName); const isDriverNameLocked = isEntranceLocked || isCustomerAssignedTruck || Boolean(truckPrefill?.driverName);
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight); const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
@@ -286,18 +331,26 @@ export function ReleaseOrderModal({ opened, onClose, item, truckPrefill }: Relea
onChange={(e) => setReference(e.currentTarget.value)} onChange={(e) => setReference(e.currentTarget.value)}
readOnly={isEntranceLocked} readOnly={isEntranceLocked}
/> />
{noTruckAssigned && (
<Alert color="orange" variant="light" icon={<Info size={16} />}>
Truck is not assigned yet assign a last-mile or customer truck, or enter the plate manually below.
</Alert>
)}
<Select <Select
label="Registered first / last-mile truck" label="Registered first / last-mile truck"
placeholder="Select truck or type plate manually below" placeholder="Select truck or type plate manually below"
searchable searchable
clearable clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS} data={truckSelectOptions}
disabled={isTruckIdentityLocked} disabled={isTruckIdentityLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null} value={truckSelectOptions.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => { onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value); const truck = truckSelectOptions.find((row) => row.value === value);
setTruckPlateNumber(truck?.value ?? ''); setTruckPlateNumber(truck?.value ?? '');
setTrailerPlateNumber(truck?.trailerPlate ?? ''); setTrailerPlateNumber(truck?.trailerPlate ?? '');
if (truck?.driverName) setDriverName(truck.driverName);
if (truck?.driverPhone) setDriverPhone(truck.driverPhone);
if (truck?.truckType) setTruckType(truck.truckType);
}} }}
/> />
<Group grow> <Group grow>

View File

@@ -0,0 +1,147 @@
import { Alert, Badge, Button, Group, Loader, Modal, MultiSelect, Stack, Text } from '@mantine/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { FileText, Truck } from 'lucide-react';
import { useState } from 'react';
import { useToast } from '@/hooks/use-toast';
import { warehouseService } from '@/services/warehouse.service';
import { extractErrorMessage } from './options';
import { openPdfBlob } from './pdf';
interface TruckDispatchModalProps {
opened: boolean;
onClose: () => void;
bookingId: string | null;
bookingReference?: string | null;
}
/**
* Truck_dispatch: after a self-haul truck arrives, staff select which of the
* booking's containers ride each truck. The loaded set drives the truck's gross
* weight; the truck is weighed for real on departure.
*/
export function TruckDispatchModal({ opened, onClose, bookingId, bookingReference }: TruckDispatchModalProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedByTruck, setSelectedByTruck] = useState<Record<string, string[]>>({});
const trucksKey = ['td-customer-trucks', bookingId];
const loadableKey = ['td-loadable', bookingId];
const { data: trucks = [], isLoading: trucksLoading } = useQuery({
queryKey: trucksKey,
queryFn: () => warehouseService.getCustomerTrucks(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const { data: loadable = [], isLoading: loadableLoading } = useQuery({
queryKey: loadableKey,
queryFn: () => warehouseService.getLoadableContainers(bookingId as string),
enabled: opened && Boolean(bookingId),
});
const loadMutation = useMutation({
mutationFn: ({ assignmentId, containerNumbers }: { assignmentId: string; containerNumbers: string[] }) =>
warehouseService.loadTruck(bookingId as string, assignmentId, containerNumbers),
onSuccess: (_res, vars) => {
queryClient.invalidateQueries({ queryKey: trucksKey });
queryClient.invalidateQueries({ queryKey: loadableKey });
setSelectedByTruck((s) => ({ ...s, [vars.assignmentId]: [] }));
toast({ title: 'Truck loaded' });
},
onError: (e) => toast({ variant: 'destructive', title: 'Load failed', description: extractErrorMessage(e) }),
});
const openTruckExitPaper = async (assignmentId: string, plate: string) => {
try {
const res = await warehouseService.downloadTruckExitPaper(assignmentId);
openPdfBlob(res.data, `exit-${plate}.pdf`);
} catch (e) {
toast({ variant: 'destructive', title: 'Exit paper not ready', description: extractErrorMessage(e) });
}
};
return (
<Modal
opened={opened}
onClose={onClose}
centered
size="lg"
title={
<Group gap={8}>
<Truck size={18} />
<Text fw={700}>Truck_dispatch load containers {bookingReference ? `· ${bookingReference}` : ''}</Text>
</Group>
}
>
{trucksLoading || loadableLoading ? (
<Group justify="center" py="lg">
<Loader />
</Group>
) : trucks.length === 0 ? (
<Alert color="orange" variant="light">
No customer truck is assigned to this booking yet.
</Alert>
) : (
<Stack gap="md">
{trucks.map((t) => {
const alreadyLoaded = (t.containers ?? []).map((c) => c.containerNumber);
// Options = still-loadable + this truck's own already-loaded (so they stay visible).
const options = Array.from(new Set([...loadable, ...alreadyLoaded]));
const selected = selectedByTruck[t.id] ?? alreadyLoaded;
const departed = Boolean(t.arrivedAt) && Boolean((t as { departedAt?: string }).departedAt);
return (
<Stack
key={t.id}
gap={8}
style={{ border: '1px solid #EEF2F6', borderRadius: 12, padding: 14 }}
>
<Group justify="space-between">
<Text fw={700}>{t.plateNumber}</Text>
<Group gap={6}>
<Text size="sm" c="dimmed">{t.driverName} · {t.truckType}</Text>
{t.arrivedAt ? <Badge color="green" variant="light">Arrived</Badge> : <Badge color="orange" variant="light">Not arrived</Badge>}
</Group>
</Group>
<MultiSelect
label="Containers on this truck"
placeholder="Select containers"
data={options}
value={selected}
onChange={(v) => setSelectedByTruck((s) => ({ ...s, [t.id]: v }))}
searchable
disabled={departed || !t.arrivedAt}
nothingFoundMessage="No loadable containers"
/>
<Group justify="flex-end" gap="xs">
<Button
size="xs"
variant="light"
color="orange"
leftSection={<FileText size={14} />}
onClick={() => openTruckExitPaper(t.id, t.plateNumber)}
>
Exit Paper
</Button>
<Button
size="xs"
color="edr-green"
disabled={departed || !t.arrivedAt || (selectedByTruck[t.id] ?? alreadyLoaded).length === 0}
loading={loadMutation.isPending}
onClick={() =>
loadMutation.mutate({
assignmentId: t.id,
containerNumbers: selectedByTruck[t.id] ?? alreadyLoaded,
})
}
>
Load truck
</Button>
</Group>
</Stack>
);
})}
</Stack>
)}
</Modal>
);
}

View File

@@ -324,6 +324,14 @@ export const URL_CONSTANTS = {
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`, PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`, FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`, DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
INTERCITY_CANDIDATES: (id: string) =>
`/train-scheduling/schedules/${id}/intercity-candidates`,
INTERCITY_ACCEPT: (id: string) =>
`/train-scheduling/schedules/${id}/intercity/accept`,
INTERCITY_LOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/intercity/${bookingId}/load`,
INTERCITY_UNLOAD: (id: string, bookingId: string) =>
`/train-scheduling/schedules/${id}/intercity/${bookingId}/unload`,
IMPORT_LOADING_BOOKINGS: (id: string) => IMPORT_LOADING_BOOKINGS: (id: string) =>
`/train-scheduling/schedules/${id}/import-loading-bookings`, `/train-scheduling/schedules/${id}/import-loading-bookings`,
IMPORT_LOADING_STATUS: (id: string) => IMPORT_LOADING_STATUS: (id: string) =>

View File

@@ -0,0 +1,55 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
import { AUTH_TOKEN_COOKIE, getCookie } from "@/auth/cookies";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
// The socket namespace lives at the server root, not under the `/api` REST
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes to live booking-window pushes for staff. Every phase transition
* the window engine applies invalidates the GL windows carousel and the batch
* board, so both flip the moment the backend does — polling stays only as a
* fallback.
*/
export function useBookingWindowSocket(enabled: boolean = true) {
const qc = useQueryClient();
useEffect(() => {
if (!enabled) return;
const token = getCookie(AUTH_TOKEN_COOKIE);
if (!token) return;
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token },
transports: ["websocket"],
withCredentials: true,
});
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {
qc.invalidateQueries({
queryKey: ["train-scheduling", "all-booking-windows"],
});
qc.invalidateQueries({
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.batchBoard(),
});
},
);
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -48,6 +48,7 @@ import {
} from "@/components/trainScheduling/containerPlacement.util"; } from "@/components/trainScheduling/containerPlacement.util";
import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid"; import { ContainerPlacementGrid } from "@/components/trainScheduling/ContainerPlacementGrid";
import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary"; import { FleetAvailabilitySummary } from "@/components/trainScheduling/FleetAvailabilitySummary";
import { IntercityRideAlongPanel } from "@/components/trainScheduling/IntercityRideAlongPanel";
// import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel"; // import { ImportLoadingConfirmationPanel } from "@/components/trainScheduling/ImportLoadingConfirmationPanel";
import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog"; import { RescheduleTrainDialog } from "@/components/trainScheduling/RescheduleTrainDialog";
import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal"; import BookingWindowSettingsModal from "@/components/trainScheduling/BookingWindowSettingsModal";
@@ -1130,6 +1131,12 @@ export default function TrainScheduleV2DetailPage() {
void detailQuery.refetch(); void detailQuery.refetch();
}} }}
/> />
{scheduleId ? (
<IntercityRideAlongPanel
scheduleId={scheduleId}
direction={schedule.direction}
/>
) : null}
</Tabs.Panel> </Tabs.Panel>
</Tabs> </Tabs>

View File

@@ -111,7 +111,12 @@ export default function TrainScheduleV2ListPage() {
const create = useMutation(api.trainScheduling.createSchedule.mutationOptions()); const create = useMutation(api.trainScheduling.createSchedule.mutationOptions());
const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions()); const cancel = useMutation(api.trainScheduling.cancelSchedule.mutationOptions());
const activeRoutes = useMemo(() => routesQuery.data ?? [], [routesQuery.data]); // Intercity (same-country / DOMESTIC) routes cannot be scheduled yet — the
// API rejects them, so keep them out of the picker entirely.
const activeRoutes = useMemo(
() => (routesQuery.data ?? []).filter((r) => r.direction !== "DOMESTIC"),
[routesQuery.data],
);
const selectedRoute = activeRoutes.find((r) => r.id === routeId); const selectedRoute = activeRoutes.find((r) => r.id === routeId);
@@ -380,7 +385,11 @@ export default function TrainScheduleV2ListPage() {
} }
try { try {
const created = await create.mutateAsync({ const created = await create.mutateAsync({
payload: { routeId, scheduleDate, locomotiveIds }, payload: {
routeId,
scheduleDate: new Date(scheduleDate).toISOString(),
locomotiveIds,
},
}); });
toast({ title: "Train schedule created" }); toast({ title: "Train schedule created" });
showScheduleWarnings(created.warnings); showScheduleWarnings(created.warnings);
@@ -570,11 +579,8 @@ export default function TrainScheduleV2ListPage() {
<TextInput <TextInput
label="Departure date" label="Departure date"
type="datetime-local" type="datetime-local"
value={scheduleDate ? scheduleDate.slice(0, 16) : ""} value={scheduleDate}
onChange={(e) => { onChange={(e) => setScheduleDate(e.currentTarget.value)}
const raw = e.currentTarget.value;
setScheduleDate(raw ? new Date(raw).toISOString() : "");
}}
/> />
<MultiSelect <MultiSelect
label="Locomotives" label="Locomotives"

View File

@@ -593,6 +593,52 @@ export const api = {
() => TRAIN_SCHEDULING_INVALIDATIONS, () => TRAIN_SCHEDULING_INVALIDATIONS,
), ),
intercityCandidates: endpoint<
{ scheduleId: string },
import("@/types/trainScheduling").IntercityCandidatesResult
>(
"train-scheduling",
"intercity-candidates",
({ scheduleId }) => trainSchedulingService.getIntercityCandidates(scheduleId),
({ scheduleId }) => ["train-scheduling", "intercity-candidates", scheduleId],
),
acceptIntercityBookings: endpoint<
{ scheduleId: string; bookingIds: string[] },
import("@/types/trainScheduling").IntercityAcceptResult
>(
"train-scheduling",
"intercity-accept",
({ scheduleId, bookingIds }) =>
trainSchedulingService.acceptIntercityBookings(scheduleId, bookingIds),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
loadIntercityBooking: endpoint<
{ scheduleId: string; bookingId: string },
void
>(
"train-scheduling",
"intercity-load",
({ scheduleId, bookingId }) =>
trainSchedulingService.loadIntercityBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
unloadIntercityBooking: endpoint<
{ scheduleId: string; bookingId: string },
void
>(
"train-scheduling",
"intercity-unload",
({ scheduleId, bookingId }) =>
trainSchedulingService.unloadIntercityBooking(scheduleId, bookingId),
undefined,
() => TRAIN_SCHEDULING_INVALIDATIONS,
),
cancelSchedule: endpoint< cancelSchedule: endpoint<
{ id: string; freightType?: FreightType }, { id: string; freightType?: FreightType },
TrainScheduleDetail TrainScheduleDetail

View File

@@ -4,6 +4,9 @@ import { URL_CONSTANTS } from '@/constants/URLS';
export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING'; export type RouteStatus = 'AVAILABLE' | 'MAINTENANCE' | 'DAMAGED' | 'STOP_WORKING';
/** Frozen from yard countries: ET→DJ EXPORT, DJ→ET IMPORT, same country DOMESTIC (intercity, disabled). */
export type RouteDirection = 'IMPORT' | 'EXPORT' | 'DOMESTIC';
export interface YardRef { export interface YardRef {
id: string; id: string;
code: string; code: string;
@@ -23,6 +26,7 @@ export interface RouteMilestone {
export interface RouteRecord { export interface RouteRecord {
id: string; id: string;
status: RouteStatus; status: RouteStatus;
direction?: RouteDirection;
originYardId: string; originYardId: string;
destinationYardId: string; destinationYardId: string;
originYard?: YardRef | null; originYard?: YardRef | null;

View File

@@ -17,6 +17,8 @@ import type {
ImportDjiboutiLoadList, ImportDjiboutiLoadList,
ImportDjiboutiOperation, ImportDjiboutiOperation,
ImportLoadingBookingsResponse, ImportLoadingBookingsResponse,
IntercityAcceptResult,
IntercityCandidatesResult,
LoadingStatus, LoadingStatus,
LocomotiveRecord, LocomotiveRecord,
PinWagonsPayload, PinWagonsPayload,
@@ -328,6 +330,46 @@ export const trainSchedulingService = {
return unwrap(response.data); return unwrap(response.data);
}, },
getIntercityCandidates: async (
scheduleId: string,
): Promise<IntercityCandidatesResult> => {
const response = await client.get<IntercityCandidatesResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_CANDIDATES(scheduleId),
);
return unwrap(response.data);
},
acceptIntercityBookings: async (
scheduleId: string,
bookingIds: string[],
): Promise<IntercityAcceptResult> => {
const response = await client.post<IntercityAcceptResult>(
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_ACCEPT(scheduleId),
{ bookingIds },
);
return unwrap(response.data);
},
loadIntercityBooking: async (
scheduleId: string,
bookingId: string,
): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_LOAD(scheduleId, bookingId),
{},
);
},
unloadIntercityBooking: async (
scheduleId: string,
bookingId: string,
): Promise<void> => {
await client.post(
URL_CONSTANTS.TRAIN_SCHEDULING.INTERCITY_UNLOAD(scheduleId, bookingId),
{},
);
},
dispatchSchedule: async ( dispatchSchedule: async (
scheduleId: string, scheduleId: string,
): Promise<TrainScheduleDetail> => { ): Promise<TrainScheduleDetail> => {

View File

@@ -1,3 +1,5 @@
import type { Freight } from '@edr/types';
import { api as apiClient } from '../auth/http'; import { api as apiClient } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS'; import { URL_CONSTANTS } from '@/constants/URLS';
@@ -67,6 +69,33 @@ const cleanParams = (params: object) =>
); );
export const warehouseService = { export const warehouseService = {
/** Customer self-haul trucks assigned to a booking (portal multi-truck). */
getCustomerTrucks: async (bookingId: string): Promise<Freight.ICustomerTruck[]> => {
const { data } = await apiClient.get(`/bookings/${bookingId}/customer-trucks`);
return data?.data ?? data ?? [];
},
/** Booking container numbers not yet loaded onto any truck. */
getLoadableContainers: async (bookingId: string): Promise<string[]> => {
const { data } = await apiClient.get(
`/bookings/${bookingId}/customer-trucks/loadable-containers`,
);
return data?.data ?? data ?? [];
},
/** Truck_dispatch: load selected containers onto a truck (after arrival). */
loadTruck: async (
bookingId: string,
assignmentId: string,
containerNumbers: string[],
): Promise<Freight.ICustomerTruck[]> => {
const { data } = await apiClient.post(
`/bookings/${bookingId}/customer-trucks/${assignmentId}/load`,
{ containerNumbers },
);
return data?.data ?? data ?? [];
},
// ── Warehouses ────────────────────────────────────────────────────────── // ── Warehouses ──────────────────────────────────────────────────────────
list: (filter?: WarehouseFilter) => list: (filter?: WarehouseFilter) =>
apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, { apiClient.get<Warehouse[]>(URL_CONSTANTS.WAREHOUSES.BASE, {
@@ -147,6 +176,11 @@ export const warehouseService = {
apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), { apiClient.get<Blob>(URL_CONSTANTS.WAREHOUSE_INVENTORY.HANDOVER_DOCUMENT(id), {
responseType: 'blob', responseType: 'blob',
}), }),
/** Per-truck exit paper PDF (containers loaded on one customer truck). */
downloadTruckExitPaper: (assignmentId: string) =>
apiClient.get<Blob>(`/warehouse-inventory/customer-truck-exit-paper/${assignmentId}`, {
responseType: 'blob',
}),
deliver: (id: string, payload: DeliverInventoryPayload) => deliver: (id: string, payload: DeliverInventoryPayload) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload), apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),

View File

@@ -407,6 +407,7 @@ export interface ScheduleWindowRule {
windowDurationHours: number | null; windowDurationHours: number | null;
reopenDelayMinutes: number | null; reopenDelayMinutes: number | null;
importWindowLeadDays: number | null; importWindowLeadDays: number | null;
exportBookingLeadHours: number | null;
/** Live global values (not snapshotted per schedule) — editor prefill baseline. */ /** Live global values (not snapshotted per schedule) — editor prefill baseline. */
docReviewMinutes: number; docReviewMinutes: number;
paymentWindowMinutes: number; paymentWindowMinutes: number;
@@ -420,6 +421,7 @@ export interface UpdateScheduleWindowRulePayload {
docReviewMinutes?: number; docReviewMinutes?: number;
paymentWindowMinutes?: number; paymentWindowMinutes?: number;
importWindowLeadDays?: number; importWindowLeadDays?: number;
exportBookingLeadHours?: number;
} }
export interface TrainScheduleDetail { export interface TrainScheduleDetail {
@@ -746,3 +748,49 @@ export interface CompositionRemovalEntry {
removedAt: string; removedAt: string;
notes: string | null; notes: string | null;
} }
// ── Intercity ride-along ─────────────────────────────────────────────────────
// Intercity (DOMESTIC) bookings have no train of their own — they ride a
// passing import/export schedule whose route milestones contain the booking's
// origin before its destination. Staff accept them at finalize time against
// the train's remaining wagon/weight/length capacity.
export interface IntercityCapacity {
wagons: number;
weightTons: number;
lengthMeters: number;
}
export interface IntercityBookingRow {
id: string;
reference: string | null;
status: string;
freightType: FreightType | null;
isGovernment: boolean;
customer: string;
originYardId: string;
destinationYardId: string;
origin: string;
destination: string;
weightTons: number;
paymentDeadline: string | null;
need: IntercityCapacity | null;
}
export interface IntercityCandidateRow extends IntercityBookingRow {
fits: boolean;
}
export interface IntercityCandidatesResult {
scheduleId: string;
routeId: string | null;
remaining: IntercityCapacity | null;
candidates: IntercityCandidateRow[];
accepted: IntercityBookingRow[];
}
export interface IntercityAcceptResult {
accepted: string[];
rejected: Array<{ bookingId: string; reason: string }>;
remaining: IntercityCapacity;
}

View File

@@ -0,0 +1,60 @@
import {
BOOKING_WINDOW_WS_EVENTS,
BOOKING_WINDOW_WS_NAMESPACE,
type BookingWindowPhaseEvent,
} from "@edr/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react";
import { io } from "socket.io-client";
import { API_BASE_URL } from "@/constants/apiConfig";
function getAuthToken(): string | undefined {
return document.cookie
.split("; ")
.find((row) => row.startsWith("auth-token="))
?.split("=")[1];
}
// The socket namespace lives at the server root, not under the `/api` REST
// prefix — strip a trailing `/api` if the base URL carries one.
const SOCKET_ORIGIN = String(API_BASE_URL ?? "").replace(/\/api\/?$/, "");
/**
* Subscribes to live booking-window pushes. Every phase transition the window
* engine applies (open, doc review, payment, reopen, done) invalidates the
* cached window lists, so the home-page "Booking Windows" card flips the
* moment the backend does — the 60s poll remains only as a fallback.
*/
export function useBookingWindowSocket(enabled: boolean) {
const qc = useQueryClient();
useEffect(() => {
if (!enabled) return;
const token = getAuthToken();
if (!token) return;
const socket = io(`${SOCKET_ORIGIN}/${BOOKING_WINDOW_WS_NAMESPACE}`, {
auth: { token },
transports: ["websocket"],
withCredentials: true,
});
socket.on(
BOOKING_WINDOW_WS_EVENTS.PHASE,
(_event: BookingWindowPhaseEvent) => {
qc.invalidateQueries({
queryKey: ["train-scheduling", "myBookingWindows"],
});
qc.invalidateQueries({
queryKey: ["train-scheduling", "contractBookingWindows"],
});
},
);
return () => {
socket.off();
socket.disconnect();
};
}, [enabled, qc]);
}

View File

@@ -1,12 +1,17 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Freight } from "@edr/types"; import { Freight } from "@edr/types";
import useAuth from "@/hooks/useAuth"; import useAuth from "@/hooks/useAuth";
import { useBookingWindowSocket } from "@/features/bookingWindows/useBookingWindowSocket";
import { api } from "@/services/api"; import { api } from "@/services/api";
import { ACTIVE_STATUSES } from "./constants"; import { ACTIVE_STATUSES } from "./constants";
export function useMyPortalData(selectedProfileId?: string) { export function useMyPortalData(selectedProfileId?: string) {
const { user, customer, company } = useAuth(); const { user, customer, company } = useAuth();
// Live booking-window pushes: a phase transition on any lane invalidates the
// windows queries below the moment it happens (poll below is only a fallback).
useBookingWindowSocket(Boolean(user));
const invoicesQuery = useQuery(api.invoices.listMy.queryOptions()); const invoicesQuery = useQuery(api.invoices.listMy.queryOptions());
const myInvoices = invoicesQuery.data ?? []; const myInvoices = invoicesQuery.data ?? [];

View File

@@ -211,7 +211,10 @@ export default function ContractDetailPage() {
}), }),
enabled: !!id, enabled: !!id,
}); });
const bookingWindowOpen = hasOpenWindow(bookingWindows); // Intercity contracts are never window-gated: the shipment rides a passing
// import/export train that staff assign later, so booking is always open.
const bookingWindowOpen =
contract?.tradeDirection === "DOMESTIC" || hasOpenWindow(bookingWindows);
// Draw-down capacity per cargo line (GENERAL contracts only). The backend // Draw-down capacity per cargo line (GENERAL contracts only). The backend
// excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships // excludes CANCELLED/REJECTED/EXPIRED bookings, so a shipment that never ships

View File

@@ -128,8 +128,10 @@ export default function NewShipmentPage() {
// Coarse gate: if the customer deep-links here while no booking window is // Coarse gate: if the customer deep-links here while no booking window is
// open, show the same closed-state notice as the contract page instead of the // open, show the same closed-state notice as the contract page instead of the
// form. Still allowed the moment any window isOpenNow. // form. Still allowed the moment any window isOpenNow. Intercity contracts
if (!hasOpenWindow(bookingWindows)) { // are never window-gated — the shipment rides a passing train that staff
// pick at finalize time, so booking is always open.
if (contract.tradeDirection !== "DOMESTIC" && !hasOpenWindow(bookingWindows)) {
return ( return (
<Box style={{ padding: "28px 0 0" }}> <Box style={{ padding: "28px 0 0" }}>
<Group <Group
@@ -213,6 +215,8 @@ function NewShipmentBookingForm({
isHazardous: contract.isHazardous ?? false, isHazardous: contract.isHazardous ?? false,
isReefer: contract.isReefer ?? false, isReefer: contract.isReefer ?? false,
unitOfMeasure: bulkUnitOfMeasure(contract), unitOfMeasure: bulkUnitOfMeasure(contract),
// Intercity rides a passing train staff pick later — no date to choose.
requiresDate: contract.tradeDirection !== "DOMESTIC",
}), }),
), ),
mode: "onChange", mode: "onChange",
@@ -246,7 +250,10 @@ function NewShipmentBookingForm({
...(values.contractRouteId ...(values.contractRouteId
? { contractRouteId: values.contractRouteId } ? { contractRouteId: values.contractRouteId }
: {}), : {}),
scheduledDate: new Date(values.scheduledDate).toISOString(), // Intercity bookings carry no date — staff assign a passing train later.
...(values.scheduledDate
? { scheduledDate: new Date(values.scheduledDate).toISOString() }
: {}),
...(isContainer ...(isContainer
? { ? {
containers: values.containers containers: values.containers
@@ -793,13 +800,31 @@ function ScheduleStep({
contract.pricingBreakdown, contract.pricingBreakdown,
]); ]);
const isIntercity = contract.tradeDirection === "DOMESTIC";
const { data: availableDays, isLoading } = useQuery({ const { data: availableDays, isLoading } = useQuery({
...api.bookings.getAvailableDaysForCargo.queryOptions({ ...api.bookings.getAvailableDaysForCargo.queryOptions({
input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery), input: cargoQuery ?? ({ freightType: "BULK" } as Freight.AvailableDaysForCargoQuery),
}), }),
enabled: cargoQuery !== null, enabled: cargoQuery !== null && !isIntercity,
}); });
if (isIntercity) {
return (
<StepCard>
<StepHeader
icon={<CalendarDays size={22} />}
title="Schedule"
description="Intercity shipments have no fixed day."
/>
<Alert color="blue" variant="light" radius="md" icon={<AlertCircle size={16} />}>
Your shipment rides the next import/export train passing through your
corridor. Operations assign it to a train with free capacity you
will be notified when it is accepted and payment is due.
</Alert>
</StepCard>
);
}
return ( return (
<StepCard> <StepCard>
<StepHeader <StepHeader

View File

@@ -32,6 +32,8 @@ export const OPERATION_TYPE_OPTIONS: Array<{
value: OperationType; value: OperationType;
label: string; label: string;
description: string; description: string;
/** Shown but not selectable (service not offered yet). */
disabled?: boolean;
}> = [ }> = [
{ {
value: "import", value: "import",
@@ -46,7 +48,8 @@ export const OPERATION_TYPE_OPTIONS: Array<{
{ {
value: "intercity", value: "intercity",
label: "Intercity", label: "Intercity",
description: "Domestic movement between Ethiopian yards.", description:
"Domestic movement between Ethiopian yards — rides on passing import/export trains, no customs.",
}, },
{ {
value: "import_ff", value: "import_ff",

View File

@@ -30,7 +30,11 @@ export function Step0OperationType({
}) { }) {
const data = OPERATION_TYPE_OPTIONS.filter((opt) => const data = OPERATION_TYPE_OPTIONS.filter((opt) =>
allowedOperations.includes(opt.value), allowedOperations.includes(opt.value),
).map((opt) => ({ value: opt.value, label: opt.label })); ).map((opt) => ({
value: opt.value,
label: opt.label,
disabled: opt.disabled ?? false,
}));
return ( return (
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -49,7 +49,11 @@ export function Step1ContractType({
const operationData = OPERATION_TYPE_OPTIONS.filter((opt) => const operationData = OPERATION_TYPE_OPTIONS.filter((opt) =>
allowedOperations.includes(opt.value), allowedOperations.includes(opt.value),
).map((opt) => ({ value: opt.value, label: opt.label })); ).map((opt) => ({
value: opt.value,
label: opt.label,
disabled: opt.disabled ?? false,
}));
const previousContractRef = form.watch("previousContractRef"); const previousContractRef = form.watch("previousContractRef");
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");

View File

@@ -19,6 +19,11 @@ export interface ShipmentValidationContext {
isHazardous: boolean; isHazardous: boolean;
isReefer: boolean; isReefer: boolean;
unitOfMeasure?: "PER_TON" | "PER_ITEM"; unitOfMeasure?: "PER_TON" | "PER_ITEM";
/**
* Intercity (DOMESTIC) shipments ride a passing import/export train that
* staff pick later, so no shipment day is chosen. Defaults to true.
*/
requiresDate?: boolean;
} }
const containerUnitSchema = z.object({ const containerUnitSchema = z.object({
@@ -54,7 +59,7 @@ const shipmentFormBase = z.object({
export function createShipmentFormSchema(ctx: ShipmentValidationContext) { export function createShipmentFormSchema(ctx: ShipmentValidationContext) {
return shipmentFormBase.superRefine((data, refineCtx) => { return shipmentFormBase.superRefine((data, refineCtx) => {
if (!data.scheduledDate.trim()) { if ((ctx.requiresDate ?? true) && !data.scheduledDate.trim()) {
refineCtx.addIssue({ refineCtx.addIssue({
code: "custom", code: "custom",
path: ["scheduledDate"], path: ["scheduledDate"],

View File

@@ -66,8 +66,8 @@
"@nestjs/schematics": "^11.1.0", "@nestjs/schematics": "^11.1.0",
"@nestjs/testing": "^11.1.19", "@nestjs/testing": "^11.1.19",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/luxon": "^3.7.1",
"@types/jest": "^29.5.11", "@types/jest": "^29.5.11",
"@types/luxon": "^3.7.1",
"@types/node": "^20.10.6", "@types/node": "^20.10.6",
"@types/qrcode": "^1.5.5", "@types/qrcode": "^1.5.5",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",

View File

@@ -0,0 +1,41 @@
/**
* Shared contracts for live booking-window pushes.
*
* The freight API emits one event whenever a schedule's booking-window state
* changes (phase transition, open/close, cycle reopen). Portal home and the
* backoffice GL windows section subscribe and refresh instantly instead of
* waiting for their poll interval.
*/
/** Booking-window lifecycle phase persisted on a train schedule. */
export type BookingWindowPhase =
| "PRE_WINDOW"
| "OPEN"
| "DOC_REVIEW"
| "PAYMENT"
| "CLOSED_FOR_DAY"
| "DONE";
/** Payload pushed on every booking-window state change. */
export interface BookingWindowPhaseEvent {
scheduleId: string;
originYardId: string;
destinationYardId: string;
direction: string | null;
phase: BookingWindowPhase;
bookingWindowStatus: string | null;
bookingCycleNo: number;
windowOpensAt: string | null;
windowClosesAt: string | null;
docReviewEndsAt: string | null;
paymentPhaseEndsAt: string | null;
scheduledDepartureDate: string | null;
}
/** Socket.io event names pushed server → client on the booking-windows namespace. */
export const BOOKING_WINDOW_WS_EVENTS = {
PHASE: "booking-window:phase",
} as const;
/** Socket.io namespace the booking-window gateway listens on. */
export const BOOKING_WINDOW_WS_NAMESPACE = "booking-windows";

View File

@@ -695,8 +695,8 @@ export interface CreateBulkLineDto {
export interface CreateBookingUnderContractDto { export interface CreateBookingUnderContractDto {
/** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */ /** Required for GENERAL multi-route contracts; ONE_TIME auto-selected. */
contractRouteId?: string; contractRouteId?: string;
/** Binding shipment day. */ /** Binding shipment day. Omitted for intercity (DOMESTIC) bookings — staff assign a passing train later. */
scheduledDate: string; scheduledDate?: string;
containers?: CreateBookingContainerLineDto[]; containers?: CreateBookingContainerLineDto[];
bulkLines?: CreateBulkLineDto[]; bulkLines?: CreateBulkLineDto[];
notes?: string; notes?: string;

View File

@@ -8,6 +8,7 @@ export * from "./etrade";
export * from "./contracts"; export * from "./contracts";
export * from "./clearance-files.catalog"; export * from "./clearance-files.catalog";
export * from "./notifications"; export * from "./notifications";
export * from "./booking-window-ws";
export enum TradeDirection { export enum TradeDirection {
IMPORT = "IMPORT", IMPORT = "IMPORT",
@@ -220,6 +221,24 @@ export enum WagonReadiness {
export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC"; export type ScheduleTradeDirection = "IMPORT" | "EXPORT" | "DOMESTIC";
/**
* The only two countries on the EDR line. Yard `country` is constrained to
* these values; route trade direction is derived from the origin/destination
* yard countries (ET→DJ = EXPORT, DJ→ET = IMPORT, same-country = DOMESTIC,
* shown as "Intercity" in UIs and currently disabled for scheduling/contracts).
*/
export enum YardCountry {
ETHIOPIA = "Ethiopia",
DJIBOUTI = "Djibouti",
}
/** UI label for a schedule/route trade direction (DOMESTIC displays as Intercity). */
export const TRADE_DIRECTION_LABELS: Record<ScheduleTradeDirection, string> = {
IMPORT: "Import",
EXPORT: "Export",
DOMESTIC: "Intercity",
};
export enum TrainCheckpointKind { export enum TrainCheckpointKind {
Departed = "DEPARTED", Departed = "DEPARTED",
Passed = "PASSED", Passed = "PASSED",

8
pnpm-lock.yaml generated
View File

@@ -15461,9 +15461,9 @@ snapshots:
dependencies: dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)
@@ -15584,9 +15584,9 @@ snapshots:
dependencies: dependencies:
'@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)
'@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/jwt': 10.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))
'@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)
'@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/swagger': 7.4.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)