mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 10:10:57 +00:00
Merge branch 'dev' into freight/feat/fixes-v1
This commit is contained in:
@@ -305,6 +305,10 @@ export class BookingPricingService {
|
||||
vgmPerUnitTons: vgm,
|
||||
totalVgmTons: qty * vgm,
|
||||
isReefer: ct.isReefer,
|
||||
// Per-container opt-ins — PER_CONTAINER surcharges bill these.
|
||||
hazardousQuantity: Number(bc.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(bc.reeferQuantity ?? 0),
|
||||
returnQuantity: Number(bc.returnQuantity ?? 0),
|
||||
},
|
||||
perWagon: containersPerWagonForSize(ct.sizeFt),
|
||||
quantity: qty,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Inject, Injectable } from "@nestjs/common";
|
||||
import { In, Not } from "typeorm";
|
||||
|
||||
import { CargoType } from "../rule-engine/entities/cargo-type.entity";
|
||||
import { ContainerType } from "../rule-engine/entities/container-type.entity";
|
||||
@@ -34,8 +33,6 @@ import {
|
||||
BookingReferenceYardDto,
|
||||
} from "./dto/booking-reference-data.dto";
|
||||
|
||||
const LEGACY_YARD_CODES = ["LEGACY_ORIGIN", "LEGACY_DEST"] as const;
|
||||
|
||||
export function buildCargoTypeTree(
|
||||
rows: CargoType[],
|
||||
): BookingReferenceCargoTypeGroupDto[] {
|
||||
@@ -134,10 +131,7 @@ export class BookingReferenceDataService {
|
||||
const [yards, containerTypes, serviceTypes, shippingLines, cargoTypes] =
|
||||
await Promise.all([
|
||||
this.yardsRepository.findAll({
|
||||
where: {
|
||||
isActive: true,
|
||||
code: Not(In([...LEGACY_YARD_CODES])),
|
||||
},
|
||||
where: { isActive: true },
|
||||
order: { displayOrder: "ASC", code: "ASC" },
|
||||
}),
|
||||
this.containerTypesRepository.findAll({
|
||||
|
||||
@@ -42,6 +42,7 @@ export class BookingTransitionService {
|
||||
private readonly bookingsRepository: BookingsRepository,
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly pricingService: BookingPricingService,
|
||||
@Inject(forwardRef(() => BookingContractService))
|
||||
private readonly contractService: BookingContractService,
|
||||
private readonly filesService: FilesService,
|
||||
private readonly fileUploadSettingsService: FileUploadSettingsService,
|
||||
@@ -1049,7 +1050,25 @@ export class BookingTransitionService {
|
||||
booking.tradeDirection === "EXPORT" &&
|
||||
!isRoadService(booking.serviceType);
|
||||
if (isExportTrain) {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
// With export split ON the booking no longer has to ride ONE train whole:
|
||||
// the largest fitting part is offered and the leftover rebooks on the next
|
||||
// train. So the day is only unbookable when NO export train that day has
|
||||
// any room at all — reject on the day total, not on a single-train fit.
|
||||
// With the flag off this stays the strict whole-booking gate.
|
||||
if (process.env.FREIGHT_EXPORT_SPLIT === "true") {
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
scheduledBooking,
|
||||
eatDay(date),
|
||||
"EXPORT",
|
||||
);
|
||||
if (!fitting.length) {
|
||||
throw new ConflictException(
|
||||
"No export train on this day has space left — pick another shipment day.",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await this.bookingBatchService.pickExportSchedule(scheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
await this.bookingsRepository.update(bookingId, {
|
||||
|
||||
@@ -1287,6 +1287,23 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Same as {@link findAllBySchedule} but for a page of schedules at once —
|
||||
* one query instead of one per schedule (batch monitoring board). */
|
||||
findAllBySchedules(scheduleIds: string[]): Promise<Booking[]> {
|
||||
if (!scheduleIds.length) return Promise.resolve([]);
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
|
||||
.leftJoinAndSelect('booking.cargoType', 'cargoType')
|
||||
.where('booking.train_schedule_id IN (:...scheduleIds)', { scheduleIds })
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Bookings currently reserved (SELECTED_FOR_BATCH) against a schedule. */
|
||||
findReservedForSchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
@@ -1342,6 +1359,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
if (!bookingIds.length) return Promise.resolve([]);
|
||||
return this.bookingRepo(manager).find({
|
||||
where: { id: In(bookingIds) },
|
||||
// Per-relation SELECTs: the containerType/cargoType→wagonTypes M2M joins
|
||||
// multiply rows badly in a single join (hot path for every allocation
|
||||
// preview / assignment validation).
|
||||
relationLoadStrategy: 'query',
|
||||
relations: {
|
||||
company: true,
|
||||
originYard: true,
|
||||
|
||||
@@ -32,6 +32,10 @@ export class BookingContainerUnit extends BaseEntity {
|
||||
@Column({ name: 'is_reefer', type: 'boolean', default: false })
|
||||
isReefer!: boolean;
|
||||
|
||||
/** This container ships back empty after unloading (equipment return). */
|
||||
@Column({ name: 'is_return', type: 'boolean', default: false })
|
||||
isReturn!: boolean;
|
||||
|
||||
@Column({ name: 'sort_order', type: 'smallint', default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ import { FilesService } from '../files/files.service';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingsService } from '../bookings/bookings.service';
|
||||
import { BookingLifecycleNotifierService } from '../bookings/booking-lifecycle-notifier.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { clearanceCodesForBooking } from '../bookings/clearance.util';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
@@ -85,6 +88,8 @@ export interface BookingClearanceView {
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
@@ -282,6 +287,12 @@ export class BookingClearanceService {
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
// Every risk decision, oldest first. `riskLevel`/`riskAssignedAt` above are
|
||||
// the current one; this is the trail behind it.
|
||||
riskHistory:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||
: [],
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
|
||||
@@ -65,4 +65,80 @@ describe('ClearanceMilestoneService.assignRisk', () => {
|
||||
expect(saved.status).toBe('COMPLETED');
|
||||
expect(saved.metadata?.riskLevel).toBe('YELLOW');
|
||||
});
|
||||
|
||||
/**
|
||||
* The level is customer-visible and stays correctable until duty is advised,
|
||||
* so a changed level must leave a trail rather than overwrite the last one.
|
||||
*/
|
||||
describe('risk history', () => {
|
||||
it('records the first assignment with no previous level', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
const saved = await service.assignRisk('b-1', 'RED', 'user-1', 'initial rating', 'Abebe K.');
|
||||
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||
level: 'RED',
|
||||
assignedByUserId: 'user-1',
|
||||
assignedBy: 'Abebe K.',
|
||||
note: 'initial rating',
|
||||
});
|
||||
expect(saved.metadata?.riskHistory?.[0]).not.toHaveProperty('previousLevel');
|
||||
});
|
||||
|
||||
it('keeps the earlier decision when the level is reassigned', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'RED', 'user-1', undefined, 'Abebe K.');
|
||||
const saved = await service.assignRisk('b-1', 'GREEN', 'user-2', 'downgraded', 'Sara M.');
|
||||
|
||||
expect(saved.metadata?.riskLevel).toBe('GREEN');
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(2);
|
||||
// The original RED decision survives, with who made it.
|
||||
expect(saved.metadata?.riskHistory?.[0]).toMatchObject({
|
||||
level: 'RED',
|
||||
assignedBy: 'Abebe K.',
|
||||
});
|
||||
expect(saved.metadata?.riskHistory?.[1]).toMatchObject({
|
||||
level: 'GREEN',
|
||||
previousLevel: 'RED',
|
||||
assignedByUserId: 'user-2',
|
||||
assignedBy: 'Sara M.',
|
||||
note: 'downgraded',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the whole chain across several reassignments, oldest first', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'GREEN');
|
||||
await service.assignRisk('b-1', 'YELLOW');
|
||||
const saved = await service.assignRisk('b-1', 'RED');
|
||||
|
||||
expect(saved.metadata?.riskHistory?.map((e) => e.level)).toEqual([
|
||||
'GREEN',
|
||||
'YELLOW',
|
||||
'RED',
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not record a repeat of the level already assigned', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'GREEN');
|
||||
const saved = await service.assignRisk('b-1', 'GREEN');
|
||||
|
||||
expect(saved.metadata?.riskHistory).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('always leaves riskLevel equal to the last history entry', async () => {
|
||||
const { service } = makeService('COMPLETED');
|
||||
|
||||
await service.assignRisk('b-1', 'RED');
|
||||
const saved = await service.assignRisk('b-1', 'YELLOW');
|
||||
|
||||
const history = saved.metadata?.riskHistory ?? [];
|
||||
expect(saved.metadata?.riskLevel).toBe(history[history.length - 1]?.level);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -210,15 +210,50 @@ export class ClearanceMilestoneService {
|
||||
* Customs cannot risk-rate cargo still moving under transit: the T1 must be
|
||||
* closed (accepted by GL Ethiopia after the train arrives) first, which is the
|
||||
* catalog order T1_CLOSED → RISK_ASSIGNED.
|
||||
*
|
||||
* The level stays correctable until duty is advised off it, so each assignment
|
||||
* is appended to `riskHistory` instead of silently replacing the last one — a
|
||||
* customer-visible level that changes needs a trail of who changed it and when.
|
||||
*/
|
||||
async assignRisk(
|
||||
bookingId: string,
|
||||
riskLevel: CustomsRiskLevel,
|
||||
userId?: string,
|
||||
note?: string,
|
||||
actor?: string,
|
||||
): Promise<ClearanceMilestone> {
|
||||
await this.assertT1Closed(bookingId);
|
||||
return this.completeWithMetadata(bookingId, 'RISK_ASSIGNED', { riskLevel }, userId, note);
|
||||
|
||||
const existing = await this.repo.findOne({
|
||||
where: { bookingId, milestoneCode: 'RISK_ASSIGNED' },
|
||||
});
|
||||
const previousLevel = existing?.metadata?.riskLevel;
|
||||
const history = existing?.metadata?.riskHistory ?? [];
|
||||
|
||||
// A repeat of the level already assigned is not a decision — recording it
|
||||
// would pad the trail with entries that changed nothing.
|
||||
const entries =
|
||||
previousLevel === riskLevel
|
||||
? history
|
||||
: [
|
||||
...history,
|
||||
{
|
||||
level: riskLevel,
|
||||
...(previousLevel ? { previousLevel } : {}),
|
||||
assignedAt: new Date().toISOString(),
|
||||
assignedByUserId: userId ?? null,
|
||||
assignedBy: actor ?? null,
|
||||
note: note ?? null,
|
||||
},
|
||||
];
|
||||
|
||||
return this.completeWithMetadata(
|
||||
bookingId,
|
||||
'RISK_ASSIGNED',
|
||||
{ riskLevel, riskHistory: entries },
|
||||
userId,
|
||||
note,
|
||||
);
|
||||
}
|
||||
|
||||
/** Guard: the booking's T1 must be closed before customs risk can be assigned. */
|
||||
|
||||
@@ -25,6 +25,7 @@ import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { wagonsPerUnitForSize } from '../rule-engine/container-type.util';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
import { RuleEngineService } from '../rule-engine/rule-engine.service';
|
||||
@@ -39,7 +40,10 @@ import { ContractsRepository } from './contracts.repository';
|
||||
import { ClearanceFeeService } from './clearance-fee.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { CreateBookingUnderContractDto } from './dto/create-booking-under-contract.dto';
|
||||
import {
|
||||
CreateBookingContainerLineDto,
|
||||
CreateBookingUnderContractDto,
|
||||
} from './dto/create-booking-under-contract.dto';
|
||||
|
||||
/** Statuses that still occupy the single active-booking slot of a ONE_TIME contract. */
|
||||
const TERMINAL_BOOKING_STATUSES = ['EXPIRED', 'CANCELLED', 'COMPLETED', 'REJECTED'];
|
||||
@@ -54,6 +58,18 @@ export interface CreateBookingUnderContractResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Outstanding split remainder of a contract: what was booked in the first split
|
||||
* booking's pre-split snapshot MINUS everything currently booked. Container
|
||||
* contracts report per size; bulk reports one tonnage figure. `null` when the
|
||||
* contract has no live split chain. Consumed by the remainder-placement engine
|
||||
* to size the auto-created remainder booking.
|
||||
*/
|
||||
export type SplitOutstanding = {
|
||||
bySize: Map<string, { total: number; outstanding: number }>;
|
||||
bulk: { total: number; outstanding: number } | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The single create path for shipment bookings under a contract.
|
||||
*
|
||||
@@ -270,8 +286,8 @@ export class ContractBookingService {
|
||||
tradeDirection: contract.tradeDirection,
|
||||
freightType,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
firstMilePickupLat: contract.firstMilePickupLat ?? null,
|
||||
@@ -580,11 +596,40 @@ export class ContractBookingService {
|
||||
if (!booking || booking.contractId !== contract.id) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found on this contract`);
|
||||
}
|
||||
if (!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED'].includes(booking.status)) {
|
||||
if (
|
||||
!['CLEARANCE_READY', 'OPERATION_CHANGES_REQUESTED', 'EXPIRED'].includes(
|
||||
booking.status,
|
||||
)
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Clearance must be finalized before the booking can be completed.',
|
||||
);
|
||||
}
|
||||
// An unpaid booking that expired at train dispatch keeps its finished
|
||||
// per-booking clearance — GL rebooks it onto a new shipment day instead of
|
||||
// forcing the customer through a new shipment request + clearance fee.
|
||||
if (booking.status === 'EXPIRED') {
|
||||
// Only a booking that completed once (it has a price, so its clearance
|
||||
// finished and cargo is persisted) can be rebooked after expiry.
|
||||
if (!(Number(booking.totalAmount) > 0)) {
|
||||
throw new BadRequestException(
|
||||
'Only a previously completed booking can be rebooked after it expires.',
|
||||
);
|
||||
}
|
||||
// Expiry released the booking's contract-capacity hold; if the payload
|
||||
// re-states the cargo, make sure the released share is still free.
|
||||
if (dto.containers?.length || dto.bulkLines?.length) {
|
||||
await this.assertWithinQuantityCap(contract, dto);
|
||||
}
|
||||
// Drop the departed train's link and fall into the day-only resubmit
|
||||
// path below — same machinery as OPERATION_CHANGES_REQUESTED.
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: 'OPERATION_CHANGES_REQUESTED',
|
||||
trainScheduleId: null,
|
||||
} as never);
|
||||
booking.status = 'OPERATION_CHANGES_REQUESTED';
|
||||
booking.trainScheduleId = null;
|
||||
}
|
||||
// Path B: only GL Ethiopia completes a customs instance — the customer
|
||||
// never enters shipment data on a customs contract.
|
||||
if (contract.customsClearingEnabled) {
|
||||
@@ -966,9 +1011,11 @@ export class ContractBookingService {
|
||||
* (CANCELLED / REJECTED / EXPIRED) release their share. Null when the
|
||||
* contract has no live split booking.
|
||||
*/
|
||||
private async splitOutstanding(
|
||||
contract: Contract,
|
||||
): Promise<{ bySize: Map<string, { total: number; outstanding: number }>; bulk: { total: number; outstanding: number } | null } | null> {
|
||||
/**
|
||||
* Public: the remainder-placement engine reads this to size the auto-created
|
||||
* remainder booking. Returns `null` when there is no live split chain.
|
||||
*/
|
||||
async splitOutstanding(contract: Contract): Promise<SplitOutstanding | null> {
|
||||
const first = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.createQueryBuilder('b')
|
||||
@@ -1015,6 +1062,25 @@ export class ContractBookingService {
|
||||
const probe = await this.buildExportProbe(contract, route, dto, yards);
|
||||
const report = await this.bookingBatchService.exportSpaceReport(probe);
|
||||
if (report.scheduleId) return;
|
||||
|
||||
// With export split ON a booking no longer has to ride ONE train whole: the
|
||||
// largest fitting part is offered and the leftover is rebooked on the next
|
||||
// train. Rejecting on the single-train fit here would block exactly the
|
||||
// bookings the split exists to serve — including the auto-created remainder,
|
||||
// which by definition did not fit the train it was split off. Fall back to
|
||||
// the day total: unbookable only when NO export train that day has room.
|
||||
if (process.env.FREIGHT_EXPORT_SPLIT === 'true') {
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
probe,
|
||||
eatDay(new Date(dto.scheduledDate)),
|
||||
'EXPORT',
|
||||
);
|
||||
if (fitting.length > 0) return;
|
||||
throw new BadRequestException(
|
||||
'No export train on this day has space left — pick another shipment day.',
|
||||
);
|
||||
}
|
||||
|
||||
throw new BadRequestException(
|
||||
report.fullMessage ?? 'Not enough train space for this day.',
|
||||
);
|
||||
@@ -1417,6 +1483,53 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-line handling counts. Each physical container carries its own hazardous
|
||||
* / reefer / return switch (entered next to its VGM), so the count is however
|
||||
* many units opted in. Forms that predate per-unit switches send line-level
|
||||
* counts and no unit flags — those are honoured as-is.
|
||||
*/
|
||||
private handlingCounts(line: CreateBookingContainerLineDto): {
|
||||
hazardousQuantity: number;
|
||||
reeferQuantity: number;
|
||||
returnQuantity: number;
|
||||
} {
|
||||
const units = line.units ?? [];
|
||||
const flagged = units.some((u) => u.isHazardous || u.isReefer || u.isReturn);
|
||||
if (!flagged) {
|
||||
return {
|
||||
hazardousQuantity: Number(line.hazardousQuantity ?? 0),
|
||||
reeferQuantity: Number(line.reeferQuantity ?? 0),
|
||||
returnQuantity: Number(line.returnQuantity ?? 0),
|
||||
};
|
||||
}
|
||||
return {
|
||||
hazardousQuantity: units.filter((u) => u.isHazardous).length,
|
||||
reeferQuantity: units.filter((u) => u.isReefer).length,
|
||||
returnQuantity: units.filter((u) => u.isReturn).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Booking-level hazardous / reefer flags. The CONTRACT gates the service; the
|
||||
* per-container opt-ins decide whether THIS shipment actually uses it. A
|
||||
* container contract that allows hazardous but a booking where nobody ticked
|
||||
* the switch is not a hazardous booking, and must not fire the surcharge.
|
||||
* Bulk keeps the contract flag — it has its own bulk*Quantity fields.
|
||||
*/
|
||||
private resolveShipmentHandlingFlag(
|
||||
contract: Contract,
|
||||
dto: CreateBookingUnderContractDto,
|
||||
field: 'hazardousQuantity' | 'reeferQuantity',
|
||||
): boolean {
|
||||
const gated = field === 'hazardousQuantity' ? contract.isHazardous : contract.isReefer;
|
||||
if (!gated) return false;
|
||||
if (contract.freightType !== 'CONTAINER') return true;
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) return Boolean(gated);
|
||||
return lines.some((l) => this.handlingCounts(l)[field] > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the booking's equipment return from the per-line return quantities
|
||||
* (container freight). The CONTRACT gates the service — like hazardous:
|
||||
@@ -1436,7 +1549,7 @@ export class ContractBookingService {
|
||||
|
||||
const lines = dto.containers ?? [];
|
||||
for (const line of lines) {
|
||||
const qty = Number(line.returnQuantity ?? 0);
|
||||
const qty = this.handlingCounts(line).returnQuantity;
|
||||
if (qty === 0) continue;
|
||||
if (contract.equipmentReturn !== 'WITH_RETURN') {
|
||||
throw new BadRequestException(
|
||||
@@ -1452,7 +1565,7 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
if (contract.equipmentReturn === 'WITH_RETURN') {
|
||||
const anyReturn = lines.some((l) => Number(l.returnQuantity ?? 0) > 0);
|
||||
const anyReturn = lines.some((l) => this.handlingCounts(l).returnQuantity > 0);
|
||||
return anyReturn ? 'WITH_RETURN' : 'WITHOUT_RETURN';
|
||||
}
|
||||
return legacy;
|
||||
@@ -1490,9 +1603,10 @@ export class ContractBookingService {
|
||||
);
|
||||
}
|
||||
|
||||
const counts = this.handlingCounts(line);
|
||||
const containerType = await this.resolveContainerTypeForSize(
|
||||
line.containerSize,
|
||||
contract.isReefer || (line.reeferQuantity ?? 0) > 0,
|
||||
contract.isReefer || counts.reeferQuantity > 0,
|
||||
);
|
||||
|
||||
const vgmPerUnit = line.units.length
|
||||
@@ -1506,12 +1620,10 @@ export class ContractBookingService {
|
||||
containerTypeId: containerType.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
hazardousQuantity: counts.hazardousQuantity,
|
||||
reeferQuantity: counts.reeferQuantity,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
: 0,
|
||||
contract.equipmentReturn === 'WITH_RETURN' ? counts.returnQuantity : 0,
|
||||
vgmPerUnitTons: vgmPerUnit,
|
||||
totalVgmTons: totalVgm,
|
||||
wagonsRequired: Math.ceil(line.quantity * wagonsPerUnitForSize(containerType.sizeFt)),
|
||||
@@ -1530,6 +1642,8 @@ export class ContractBookingService {
|
||||
vgmTons: unit.vgmTons,
|
||||
isHazardous: unit.isHazardous ?? false,
|
||||
isReefer: unit.isReefer ?? false,
|
||||
isReturn:
|
||||
contract.equipmentReturn === 'WITH_RETURN' && (unit.isReturn ?? false),
|
||||
sortOrder: sortOrder++,
|
||||
}),
|
||||
);
|
||||
@@ -1630,12 +1744,14 @@ export class ContractBookingService {
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
isHazardous: this.resolveShipmentHandlingFlag(contract, dto, 'hazardousQuantity'),
|
||||
isReefer: this.resolveShipmentHandlingFlag(contract, dto, 'reeferQuantity'),
|
||||
equipmentReturn: this.resolveShipmentEquipmentReturn(contract, dto),
|
||||
isGovernment: contract.isGovernment,
|
||||
shippingLineId: null,
|
||||
contractRouteId: route?.id ?? null,
|
||||
originYardId: route?.originYardId ?? null,
|
||||
destinationYardId: route?.destinationYardId ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
@@ -1644,11 +1760,11 @@ export class ContractBookingService {
|
||||
containerTypeId: ct.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
hazardousQuantity: this.handlingCounts(line).hazardousQuantity,
|
||||
reeferQuantity: this.handlingCounts(line).reeferQuantity,
|
||||
returnQuantity:
|
||||
contract.equipmentReturn === 'WITH_RETURN'
|
||||
? (line.returnQuantity ?? 0)
|
||||
? this.handlingCounts(line).returnQuantity
|
||||
: 0,
|
||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||
totalVgmTons,
|
||||
|
||||
@@ -18,7 +18,10 @@ import { ClearanceWorkflowService } from './clearance-workflow.service';
|
||||
import { ClearanceMilestoneService } from './clearance-milestone.service';
|
||||
import { ContractNotifierService } from './contract-notifier.service';
|
||||
import { GlOperationsService } from './gl-operations.service';
|
||||
import { ClearanceMilestone } from './entities/clearance-milestone.entity';
|
||||
import {
|
||||
ClearanceMilestone,
|
||||
type RiskAssignmentRecord,
|
||||
} from './entities/clearance-milestone.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractDocReviewStatus } from './entities/contract-document-review.entity';
|
||||
import { FilterContractDto } from './dto/filter-contract.dto';
|
||||
@@ -102,6 +105,8 @@ export interface ContractClearanceView {
|
||||
/** Customs risk level assigned by GL ET (import; visible to the customer). */
|
||||
riskLevel?: string | null;
|
||||
riskAssignedAt?: string | null;
|
||||
/** Every risk decision, oldest first; the last entry is the current level. */
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
/** Post-arrival additional duty/tax round (import). */
|
||||
secondDuty?: ClearanceSecondDuty | null;
|
||||
importReleaseGranted?: boolean;
|
||||
@@ -365,6 +370,11 @@ export class ContractClearanceService {
|
||||
riskMilestone?.status === 'COMPLETED' && riskMilestone.triggeredAt
|
||||
? riskMilestone.triggeredAt.toISOString()
|
||||
: null,
|
||||
// Every risk decision, oldest first — see booking-clearance.service.
|
||||
riskHistory:
|
||||
riskMilestone?.status === 'COMPLETED'
|
||||
? (riskMilestone.metadata?.riskHistory ?? [])
|
||||
: [],
|
||||
secondDuty,
|
||||
importReleaseGranted:
|
||||
bookingMilestone('IMPORT_RELEASE_GRANTED')?.status === 'COMPLETED',
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { actorLabel } from '../warehouses/current-actor.util';
|
||||
import { BookingStaff } from '../../common/booking-guards';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import {
|
||||
@@ -968,13 +969,16 @@ export class ContractsController {
|
||||
assignRisk(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: AssignRiskDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.milestoneService.assignRisk(
|
||||
bookingId,
|
||||
dto.riskLevel,
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
// Risk history is read by people, so resolve the name now — the id alone
|
||||
// would render as a UUID in the trail.
|
||||
actorLabel(user),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -50,6 +50,15 @@ export class CreateContainerUnitDto {
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReefer?: boolean;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
default: false,
|
||||
description: 'This container ships back empty (equipment return).',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
isReturn?: boolean;
|
||||
}
|
||||
|
||||
export class CreateBookingContainerLineDto {
|
||||
|
||||
@@ -12,14 +12,35 @@ export type MilestoneOwnerRegion = (typeof MILESTONE_OWNER_REGIONS)[number];
|
||||
export const CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'RED'] as const;
|
||||
export type CustomsRiskLevel = (typeof CUSTOMS_RISK_LEVELS)[number];
|
||||
|
||||
/**
|
||||
* One customs risk decision. Risk stays correctable until duty is advised off
|
||||
* it, and the level is customer-visible, so every assignment is kept rather than
|
||||
* overwritten — a disputed level needs to show what was set, by whom, and when.
|
||||
*/
|
||||
export interface RiskAssignmentRecord {
|
||||
level: CustomsRiskLevel;
|
||||
/** The level this replaced; absent on the first assignment. */
|
||||
previousLevel?: CustomsRiskLevel;
|
||||
assignedAt: string;
|
||||
assignedByUserId?: string | null;
|
||||
/** Display name resolved at assignment time, so the trail never shows a UUID. */
|
||||
assignedBy?: string | null;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured payload some milestones carry beyond a plain note (doc §11.3):
|
||||
* - RISK_ASSIGNED → `riskLevel`
|
||||
* - RISK_ASSIGNED → `riskLevel` (current) + `riskHistory` (every assignment)
|
||||
* - DUTY_TAXES_ADVISED → `dutyAmount`, `dutyCurrency`, `declarationSerial`
|
||||
* Stored on the milestone so the timeline can render the value inline.
|
||||
*/
|
||||
export interface MilestoneMetadata {
|
||||
riskLevel?: CustomsRiskLevel;
|
||||
/**
|
||||
* Append-only, oldest first. `riskLevel` is the current value and always
|
||||
* equals the last entry's `level`.
|
||||
*/
|
||||
riskHistory?: RiskAssignmentRecord[];
|
||||
dutyAmount?: number;
|
||||
dutyCurrency?: string;
|
||||
declarationSerial?: string;
|
||||
|
||||
@@ -256,6 +256,11 @@ export const PHASED_CUSTOMS_CONTRACT_QUEUE_STATUSES = [
|
||||
'FULLY_EXECUTED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Terminal contracts stay on the list — the clearance hub is GL's history of
|
||||
// everything that passed through, not just the live work queue.
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
'REJECTED',
|
||||
] as const;
|
||||
|
||||
/** Whether a customs clearance item belongs on the persistent GL Ethiopia list. */
|
||||
@@ -275,12 +280,22 @@ export const PHASED_CUSTOMS_BOOKING_QUEUE_STATUSES = [
|
||||
'OPERATION_REQUEST_PENDING',
|
||||
'OPERATION_CHANGES_REQUESTED',
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
// Payment phase — the booking is selected/awaiting the customer's payment.
|
||||
'SELECTED_FOR_BATCH',
|
||||
'PNR_GENERATED',
|
||||
'AWAITING_PAYMENT',
|
||||
'PAYMENT_VERIFICATION_IN_PROGRESS',
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED',
|
||||
'PAID',
|
||||
'COMPLETED',
|
||||
'CONTRACT_ACTIVE',
|
||||
'CONTRACT_CLOSED',
|
||||
// Terminal bookings stay on the list — EXPIRED especially: GL rebooks it
|
||||
// from here, and the hub doubles as clearance history.
|
||||
'EXPIRED',
|
||||
'CANCELLED',
|
||||
'REJECTED',
|
||||
] as const;
|
||||
|
||||
/** Booking statuses that may appear on the GL Djibouti clearance list (includes post-clearance). */
|
||||
|
||||
@@ -74,9 +74,14 @@ export class CreateRateDto {
|
||||
@Transform(({ value }) => Number(value))
|
||||
rateValue!: number;
|
||||
|
||||
@ApiProperty({ enum: RATE_UNITS, description: 'Unit basis for the rate' })
|
||||
@ApiPropertyOptional({
|
||||
enum: RATE_UNITS,
|
||||
description:
|
||||
'Unit basis for the rate. Optional for shapes with a forced unit (overweight is always PER_TON — the admin form hides the field and omits it); required otherwise.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsIn([...RATE_UNITS])
|
||||
rateUnit!: string;
|
||||
rateUnit?: string;
|
||||
}
|
||||
|
||||
export class SubmitRateForApprovalDto {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { deriveRateType } from './rate-type.util';
|
||||
|
||||
describe('deriveRateType — surcharge triggers', () => {
|
||||
// Every surcharge trigger must land on its own rateType. A trigger with no
|
||||
// mapping falls through to the base-freight branch and is silently stored as
|
||||
// CANCELLATION_FEE, which both mislabels the booking's rate snapshot and
|
||||
// hides the rate from contract pricing (which looks rateTypes up by name).
|
||||
it.each([
|
||||
['HAZARDOUS', 'HAZARD_SURCHARGE'],
|
||||
['REEFER', 'REEFER_SURCHARGE'],
|
||||
['WITH_RETURN', 'RETURN_SURCHARGE'],
|
||||
['OVERWEIGHT', 'OVERWEIGHT_PER_TON'],
|
||||
['SHIPPING_LINE', 'DOUBLE_HANDLING'],
|
||||
['CONSOLIDATION', 'LASHING'],
|
||||
['CANCELLATION', 'CANCELLATION_FEE'],
|
||||
['DEMURRAGE', 'DEMURRAGE'],
|
||||
['PIL_EXTRA_FEE', 'PIL_EXTRA_FEE'],
|
||||
['CUSTOMS_CLEARANCE', 'CUSTOMS_CLEARANCE'],
|
||||
] as const)('maps trigger %s to %s', (trigger, expected) => {
|
||||
expect(deriveRateType({ appliesTo: 'OTHER', trigger })).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not fall back to CANCELLATION_FEE for the empty-return service', () => {
|
||||
expect(deriveRateType({ appliesTo: 'OTHER', trigger: 'WITH_RETURN' })).not.toBe(
|
||||
'CANCELLATION_FEE',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,12 @@ export function deriveRateType(input: {
|
||||
return 'HAZARD_SURCHARGE';
|
||||
case 'REEFER':
|
||||
return 'REEFER_SURCHARGE';
|
||||
// Empty-container return service. Contract pricing looks this rateType up
|
||||
// by name, so without the mapping a WITH_RETURN rate fell through to the
|
||||
// base-freight branch and was stored as CANCELLATION_FEE — invisible to
|
||||
// the contract, and mislabelled on the booking's snapshot.
|
||||
case 'WITH_RETURN':
|
||||
return 'RETURN_SURCHARGE';
|
||||
case 'OVERWEIGHT':
|
||||
return 'OVERWEIGHT_PER_TON';
|
||||
case 'SHIPPING_LINE':
|
||||
|
||||
@@ -26,6 +26,17 @@ export class YardFacility extends BaseEntity {
|
||||
@Column({ name: 'has_warehouse', type: 'boolean', default: false })
|
||||
hasWarehouse!: boolean;
|
||||
|
||||
/**
|
||||
* Containers need a reach stacker or gantry, so only the equipped facilities
|
||||
* (Indode, Modjo, Dire Dawa) take them. Bulk needs far less and is handled
|
||||
* everywhere.
|
||||
*/
|
||||
@Column({ name: 'handles_container', type: 'boolean', default: true })
|
||||
handlesContainer!: boolean;
|
||||
|
||||
@Column({ name: 'handles_bulk', type: 'boolean', default: true })
|
||||
handlesBulk!: boolean;
|
||||
|
||||
@Column({ name: 'equipment_notes', type: 'text', nullable: true })
|
||||
equipmentNotes?: string | null;
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
@Index(['country'])
|
||||
@Index(['isActive'])
|
||||
export class Yard extends BaseEntity {
|
||||
@Column({ name: 'code', type: 'varchar', length: 20, unique: true })
|
||||
// 40 leaves room for the `@<epoch-ms>` suffix soft-delete appends to free the code.
|
||||
@Column({ name: 'code', type: 'varchar', length: 40, unique: true })
|
||||
code!: string;
|
||||
|
||||
@Column({ name: 'label', type: 'varchar', length: 100 })
|
||||
|
||||
@@ -42,6 +42,14 @@ export interface BookingContainerEvalInput {
|
||||
isReefer?: boolean;
|
||||
isOverweight?: boolean;
|
||||
overweightExcessTons?: number | null;
|
||||
/**
|
||||
* How many individual containers on this line opted into each handling
|
||||
* service. PER_CONTAINER surcharges bill these counts, not the line
|
||||
* quantity — 20 containers with 10 hazardous bill hazard on 10.
|
||||
*/
|
||||
hazardousQuantity?: number;
|
||||
reeferQuantity?: number;
|
||||
returnQuantity?: number;
|
||||
}
|
||||
|
||||
export interface BookingEvaluationInput {
|
||||
@@ -270,6 +278,27 @@ export class RuleEngineService {
|
||||
(sum, r) => sum + (r.overweightExcessTons ?? 0),
|
||||
0,
|
||||
);
|
||||
/**
|
||||
* Containers that opted into this trigger's handling service, summed
|
||||
* across lines. null when the trigger isn't per-container handling (or
|
||||
* no line carries a count) so the caller falls back to the full count.
|
||||
*/
|
||||
const optedInCount = (trigger: string | null): number | null => {
|
||||
const field =
|
||||
trigger === 'HAZARDOUS'
|
||||
? 'hazardousQuantity'
|
||||
: trigger === 'REEFER'
|
||||
? 'reeferQuantity'
|
||||
: trigger === 'WITH_RETURN'
|
||||
? 'returnQuantity'
|
||||
: null;
|
||||
if (!field) return null;
|
||||
const total = input.containers.reduce(
|
||||
(sum, c) => sum + Number(c[field] ?? 0),
|
||||
0,
|
||||
);
|
||||
return total > 0 ? total : null;
|
||||
};
|
||||
|
||||
let triggerValue: number | null = null;
|
||||
let calculatedAmount: number;
|
||||
@@ -285,7 +314,11 @@ export class RuleEngineService {
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'PER_CONTAINER':
|
||||
triggerValue = containerCount;
|
||||
// Handling surcharges bill only the containers that opted in, not the
|
||||
// whole line — 20 containers with 10 hazardous bill hazard on 10.
|
||||
// Legacy bookings carry no per-container counts (all 0) while their
|
||||
// booking-level flag is set, so fall back to the full count there.
|
||||
triggerValue = optedInCount(rate.trigger) ?? containerCount;
|
||||
calculatedAmount = triggerValue * rateValue;
|
||||
break;
|
||||
case 'PER_WAGON':
|
||||
|
||||
@@ -68,15 +68,21 @@ export class RatesService {
|
||||
private resolveRateUnit(
|
||||
appliesTo: Rate['appliesTo'],
|
||||
trigger: Rate['trigger'],
|
||||
requestedUnit: Rate['rateUnit'],
|
||||
requestedUnit: Rate['rateUnit'] | undefined,
|
||||
): Rate['rateUnit'] {
|
||||
// Overweight is per-ton, full stop.
|
||||
// Overweight is per-ton, full stop — the admin form hides the unit field
|
||||
// for it and omits rateUnit from the payload entirely.
|
||||
if (trigger === 'OVERWEIGHT') return 'PER_TON';
|
||||
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger }).join(', ');
|
||||
const allowed = allowedRateUnits({ appliesTo, trigger });
|
||||
if (!requestedUnit) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed}.`,
|
||||
`Pick a rate unit for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
if (!isRateUnitAllowed({ appliesTo, trigger, unit: requestedUnit })) {
|
||||
throw new BadRequestException(
|
||||
`Rate unit "${requestedUnit}" is not valid for this rate. Allowed: ${allowed.join(', ')}.`,
|
||||
);
|
||||
}
|
||||
return requestedUnit;
|
||||
@@ -272,7 +278,11 @@ export class RatesService {
|
||||
tradeDirection,
|
||||
isBulk: this.resolvesToBulk(appliesTo, intercityKind),
|
||||
});
|
||||
const rateUnit = this.resolveRateUnit(appliesTo, trigger, dto.rateUnit as Rate['rateUnit']);
|
||||
const rateUnit = this.resolveRateUnit(
|
||||
appliesTo,
|
||||
trigger,
|
||||
dto.rateUnit as Rate['rateUnit'] | undefined,
|
||||
);
|
||||
|
||||
await this.assertNoDuplicatePattern({
|
||||
rateType,
|
||||
|
||||
@@ -10,80 +10,91 @@ export interface YardFacilityInfo {
|
||||
hasFacility: boolean;
|
||||
/** The facility stores cargo — enables the warehouse flow (storage, demurrage). */
|
||||
hasWarehouse: boolean;
|
||||
/** Containers need a reach stacker/gantry — not every facility has one. */
|
||||
handlesContainer: boolean;
|
||||
handlesBulk: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which yards can handle cargo, and how.
|
||||
* Which yards can handle cargo, and what kind.
|
||||
*
|
||||
* A yard is a load/unload point when `yards.has_facility` is set; the matching
|
||||
* `yard_facilities` record says whether it also stores cargo. Facilities without a
|
||||
* warehouse move cargo on and off the train and nothing more — no storage, no
|
||||
* demurrage. This is the single resolver the journey and handling flows use, so
|
||||
* they can't drift on what a facility is.
|
||||
* `yard_facilities` record says what it can actually do — whether it stores cargo
|
||||
* (storage/demurrage), and which freight types its equipment can lift. Containers
|
||||
* need a reach stacker or gantry, so only Indode, Modjo and Dire Dawa take them;
|
||||
* bulk is handled at all five.
|
||||
*
|
||||
* This is the single resolver the journey and handling flows use, so they can't
|
||||
* drift on what a facility is or what it can lift.
|
||||
*/
|
||||
@Injectable()
|
||||
export class YardFacilitiesService {
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
||||
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
||||
const [row]: Array<{
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
||||
WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||
[yardId],
|
||||
);
|
||||
if (!row) return null;
|
||||
private readonly SELECT = `
|
||||
SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse",
|
||||
f.handles_container AS "handlesContainer",
|
||||
f.handles_bulk AS "handlesBulk"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true`;
|
||||
|
||||
private toInfo(row: {
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
handlesContainer: boolean | null;
|
||||
handlesBulk: boolean | null;
|
||||
}): YardFacilityInfo {
|
||||
// No facility record means no capability, whatever the flag says.
|
||||
const hasFacility = Boolean(row.hasFacility);
|
||||
return {
|
||||
yardId: row.yardId,
|
||||
yardCode: row.yardCode,
|
||||
yardLabel: row.yardLabel,
|
||||
hasFacility: Boolean(row.hasFacility),
|
||||
// No facility record means no warehouse, whatever the flag says.
|
||||
hasWarehouse: Boolean(row.hasFacility) && Boolean(row.hasWarehouse),
|
||||
hasFacility,
|
||||
hasWarehouse: hasFacility && Boolean(row.hasWarehouse),
|
||||
handlesContainer: hasFacility && Boolean(row.handlesContainer),
|
||||
handlesBulk: hasFacility && Boolean(row.handlesBulk),
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve a yard's handling capability. Null when the yard doesn't exist. */
|
||||
async facilityForYard(yardId: string): Promise<YardFacilityInfo | null> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`${this.SELECT} WHERE y.id = $1 AND y.deleted_at IS NULL`,
|
||||
[yardId],
|
||||
);
|
||||
return row ? this.toInfo(row) : null;
|
||||
}
|
||||
|
||||
/** Every yard that can load/unload, for pickers and the intercity queues. */
|
||||
async listFacilityYards(): Promise<YardFacilityInfo[]> {
|
||||
const rows: Array<{
|
||||
yardId: string;
|
||||
yardCode: string | null;
|
||||
yardLabel: string | null;
|
||||
hasFacility: boolean;
|
||||
hasWarehouse: boolean | null;
|
||||
}> = await this.dataSource.query(
|
||||
`SELECT y.id AS "yardId",
|
||||
y.code AS "yardCode",
|
||||
y.label AS "yardLabel",
|
||||
y.has_facility AS "hasFacility",
|
||||
f.has_warehouse AS "hasWarehouse"
|
||||
FROM freight.yards y
|
||||
LEFT JOIN freight.yard_facilities f
|
||||
ON f.yard_id = y.id AND f.deleted_at IS NULL AND f.is_active = true
|
||||
WHERE y.deleted_at IS NULL
|
||||
AND y.is_active = true
|
||||
AND y.has_facility = true
|
||||
const rows = await this.dataSource.query(
|
||||
`${this.SELECT}
|
||||
WHERE y.deleted_at IS NULL AND y.is_active = true AND y.has_facility = true
|
||||
ORDER BY y.display_order ASC, y.label ASC`,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
yardId: r.yardId,
|
||||
yardCode: r.yardCode,
|
||||
yardLabel: r.yardLabel,
|
||||
hasFacility: true,
|
||||
hasWarehouse: Boolean(r.hasWarehouse),
|
||||
}));
|
||||
return rows.map((r: Parameters<typeof this.toInfo>[0]) => this.toInfo(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* Can this facility lift this cargo? Keeps the freight-type rule in one place
|
||||
* so callers can't get it subtly wrong.
|
||||
*/
|
||||
canHandleFreight(
|
||||
facility: YardFacilityInfo | null,
|
||||
freightType: string | null | undefined,
|
||||
): boolean {
|
||||
if (!facility?.hasFacility) return false;
|
||||
return String(freightType).toUpperCase() === 'CONTAINER'
|
||||
? facility.handlesContainer
|
||||
: facility.handlesBulk;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export class YardsService {
|
||||
|
||||
/** Create a yard. */
|
||||
async create(dto: CreateYardDto): Promise<Yard> {
|
||||
const code = generateCode(dto.label);
|
||||
const code = generateCode(dto.label).slice(0, 40);
|
||||
const existing = await this.repository.findByCode(code);
|
||||
if (existing) throw new ConflictException(`Yard with label "${dto.label}" conflicts with existing code "${code}"`);
|
||||
|
||||
@@ -58,9 +58,19 @@ export class YardsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Soft-delete a yard. */
|
||||
/**
|
||||
* Soft-delete a yard. The unique `code` (and the label) get a `@<epoch-ms>`
|
||||
* suffix first — e.g. SEBETA → SEBETA@1755612345678 — so a new yard with the
|
||||
* same name can be created later without tripping UQ_yards_code, which spans
|
||||
* soft-deleted rows too.
|
||||
*/
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.findById(id);
|
||||
const yard = await this.findById(id);
|
||||
const suffix = `@${Date.now()}`;
|
||||
await this.repository.update(id, {
|
||||
code: `${yard.code.slice(0, 40 - suffix.length)}${suffix}`,
|
||||
label: `${yard.label.slice(0, 100 - suffix.length)}${suffix}`,
|
||||
});
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ export class TrainSchedulesRepository extends BaseRepository<TrainSchedule> {
|
||||
findByIdWithFullGraph(id: string, manager?: EntityManager): Promise<TrainSchedule | null> {
|
||||
return this.repo(manager).findOne({
|
||||
where: { id },
|
||||
// One SELECT per relation instead of a single monster join — the nested
|
||||
// wagon×allocation×booking×container branches multiply rows catastrophically
|
||||
// when joined (measured ~925ms vs ~84ms on a 21-wagon schedule).
|
||||
relationLoadStrategy: 'query',
|
||||
relations: {
|
||||
// Yards carry the route's display name; without them formatRouteLabel
|
||||
// degrades to the literal "Origin → Destination". Milestones (with
|
||||
|
||||
@@ -958,20 +958,21 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
// assertion below that says "not full" proves those axes are ignored.
|
||||
const scheduleId = 'schedule-built';
|
||||
|
||||
const reservedBooking = (id: string) =>
|
||||
const reservedBooking = (id: string, leg?: { origin: string; dest: string }) =>
|
||||
({
|
||||
id,
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: 50, // 1 wagon at the 60T default bulk payload
|
||||
bookingContainers: [],
|
||||
originYardId: 'yard-a',
|
||||
destinationYardId: 'yard-b',
|
||||
originYardId: leg?.origin ?? 'yard-a',
|
||||
destinationYardId: leg?.dest ?? 'yard-b',
|
||||
}) as unknown as Booking;
|
||||
|
||||
const buildService = (opts: {
|
||||
physicalWagons: number;
|
||||
reserved: Booking[];
|
||||
maxWagons?: number;
|
||||
routeStops?: string[];
|
||||
}) => {
|
||||
const schedule = {
|
||||
id: scheduleId,
|
||||
@@ -979,7 +980,7 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
bookingWindowStatus: 'OPEN',
|
||||
originStationId: 'yard-a',
|
||||
destinationStationId: 'yard-b',
|
||||
routeId: null,
|
||||
routeId: opts.routeStops ? 'route-1' : null,
|
||||
scheduleBookings: [],
|
||||
trainSet: {
|
||||
locomotive: {
|
||||
@@ -992,14 +993,23 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
},
|
||||
};
|
||||
const wagonRepo = { count: jest.fn().mockResolvedValue(opts.physicalWagons) };
|
||||
const milestoneRepo = {
|
||||
find: jest
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
(opts.routeStops ?? []).map((yardId, i) => ({ yardId, sequenceNo: i + 1 })),
|
||||
),
|
||||
};
|
||||
const genericRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) =>
|
||||
entity?.name === 'Wagon' ? wagonRepo : genericRepo,
|
||||
),
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
if (entity?.name === 'Wagon') return wagonRepo;
|
||||
if (entity?.name === 'RouteMilestone') return milestoneRepo;
|
||||
return genericRepo;
|
||||
}),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
const service = new BookingBatchService(
|
||||
@@ -1040,6 +1050,22 @@ describe('BookingBatchService — built-train wagon capacity', () => {
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('is FULL when sub-leg bookings hold every physical wagon of a milestone route', async () => {
|
||||
// Regression: 50 wagons sold Negad→Mojo on a Doraleh→…→Dire Dawa corridor
|
||||
// left the pass-through edges reading "free" in the per-edge budget, so the
|
||||
// full train's window cycled OPEN forever and the day pool never expired.
|
||||
// A wagon is committed for the whole trip — leg-free edges are not capacity.
|
||||
const { service } = buildService({
|
||||
physicalWagons: 2,
|
||||
routeStops: ['yard-a', 'yard-m1', 'yard-m2', 'yard-b'],
|
||||
reserved: [
|
||||
reservedBooking('b1', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
reservedBooking('b2', { origin: 'yard-m1', dest: 'yard-m2' }),
|
||||
],
|
||||
});
|
||||
await expect(service.isScheduleFull(scheduleId)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('reports over-allocation when the consist is trimmed below committed bookings', async () => {
|
||||
const { service } = buildService({
|
||||
physicalWagons: 1,
|
||||
|
||||
@@ -33,7 +33,7 @@ import { TrainSchedulesRepository } from '../train-schedules/train-schedules.rep
|
||||
import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedule-bookings.repository';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import {
|
||||
BATCH_BOARD_STATUSES,
|
||||
BatchBoardQueryDto,
|
||||
@@ -71,6 +71,7 @@ import { WagonType } from '../wagon-types/entities/wagon-type.entity';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { ClearanceMilestoneService } from '../contracts/clearance-milestone.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
import { BookingWindowGateway } from './booking-window.gateway';
|
||||
import {
|
||||
MAX_TEU_SLOTS_PER_WAGON,
|
||||
@@ -171,23 +172,18 @@ export interface BatchBoardBookingDetail extends BatchBoardBooking {
|
||||
consolidationPartnerRef: string | null;
|
||||
}
|
||||
|
||||
export interface BatchWindowGroup {
|
||||
key: string;
|
||||
label: string;
|
||||
/** EAT calendar day as ISO `YYYY-MM-DD` (empty for the pending-contract bucket). */
|
||||
date: string;
|
||||
/** Human label for the day, e.g. `Thu, 05 Jun` (empty for pending-contract). */
|
||||
dateLabel: string;
|
||||
start: string;
|
||||
end: string;
|
||||
counts: {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
};
|
||||
export interface BatchBoardCounts {
|
||||
allocated: number;
|
||||
selectedForBatch: number;
|
||||
ready: number;
|
||||
waiting: number;
|
||||
expired: number;
|
||||
pendingContract: number;
|
||||
}
|
||||
|
||||
/** A booking bucket on the detail board (in-window vs pending-contract). */
|
||||
export interface BatchBoardBucket {
|
||||
counts: BatchBoardCounts;
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
}
|
||||
|
||||
@@ -214,8 +210,9 @@ export interface BatchBoardScheduleDetail {
|
||||
locomotive: BatchBoardSchedule["locomotive"];
|
||||
capacity: BatchBoardSchedule["capacity"];
|
||||
counts: BatchBoardSchedule["counts"];
|
||||
windows: BatchWindowGroup[];
|
||||
pendingContract: BatchWindowGroup;
|
||||
/** Bookings inside the schedule's booking window (fully-executed contracts). */
|
||||
bookings: BatchBoardBookingDetail[];
|
||||
pendingContract: BatchBoardBucket;
|
||||
allocationViolations: string[];
|
||||
}
|
||||
|
||||
@@ -317,9 +314,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
@Optional() private readonly splitService?: BookingSplitService,
|
||||
@Optional()
|
||||
@Inject(forwardRef(() => RemainderPlacementService))
|
||||
private readonly remainderPlacement?: RemainderPlacementService,
|
||||
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Auto-place a paid booking's split remainder onto the next fitting train.
|
||||
* Gated so it can ship dark: off unless FREIGHT_AUTO_REMAINDER=true.
|
||||
*/
|
||||
private get autoRemainderEnabled(): boolean {
|
||||
return process.env.FREIGHT_AUTO_REMAINDER === "true";
|
||||
}
|
||||
|
||||
/**
|
||||
* Let EXPORT bookings split (offer the largest fitting part, leftover rebooks
|
||||
* on the next train). Separate flag from auto-remainder: export touches the
|
||||
* FCFS money path, so partial-offer can be enabled independently.
|
||||
*/
|
||||
private get exportSplitEnabled(): boolean {
|
||||
return process.env.FREIGHT_EXPORT_SPLIT === "true";
|
||||
}
|
||||
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
@@ -496,6 +513,34 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// to the offered part before it boards (remainder returns to the contract cap).
|
||||
if (this.splitService) {
|
||||
await this.splitService.applySplit(bookingId);
|
||||
|
||||
// The split only happens on payment (here) — so auto-placing the remainder
|
||||
// also only happens once the customer has accepted+paid. Re-read to see if
|
||||
// applySplit actually reduced this booking (an open offer existed); if so,
|
||||
// auto-create + place the remainder booking on the next fitting train.
|
||||
// applySplit committed its own transaction before returning, so this reads
|
||||
// the reduced lines. Best-effort: a placement failure never blocks the
|
||||
// paid booking from boarding — the remainder falls back to manual rebook.
|
||||
if (this.autoRemainderEnabled && this.remainderPlacement) {
|
||||
const split = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({ where: { id: bookingId } });
|
||||
// Export remainders only auto-place when export split is on — otherwise
|
||||
// an export booking never splits in the first place.
|
||||
const directionOn =
|
||||
split?.tradeDirection !== "EXPORT" || this.exportSplitEnabled;
|
||||
if (split?.isSplit && directionOn) {
|
||||
await this.remainderPlacement
|
||||
.placeRemainder(split)
|
||||
.catch((err) =>
|
||||
this.logger.error(
|
||||
`Auto-place remainder failed for ${split.reference}: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const linked =
|
||||
@@ -731,6 +776,76 @@ export class BookingBatchService implements OnModuleInit {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trains that can carry a booking's leg on a given day, earliest departure
|
||||
* first, each with the largest number of wagons it could still admit for the
|
||||
* booking's wagon type. Direction-filtered: EXPORT bookings see export trains,
|
||||
* IMPORT/DOMESTIC see non-export trains. Measures against the booking's FULL
|
||||
* allowed wagon-type set ({@link dimsForAllowed}) so a train stocking a
|
||||
* non-primary allowed type still counts. The remainder placer uses this to
|
||||
* pick the next fitting train; the `free` wagon count is the best across the
|
||||
* allowed types (a train fits under whichever allowed type gives most room).
|
||||
*/
|
||||
async fittingTrainsForDay(
|
||||
booking: Booking,
|
||||
day: string,
|
||||
direction: "IMPORT" | "EXPORT",
|
||||
): Promise<Array<{ scheduleId: string; departure: Date; freeWagons: number }>> {
|
||||
const corridor = await this.trainSchedulesRepository.findAll({
|
||||
where: [
|
||||
{ status: TrainScheduleStatusEnum.Draft },
|
||||
{ status: TrainScheduleStatusEnum.Scheduled },
|
||||
],
|
||||
});
|
||||
const candidates = corridor
|
||||
.filter(
|
||||
(s) =>
|
||||
s.scheduledDepartureDate != null &&
|
||||
eatDay(s.scheduledDepartureDate) === day &&
|
||||
s.bookingWindowStatus !== "FULL" &&
|
||||
(direction === "EXPORT"
|
||||
? s.direction === "EXPORT"
|
||||
: s.direction !== "EXPORT"),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.scheduledDepartureDate!.getTime() -
|
||||
b.scheduledDepartureDate!.getTime(),
|
||||
);
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const dimsOptions = this.dimsForAllowed(booking, wagonDims);
|
||||
const out: Array<{ scheduleId: string; departure: Date; freeWagons: number }> = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
candidate.id,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) continue;
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) continue; // this train's route doesn't carry the booking's leg
|
||||
const room = budget.remainingFor(leg);
|
||||
// Best usable wagons across the allowed types — a train fits under
|
||||
// whichever configured wagon type gives it the most room.
|
||||
let freeWagons = 0;
|
||||
for (const dims of dimsOptions) {
|
||||
const w = this.bookableWithin(room, dims).wagons;
|
||||
if (w > freeWagons) freeWagons = w;
|
||||
}
|
||||
if (freeWagons > 0) {
|
||||
out.push({
|
||||
scheduleId: schedule.id,
|
||||
departure: schedule.scheduledDepartureDate!,
|
||||
freeWagons,
|
||||
});
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advisory free-wagon count for an IMPORT/DOMESTIC booking on a given day,
|
||||
* summed across every train on the booking's corridor that day. Unlike the
|
||||
@@ -789,6 +904,58 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return { freeWagons, need, trainsForDay };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export split: no single train carries the whole booking, so offer the
|
||||
* largest fitting part on the export train with the most room for its leg.
|
||||
* Returns true when an offer was opened (the caller must NOT then reserve —
|
||||
* the offer already opened its own pay window), false when the booking fits
|
||||
* whole somewhere (normal FCFS path) or no meaningful partial exists.
|
||||
*
|
||||
* Only the offer is written here: the booking is reduced to the offered part
|
||||
* on payment (applySplit), and the leftover is auto-placed afterwards. So an
|
||||
* unpaid export booking stays whole and the customer may still cancel it.
|
||||
*/
|
||||
private async tryExportPartialOffer(booking: Booking): Promise<boolean> {
|
||||
if (!this.splitService) return false;
|
||||
const report = await this.exportSpaceReport(booking);
|
||||
// A train fits it whole — nothing to split, take the normal path.
|
||||
if (report.scheduleId) return false;
|
||||
if (!report.bestAvailable || report.bestAvailable.wagons < 1) return false;
|
||||
|
||||
if (!booking.scheduledDate) return false;
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
const fitting = await this.fittingTrainsForDay(booking, day, "EXPORT");
|
||||
if (!fitting.length) return false;
|
||||
// Most room first — the largest single part ships now, the smallest leftover
|
||||
// is what has to find another train.
|
||||
const target = [...fitting].sort((a, b) => b.freeWagons - a.freeWagons)[0];
|
||||
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(
|
||||
target.scheduleId,
|
||||
);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !locomotive) return false;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
if (!leg) return false;
|
||||
|
||||
const offered = await this.tryPartialOffer(
|
||||
booking,
|
||||
schedule.id,
|
||||
budget.remainingFor(leg),
|
||||
report.need,
|
||||
);
|
||||
if (!offered) return false;
|
||||
this.logger.log(
|
||||
`[EXPORT SPLIT] offered partial to ${booking.reference} on schedule ` +
|
||||
`${schedule.id} — leftover rebooks on the next train once paid.`,
|
||||
);
|
||||
this.notifyBoardChanged(schedule.id, "batch_fill");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an export booking into the FCFS flow. Solo bookings reserve immediately.
|
||||
* A consolidated booking reserves as a pair only once BOTH partners are ready
|
||||
@@ -800,6 +967,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
async acceptExportBooking(booking: Booking): Promise<void> {
|
||||
const partnerId = booking.consolidationPartnerId ?? null;
|
||||
if (!partnerId) {
|
||||
// Export split: when no single train carries the whole booking, offer the
|
||||
// largest fitting part instead of failing the accept. The customer pays
|
||||
// that part; on payment applySplit reduces this booking to it and the
|
||||
// leftover is auto-placed as its own booking on the next train. Pairs are
|
||||
// excluded (handled below) — a shared wagon is never split.
|
||||
if (this.exportSplitEnabled && this.isSplitEligible(booking, false)) {
|
||||
const offered = await this.tryExportPartialOffer(booking);
|
||||
if (offered) return;
|
||||
}
|
||||
const scheduleId = await this.pickExportSchedule(booking);
|
||||
await this.reserveOnExport([booking], scheduleId);
|
||||
return;
|
||||
@@ -1013,11 +1189,32 @@ export class BookingBatchService implements OnModuleInit {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
// One links query + one bookings query for the whole page (was 2 per card).
|
||||
const scheduleIds = schedules.map((s) => s.id);
|
||||
const [allLinks, allBookings] = await Promise.all([
|
||||
scheduleIds.length
|
||||
? linkRepo.find({ where: { trainScheduleId: In(scheduleIds) } })
|
||||
: Promise.resolve([]),
|
||||
this.bookingsRepository.findAllBySchedules(scheduleIds),
|
||||
]);
|
||||
const linkedIdsBySchedule = new Map<string, Set<string>>();
|
||||
for (const l of allLinks) {
|
||||
let set = linkedIdsBySchedule.get(l.trainScheduleId);
|
||||
if (!set) linkedIdsBySchedule.set(l.trainScheduleId, (set = new Set()));
|
||||
set.add(l.bookingId);
|
||||
}
|
||||
const bookingsBySchedule = new Map<string, Booking[]>();
|
||||
for (const b of allBookings) {
|
||||
if (!b.trainScheduleId) continue;
|
||||
let list = bookingsBySchedule.get(b.trainScheduleId);
|
||||
if (!list) bookingsBySchedule.set(b.trainScheduleId, (list = []));
|
||||
list.push(b);
|
||||
}
|
||||
|
||||
const board: BatchBoardSchedule[] = [];
|
||||
for (const s of schedules) {
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
const linkedIds = linkedIdsBySchedule.get(s.id) ?? new Set<string>();
|
||||
const bookings = bookingsBySchedule.get(s.id) ?? [];
|
||||
|
||||
const items: BatchBoardBooking[] = bookings.map((b) => {
|
||||
const need = this.needFor(b, wagonDims);
|
||||
@@ -1046,7 +1243,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return { items: board, meta: buildPaginationMeta(total, page, pageSize) };
|
||||
}
|
||||
|
||||
/** Schedule-level batch board with EAT 3h windows grouped by fullyExecutedAt. */
|
||||
/** Schedule-level batch board: the schedule's own booking window plus its
|
||||
* bookings split into in-window (contract executed) vs pending-contract. */
|
||||
async getBatchBoardDetail(
|
||||
scheduleId: string,
|
||||
): Promise<BatchBoardScheduleDetail> {
|
||||
@@ -1064,17 +1262,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const linkRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const links = await linkRepo.find({ where: { trainScheduleId: s.id } });
|
||||
const linkedIds = new Set(links.map((l) => l.bookingId));
|
||||
// The full graph already carries the schedule↔booking links — no separate
|
||||
// link query needed.
|
||||
const linkedIds = new Set(
|
||||
(s.scheduleBookings ?? []).map((l) => l.bookingId),
|
||||
);
|
||||
const bookings = await this.bookingsRepository.findAllBySchedule(s.id);
|
||||
|
||||
let allocationPreview: Awaited<
|
||||
ReturnType<TrainSchedulingService["previewAllocationForSchedule"]>
|
||||
>;
|
||||
try {
|
||||
// Reuse the graph loaded above — the preview otherwise re-loads the same
|
||||
// heavy schedule graph a second time per request.
|
||||
allocationPreview =
|
||||
await this.trainSchedulingService.previewAllocationForSchedule(s.id);
|
||||
await this.trainSchedulingService.previewAllocationForSchedule(
|
||||
s.id,
|
||||
s,
|
||||
);
|
||||
} catch {
|
||||
allocationPreview = {
|
||||
assignedBookingIds: [],
|
||||
@@ -1144,73 +1349,22 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
const loco = s.trainSet?.locomotive ?? null;
|
||||
|
||||
// Display windows are the REAL booking-window cycles this schedule was FROZEN
|
||||
// with at creation (import: opens at its stored window time, lasts its rule's
|
||||
// duration, reopens per its rule's delay; export: single FCFS lead window) —
|
||||
// NOT the live global config. A later global-rules edit only re-derives
|
||||
// not-yet-open schedules (restampPendingWindows), so an already-open schedule
|
||||
// must keep drawing from its own snapshot, anchored on its stored open time.
|
||||
// Legacy rows with no snapshot fall back to the live config.
|
||||
const liveCfg = await this.trainSchedulingService.getWindowConfig();
|
||||
const num = (v: unknown, fallback: number) => {
|
||||
const n = v == null ? NaN : Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
const windowCfg = {
|
||||
windowOpenHour: num(s.ruleWindowOpenHour, liveCfg.windowOpenHour),
|
||||
windowCloseHour: num(s.ruleWindowCloseHour, liveCfg.windowCloseHour),
|
||||
windowDurationHours: num(
|
||||
s.ruleWindowDurationHours,
|
||||
liveCfg.windowDurationHours,
|
||||
),
|
||||
// Frozen doc-review + payment sum; legacy rows fall back to the live sum.
|
||||
reopenGapMinutes: num(
|
||||
s.ruleReopenDelayMinutes,
|
||||
liveCfg.docReviewMinutes + liveCfg.paymentWindowMinutes,
|
||||
),
|
||||
importWindowLeadDays: num(
|
||||
s.ruleImportWindowLeadDays,
|
||||
liveCfg.importWindowLeadDays,
|
||||
),
|
||||
exportBookingLeadHours: num(
|
||||
s.ruleExportBookingLeadHours,
|
||||
liveCfg.exportBookingLeadHours,
|
||||
),
|
||||
// Frozen close offsets: a snapshot null means "no offset for this train"
|
||||
// and stays null (not the live offset); only legacy rows lacking the
|
||||
// column (undefined) fall back to live config.
|
||||
importCloseOffsetMinutes:
|
||||
s.ruleImportCloseOffsetMinutes !== undefined
|
||||
? s.ruleImportCloseOffsetMinutes
|
||||
: liveCfg.importCloseOffsetMinutes,
|
||||
exportCloseOffsetMinutes:
|
||||
s.ruleExportCloseOffsetMinutes !== undefined
|
||||
? s.ruleExportCloseOffsetMinutes
|
||||
: liveCfg.exportCloseOffsetMinutes,
|
||||
};
|
||||
const departureDate = s.scheduledDepartureDate ?? new Date();
|
||||
const windowBuckets = groupBookingsIntoBoardWindows(
|
||||
items,
|
||||
(item) => (item.fullyExecutedAt ? new Date(item.fullyExecutedAt) : null),
|
||||
s.direction ?? null,
|
||||
departureDate,
|
||||
windowCfg,
|
||||
undefined,
|
||||
s.windowOpensAt ?? null,
|
||||
);
|
||||
|
||||
const emptyCounts = () => ({
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
});
|
||||
|
||||
const countFor = (bookingsInWindow: BatchBoardBookingDetail[]) => {
|
||||
const counts = emptyCounts();
|
||||
for (const b of bookingsInWindow) {
|
||||
// The board renders ONE booking window — the schedule's own frozen window
|
||||
// (windowOpensAt/windowClosesAt + phase deadlines returned below). Bookings
|
||||
// split into two buckets: contract executed (in the window) vs pending
|
||||
// contract. The old per-cycle window projection was dropped — the UI never
|
||||
// showed it, and reconstructing every cycle cost a config load + grouping
|
||||
// pass per request.
|
||||
const countFor = (bucket: BatchBoardBookingDetail[]): BatchBoardCounts => {
|
||||
const counts: BatchBoardCounts = {
|
||||
allocated: 0,
|
||||
selectedForBatch: 0,
|
||||
ready: 0,
|
||||
waiting: 0,
|
||||
expired: 0,
|
||||
pendingContract: 0,
|
||||
};
|
||||
for (const b of bucket) {
|
||||
if (b.state === "ALLOCATED") counts.allocated += 1;
|
||||
else if (b.state === "SELECTED_FOR_BATCH") counts.selectedForBatch += 1;
|
||||
else if (b.state === "READY") counts.ready += 1;
|
||||
@@ -1221,26 +1375,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return counts;
|
||||
};
|
||||
|
||||
const windows: BatchWindowGroup[] = [];
|
||||
for (const [key, bucket] of windowBuckets) {
|
||||
if (key === "pending-contract" || !bucket.window) continue;
|
||||
const w = bucket.window;
|
||||
windows.push({
|
||||
key: w.key,
|
||||
label: w.label,
|
||||
date: w.date,
|
||||
dateLabel: w.dateLabel,
|
||||
start: w.start.toISOString(),
|
||||
end: w.end.toISOString(),
|
||||
counts: countFor(bucket.items),
|
||||
bookings: bucket.items,
|
||||
});
|
||||
}
|
||||
windows.sort(
|
||||
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime(),
|
||||
);
|
||||
|
||||
const pendingBookings = windowBuckets.get("pending-contract")?.items ?? [];
|
||||
const windowBookings = items.filter((i) => i.fullyExecutedAt);
|
||||
const pendingBookings = items.filter((i) => !i.fullyExecutedAt);
|
||||
|
||||
return {
|
||||
scheduleId: s.id,
|
||||
@@ -1290,14 +1426,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
.length,
|
||||
expired: items.filter((i) => i.state === "EXPIRED").length,
|
||||
},
|
||||
windows,
|
||||
bookings: windowBookings,
|
||||
pendingContract: {
|
||||
key: "pending-contract",
|
||||
label: "Pending contract",
|
||||
date: "",
|
||||
dateLabel: "",
|
||||
start: "",
|
||||
end: "",
|
||||
counts: countFor(pendingBookings),
|
||||
bookings: pendingBookings,
|
||||
},
|
||||
@@ -1852,15 +1982,23 @@ export class BookingBatchService implements OnModuleInit {
|
||||
}
|
||||
|
||||
/**
|
||||
* A lone commercial IMPORT booking on a GENERAL or ONE_TIME contract may be
|
||||
* offered a partial (split-on-payment). Consolidated pairs never split (both-or-
|
||||
* neither shared wagon) and government bookings never split (they preempt).
|
||||
* A lone commercial booking on a GENERAL or ONE_TIME contract may be offered a
|
||||
* partial (split-on-payment). Consolidated pairs never split (both-or-neither
|
||||
* shared wagon) and government bookings never split (they preempt).
|
||||
*
|
||||
* IMPORT is always eligible. EXPORT is eligible only when export split is
|
||||
* enabled: export historically rides one train whole, so splitting it changes
|
||||
* the FCFS money path — each split part still rides ONE train whole, and the
|
||||
* leftover becomes its own booking on the next train.
|
||||
*/
|
||||
private isSplitEligible(booking: Booking, isPair: boolean): boolean {
|
||||
const directionOk =
|
||||
booking.tradeDirection === "IMPORT" ||
|
||||
(booking.tradeDirection === "EXPORT" && this.exportSplitEnabled);
|
||||
return (
|
||||
!isPair &&
|
||||
!booking.isGovernment &&
|
||||
booking.tradeDirection === "IMPORT" &&
|
||||
directionOk &&
|
||||
(booking.contractKind === "GENERAL" || booking.contractKind === "ONE_TIME") &&
|
||||
this.splitService != null
|
||||
);
|
||||
@@ -2323,7 +2461,13 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (!schedule || !locomotive) return null;
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const limits = await this.capacityLimits(locomotive);
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims);
|
||||
// Built trains: collapse to a single train-wide pool so the freed capacity of
|
||||
// a booking that alights mid-corridor is NOT re-offered on the pass-through
|
||||
// leg (see remainingBudget). Keeps intercity accept consistent with the
|
||||
// train-wide isTrainFull / committedWagons finalize signal.
|
||||
const budget = await this.remainingBudget(schedule, limits, wagonDims, {
|
||||
collapseForBuiltTrain: true,
|
||||
});
|
||||
return { budget, needFor: (booking) => this.needFor(booking, wagonDims) };
|
||||
}
|
||||
|
||||
@@ -3117,7 +3261,14 @@ export class BookingBatchService implements OnModuleInit {
|
||||
* (NW5 flat for containers, CW3 gondola for bulk) for bookings whose type has
|
||||
* no wagon type configured yet.
|
||||
*/
|
||||
/** Wagon types are near-static reference data — a short TTL cache spares one
|
||||
* table scan per board/detail request without letting edits go stale long. */
|
||||
private wagonDimsCache: { value: WagonDims; expiresAt: number } | null = null;
|
||||
|
||||
private async loadWagonDims(): Promise<WagonDims> {
|
||||
if (this.wagonDimsCache && this.wagonDimsCache.expiresAt > Date.now()) {
|
||||
return this.wagonDimsCache.value;
|
||||
}
|
||||
const types = await this.dataSource.getRepository(WagonType).find();
|
||||
const byCode = new Map(
|
||||
types.map((t) => [t.code, wagonTypeDimensionsFromEntity(t)]),
|
||||
@@ -3131,7 +3282,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
// must fall back rather than yield an infinite wagon count.
|
||||
const payload = (value: number | undefined, fallback: number): number =>
|
||||
value && value > 0 ? value : fallback;
|
||||
return {
|
||||
const value: WagonDims = {
|
||||
container: {
|
||||
lengthMeters: nw5?.lengthMeters ?? DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
tareWeightTons: nw5?.tareWeightTons ?? DEFAULT_CONTAINER_WAGON_TARE_TONS,
|
||||
@@ -3144,6 +3295,8 @@ export class BookingBatchService implements OnModuleInit {
|
||||
},
|
||||
byWagonTypeId,
|
||||
};
|
||||
this.wagonDimsCache = { value, expiresAt: Date.now() + 60_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3174,6 +3327,40 @@ export class BookingBatchService implements OnModuleInit {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* EVERY wagon-type dimension a booking may ride — its cargo/container type's
|
||||
* full allowed (many-to-many) wagon-type list, not just the first like
|
||||
* {@link dimsFor}. The remainder placer needs the whole set so a train that
|
||||
* stocks a non-primary allowed type still counts as fitting: a container type
|
||||
* mapped to both NW5 and (say) NW7 must be measured against whichever a given
|
||||
* train actually has free. Deduped by wagon-type id; falls back to the single
|
||||
* representative dims when no allowed type is configured.
|
||||
*/
|
||||
private dimsForAllowed(booking: Booking, wagonDims: WagonDims): PerWagonDims[] {
|
||||
const fallback =
|
||||
booking.freightType === "BULK" ? wagonDims.bulk : wagonDims.container;
|
||||
const ids =
|
||||
booking.freightType === "BULK"
|
||||
? (booking.cargoType?.wagonTypes ?? []).map((wt) => wt.id)
|
||||
: (booking.bookingContainers ?? [])
|
||||
.flatMap((line) => line.containerType?.wagonTypes ?? [])
|
||||
.map((wt) => wt.id);
|
||||
const seen = new Set<string>();
|
||||
const dims: PerWagonDims[] = [];
|
||||
for (const id of ids) {
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const d = wagonDims.byWagonTypeId.get(id);
|
||||
if (d) {
|
||||
dims.push({
|
||||
...d,
|
||||
capacityTons: d.capacityTons > 0 ? d.capacityTons : fallback.capacityTons,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dims.length ? dims : [fallback];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered stop yards of the schedule's route (origin → milestones →
|
||||
* destination); the legacy two-stop pseudo-route when milestones are absent.
|
||||
@@ -3211,6 +3398,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
schedule: TrainSchedule,
|
||||
limits: TrainLimits,
|
||||
wagonDims: WagonDims,
|
||||
opts?: { collapseForBuiltTrain?: boolean },
|
||||
): Promise<CorridorBudget> {
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
@@ -3223,7 +3411,21 @@ export class BookingBatchService implements OnModuleInit {
|
||||
tolerance: { weightTons: 0, lengthMeters: 0 },
|
||||
};
|
||||
}
|
||||
const stops = await this.stopsForSchedule(schedule);
|
||||
// A built train's wagons are coupled for the WHOLE trip, and the allocator
|
||||
// commits each booking to a wagon for the entire route — it never reloads a
|
||||
// wagon at a mid-corridor alight yard. So a built train has no leg concept:
|
||||
// its capacity is one train-wide pool, exactly as isTrainFull /
|
||||
// committedWagons already count it. When a caller opts in, collapse the
|
||||
// corridor to a single whole-route edge so every booking (full-route OR
|
||||
// mid-corridor) draws from that one pool — a train full of import-to-DireDawa
|
||||
// then correctly shows NO room for a DireDawa->Addis intercity booking on the
|
||||
// leg it merely passes through, instead of over-promising the freed slots.
|
||||
// Locomotive-derived schedules keep the leg-aware multi-edge corridor: their
|
||||
// abstract slot/weight/length budget genuinely frees past an alight yard.
|
||||
const stops =
|
||||
physicalWagons != null && opts?.collapseForBuiltTrain
|
||||
? [schedule.originStationId, schedule.destinationStationId]
|
||||
: await this.stopsForSchedule(schedule);
|
||||
const budget = new CorridorBudget(stops, limits.base, limits.tolerance);
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
@@ -3385,11 +3587,19 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
/** See {@link isScheduleFull} — same check for callers that already hold the full graph. */
|
||||
private async isTrainFull(schedule: TrainSchedule): Promise<boolean> {
|
||||
// Built train: the physical consist is the only capacity axis, and a wagon
|
||||
// is committed to its booking for the WHOLE trip — wagon allocation has no
|
||||
// leg concept, so a wagon hauling Negad→Mojo cargo can never be re-sold for
|
||||
// the Doraleh→Negad edge it merely passes through. Count commitments
|
||||
// train-wide, not per corridor edge: the per-edge budget read "free slots"
|
||||
// on pass-through legs of a sold-out consist, so the window of a full train
|
||||
// cycled OPEN forever instead of concluding DONE (and the day pool's
|
||||
// leftover bookings were never expired).
|
||||
const physicalWagons = await this.builtTrainWagonCount(schedule);
|
||||
if (physicalWagons != null) {
|
||||
return (await this.committedWagons(schedule)) >= physicalWagons;
|
||||
}
|
||||
if ((await this.remainingWagons(schedule)) <= 0) return true;
|
||||
// Built train: the physical consist is the only capacity axis. Weight and
|
||||
// length were enforced when the consist was assembled (builder /
|
||||
// adjust-consist), so a free wagon slot means the train genuinely has room.
|
||||
if ((await this.builtTrainWagonCount(schedule)) != null) return false;
|
||||
const locomotive = schedule.trainSet?.locomotive;
|
||||
if (!locomotive) return false; // no weight/length limits to bind against
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
@@ -3398,6 +3608,29 @@ export class BookingBatchService implements OnModuleInit {
|
||||
return budget.isExhausted(this.minPerWagonNeed(wagonDims));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagons the schedule's allocated + reserved bookings occupy train-wide,
|
||||
* regardless of which corridor leg each rides. Deduped by booking id — a
|
||||
* booking mid-settle can momentarily be both linked and reserved.
|
||||
*/
|
||||
private async committedWagons(schedule: TrainSchedule): Promise<number> {
|
||||
const wagonDims = await this.loadWagonDims();
|
||||
const allocated = (schedule.scheduleBookings ?? [])
|
||||
.map((sb) => sb.booking)
|
||||
.filter((b): b is Booking => Boolean(b));
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(
|
||||
schedule.id,
|
||||
);
|
||||
const byId = new Map(
|
||||
[...allocated, ...reserved].map((b) => [b.id, b] as const),
|
||||
);
|
||||
let total = 0;
|
||||
for (const booking of byId.values()) {
|
||||
total += this.wagonsFor(booking, wagonDims);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Smallest gross weight / shortest length one more wagon could add: the
|
||||
* lightest wagon type at its rated payload. Feeds CorridorBudget.isExhausted,
|
||||
|
||||
@@ -352,10 +352,19 @@ export class BookingJourneyService {
|
||||
): Promise<void> {
|
||||
if (booking.tradeDirection !== 'DOMESTIC') return;
|
||||
const facility = await this.yardFacilities.facilityForYard(yardId);
|
||||
const where = side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination';
|
||||
|
||||
if (!facility?.hasFacility) {
|
||||
throw new BadRequestException(
|
||||
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ` +
|
||||
`${side === 'origin' ? 'loaded at its origin' : 'unloaded at its destination'} here.`,
|
||||
`${facility?.yardLabel ?? 'This yard'} has no load/unload facility — an intercity booking cannot be ${where} here.`,
|
||||
);
|
||||
}
|
||||
// A facility only handles what its equipment can lift: containers need a
|
||||
// reach stacker/gantry, bulk does not.
|
||||
if (!this.yardFacilities.canHandleFreight(facility, booking.freightType)) {
|
||||
throw new BadRequestException(
|
||||
`${facility.yardLabel ?? 'This yard'} does not handle ${String(booking.freightType).toLowerCase()} cargo — ` +
|
||||
`an intercity booking cannot be ${where} here.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,10 +150,18 @@ export class BookingNotifierService {
|
||||
): Promise<void> {
|
||||
const payMinutes = Math.max(1, Math.round((deadline.getTime() - Date.now()) / 60_000));
|
||||
const eat = deadline.toLocaleString('en-GB', { timeZone: 'Africa/Addis_Ababa' });
|
||||
const leftover = totalWagons - offeredWagons;
|
||||
// With auto-placement on, the leftover is booked FOR the customer on another
|
||||
// train (its own invoice) — telling them to rebook it themselves would be
|
||||
// wrong. Without it, the leftover returns to the contract to rebook.
|
||||
const leftoverCopy =
|
||||
process.env.FREIGHT_AUTO_REMAINDER === 'true'
|
||||
? `The remaining ${leftover} will be booked for you on another train, with its own invoice. `
|
||||
: `The remaining ${leftover} return${leftover === 1 ? 's' : ''} to your contract — book them yourself in a later window. `;
|
||||
const msg =
|
||||
`Only ${offeredWagons} of ${totalWagons} wagons fit the train for booking ${b.reference ?? b.id}. ` +
|
||||
`Pay within ${payMinutes} minute${payMinutes === 1 ? '' : 's'} to accept and ship ${offeredWagons} wagon${offeredWagons === 1 ? '' : 's'} now. ` +
|
||||
`The remaining ${totalWagons - offeredWagons} return${totalWagons - offeredWagons === 1 ? 's' : ''} to your contract — book them yourself in a later window. ` +
|
||||
leftoverCopy +
|
||||
`If you do not pay, the booking stays whole and you can rebook in the next window. Deadline: ${eat} EAT.`;
|
||||
await this.notifyContact(b, msg, 'PAY NOW (PARTIAL)');
|
||||
// HIGH: a split is a change to what the customer ordered AND a live payment
|
||||
@@ -164,6 +172,23 @@ export class BookingNotifierService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The wagons that did not fit the train the customer just paid for have been
|
||||
* auto-booked as their own booking (`remainder`) — they ride another train and
|
||||
* are billed separately. Sent instead of leaving the customer to rebook.
|
||||
*/
|
||||
remainderPlaced(remainder: Booking, parentReference: string): void {
|
||||
const msg =
|
||||
`The wagons left over from booking ${parentReference} have been booked as ` +
|
||||
`${remainder.reference ?? remainder.id} on another train. ` +
|
||||
`It carries its own invoice — pay it to secure that slot.`;
|
||||
void this.notifyContact(remainder, msg, 'REMAINDER BOOKED');
|
||||
this.inApp(remainder, 'Leftover wagons booked', msg, {
|
||||
type: NotificationType.INVOICE_ISSUED,
|
||||
priority: NotificationPriority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
secured(b: Booking, reason: 'paid' | 'gov', scheduleId?: string | null): void {
|
||||
void (async () => {
|
||||
const label = await this.scheduleLabel(scheduleId ?? b.trainScheduleId);
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { plainToInstance } from 'class-transformer';
|
||||
import { validate } from 'class-validator';
|
||||
|
||||
import { RecordCheckpointDto } from './record-checkpoint.dto';
|
||||
|
||||
const validateBody = (body: Record<string, unknown>) =>
|
||||
validate(plainToInstance(RecordCheckpointDto, body));
|
||||
|
||||
describe('RecordCheckpointDto', () => {
|
||||
// The final checkpoint arrives the schedule, so a backdated one rewrites the
|
||||
// journey after the fact. No UI sends occurredAt; the endpoint still accepts it.
|
||||
it('rejects a backdated occurredAt', async () => {
|
||||
const errors = await validateBody({
|
||||
sequenceNo: 3,
|
||||
occurredAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
});
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0].property).toBe('occurredAt');
|
||||
expect(errors[0].constraints).toHaveProperty('IsNotBackdated');
|
||||
});
|
||||
|
||||
it('accepts occurredAt of now', async () => {
|
||||
const errors = await validateBody({
|
||||
sequenceNo: 3,
|
||||
occurredAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('accepts a body that omits occurredAt, leaving the service to stamp it', async () => {
|
||||
const errors = await validateBody({ sequenceNo: 0 });
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { IsNotBackdated } from '../../../common/validators/is-not-backdated.validator';
|
||||
|
||||
export class RecordCheckpointDto {
|
||||
@ApiProperty({ description: 'Station position along the route (0 = origin).' })
|
||||
@IsInt()
|
||||
@@ -21,9 +23,18 @@ export class RecordCheckpointDto {
|
||||
@IsEnum(TrainCheckpointKind)
|
||||
kind?: TrainCheckpointKind;
|
||||
|
||||
@ApiProperty({ required: false, description: 'ISO timestamp; defaults to now.' })
|
||||
/**
|
||||
* A checkpoint records where the train is as staff observe it, and the final
|
||||
* one arrives the schedule — so a backdated value rewrites the journey after
|
||||
* the fact. Only "now" is accepted; omit the field and the service stamps it.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@IsNotBackdated()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
|
||||
@@ -67,10 +67,18 @@ export class IntercityService {
|
||||
ts.status AS "scheduleStatus",
|
||||
oy.id AS "originYardId",
|
||||
COALESCE(oy.label, oy.code) AS "origin",
|
||||
oy.has_facility AS "originHasFacility",
|
||||
-- Can that end actually handle THIS booking's cargo? A container
|
||||
-- booking needs a facility with a stacker; bulk needs any facility.
|
||||
(oy.has_facility AND COALESCE(
|
||||
CASE WHEN b.freight_type = 'CONTAINER'
|
||||
THEN ofac.handles_container ELSE ofac.handles_bulk END, false))
|
||||
AS "originHasFacility",
|
||||
dy.id AS "destinationYardId",
|
||||
COALESCE(dy.label, dy.code) AS "destination",
|
||||
dy.has_facility AS "destinationHasFacility",
|
||||
(dy.has_facility AND COALESCE(
|
||||
CASE WHEN b.freight_type = 'CONTAINER'
|
||||
THEN dfac.handles_container ELSE dfac.handles_bulk END, false))
|
||||
AS "destinationHasFacility",
|
||||
-- Where the train actually is, so the operator knows if the cargo
|
||||
-- can be worked right now.
|
||||
cp.yard_id AS "trainAtYardId",
|
||||
@@ -80,6 +88,10 @@ export class IntercityService {
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.yard_facilities ofac
|
||||
ON ofac.yard_id = oy.id AND ofac.deleted_at IS NULL AND ofac.is_active = true
|
||||
LEFT JOIN freight.yard_facilities dfac
|
||||
ON dfac.yard_id = dy.id AND dfac.deleted_at IS NULL AND dfac.is_active = true
|
||||
LEFT JOIN freight.train_schedules ts
|
||||
ON ts.id = b.train_schedule_id AND ts.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
@@ -104,7 +116,8 @@ export class IntercityService {
|
||||
|
||||
async listCandidates(scheduleId: string) {
|
||||
const schedule = await this.getSchedule(scheduleId);
|
||||
const milestoneSeq = await this.routeMilestoneSequence(schedule);
|
||||
const milestones = await this.routeMilestones(schedule);
|
||||
const milestoneSeq = this.milestoneSequenceOf(schedule, milestones);
|
||||
const capacity = await this.bookingBatchService.intercityCapacity(scheduleId);
|
||||
|
||||
const waiting = milestoneSeq
|
||||
@@ -112,29 +125,45 @@ export class IntercityService {
|
||||
: [];
|
||||
const accepted = await this.findAcceptedIntercityBookings(scheduleId);
|
||||
|
||||
// Mid-corridor intercity matching needs a real stop list (>= 2 route
|
||||
// milestones). Without one the fallback is a 2-stop origin->destination
|
||||
// pseudo-route that only matches bookings on the train's exact corridor —
|
||||
// surface that so an empty candidate list isn't misread as "nobody waiting".
|
||||
const warning =
|
||||
milestoneSeq == null
|
||||
? 'This schedule has no route or origin/destination set, so no intercity corridors can be served.'
|
||||
: schedule.routeId && milestones.length < 2
|
||||
? "This schedule's route has no stop list (needs at least 2 route milestones), so mid-corridor intercity bookings cannot be matched — only bookings on the train's exact origin→destination will appear."
|
||||
: null;
|
||||
|
||||
return {
|
||||
scheduleId,
|
||||
routeId: schedule.routeId ?? null,
|
||||
warning,
|
||||
// Segment-based: "remaining" is the most-open edge; each candidate's
|
||||
// `fits` is judged against ITS OWN leg, so a booking on a free leg fits
|
||||
// even when the train is full elsewhere.
|
||||
remaining: capacity?.budget.maxRemaining() ?? null,
|
||||
candidates: waiting.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
const leg = capacity?.budget.legOf(
|
||||
// legForYards, not legOf: on a built train the budget is a single
|
||||
// whole-route edge (see intercityCapacity), so a mid-corridor booking
|
||||
// must draw from that one pool via the whole-route fallback. On a
|
||||
// locomotive-derived schedule it still resolves to the booking's own leg.
|
||||
const leg = capacity?.budget.legForYards(
|
||||
booking.originYardId,
|
||||
booking.destinationYardId,
|
||||
);
|
||||
return {
|
||||
...this.mapBooking(booking),
|
||||
...this.mapBooking(booking, need),
|
||||
need,
|
||||
fits: Boolean(need && capacity && leg && capacity.budget.fits(need, leg)),
|
||||
};
|
||||
}),
|
||||
accepted: accepted.map((booking) => ({
|
||||
...this.mapBooking(booking),
|
||||
need: capacity?.needFor(booking) ?? null,
|
||||
})),
|
||||
accepted: accepted.map((booking) => {
|
||||
const need = capacity?.needFor(booking) ?? null;
|
||||
return { ...this.mapBooking(booking, need), need };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,14 +215,17 @@ export class IntercityService {
|
||||
continue;
|
||||
}
|
||||
const need = capacity.needFor(booking);
|
||||
const leg = budget.legOf(booking.originYardId, booking.destinationYardId);
|
||||
// Segment-based: only the booking's own leg must have room, so an
|
||||
// intercity booking still boards a train that is full on other legs.
|
||||
if (!leg || !budget.fits(need, leg)) {
|
||||
// legForYards, not legOf: a built train's budget is a single whole-route
|
||||
// pool (mid-corridor wagons are committed for the whole trip and never
|
||||
// reloaded), so the booking draws from that pool via the whole-route
|
||||
// fallback; a locomotive-derived schedule still gets the booking's own
|
||||
// leg, so it can still board a train that is full only on other legs.
|
||||
const leg = budget.legForYards(booking.originYardId, booking.destinationYardId);
|
||||
if (!budget.fits(need, leg)) {
|
||||
rejected.push({
|
||||
bookingId,
|
||||
reason:
|
||||
'Does not fit the remaining wagon/weight/length capacity on its leg',
|
||||
'Does not fit the remaining wagon/weight/length capacity for this train',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -244,16 +276,21 @@ export class IntercityService {
|
||||
* so an intercity booking exactly matching the train's own corridor still
|
||||
* qualifies.
|
||||
*/
|
||||
private async routeMilestoneSequence(
|
||||
private async routeMilestones(
|
||||
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]));
|
||||
}
|
||||
): Promise<RouteMilestone[]> {
|
||||
if (!schedule.routeId) return [];
|
||||
return this.dataSource
|
||||
.getRepository(RouteMilestone)
|
||||
.find({ where: { routeId: schedule.routeId }, order: { sequenceNo: 'ASC' } });
|
||||
}
|
||||
|
||||
private milestoneSequenceOf(
|
||||
schedule: TrainSchedule,
|
||||
milestones: RouteMilestone[],
|
||||
): Map<string, number> | null {
|
||||
if (milestones.length >= 2) {
|
||||
return new Map(milestones.map((m) => [m.yardId, m.sequenceNo]));
|
||||
}
|
||||
if (schedule.originStationId && schedule.destinationStationId) {
|
||||
return new Map([
|
||||
@@ -264,6 +301,15 @@ export class IntercityService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async routeMilestoneSequence(
|
||||
schedule: TrainSchedule,
|
||||
): Promise<Map<string, number> | null> {
|
||||
return this.milestoneSequenceOf(
|
||||
schedule,
|
||||
await this.routeMilestones(schedule),
|
||||
);
|
||||
}
|
||||
|
||||
/** Waiting = ready intercity bookings not yet on any train, corridor on this route. */
|
||||
private async findWaitingIntercityBookings(
|
||||
milestoneSeq: Map<string, number>,
|
||||
@@ -356,7 +402,12 @@ export class IntercityService {
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
private mapBooking(booking: Booking) {
|
||||
/**
|
||||
* `need` carries the GROSS weight (cargo + wagon tare) the capacity budget is
|
||||
* spent in. Prefer it, so the row's weight sits on the same axis as the
|
||||
* remaining-capacity figure shown beside it; cargo VGM is the fallback.
|
||||
*/
|
||||
private mapBooking(booking: Booking, need?: { weightTons: number } | null) {
|
||||
return {
|
||||
id: booking.id,
|
||||
reference: booking.reference,
|
||||
@@ -372,7 +423,7 @@ export class IntercityService {
|
||||
booking.destinationYard?.label ??
|
||||
booking.destinationYard?.code ??
|
||||
'Unknown destination',
|
||||
weightTons: Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
weightTons: need?.weightTons ?? Number(booking.cargoTotalWeightVgm ?? 0),
|
||||
paymentDeadline: booking.paymentDeadline?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
|
||||
/**
|
||||
* The remainder placer reconstructs the outstanding split remainder as a new
|
||||
* booking. The delicate parts under test: bulk sizes from the outstanding tons;
|
||||
* container recovers real numbers from the SOFT-DELETED units (never fabricates)
|
||||
* and throws on a shortfall; and nothing is placed when there's no outstanding
|
||||
* or no fitting train.
|
||||
*/
|
||||
describe('RemainderPlacementService', () => {
|
||||
const DAY = '2026-07-20';
|
||||
|
||||
function make(opts: {
|
||||
freightType: 'CONTAINER' | 'BULK';
|
||||
contractKind?: 'ONE_TIME' | 'GENERAL';
|
||||
outstanding: unknown;
|
||||
createThrows?: Error;
|
||||
deferredUnits?: Array<{
|
||||
containerNumber: string;
|
||||
vgmTons: number;
|
||||
isHazardous?: boolean;
|
||||
isReefer?: boolean;
|
||||
}>;
|
||||
fittingTrains?: Array<{ scheduleId: string }>;
|
||||
}) {
|
||||
const contract = {
|
||||
id: 'c-1',
|
||||
freightType: opts.freightType,
|
||||
contractKind: opts.contractKind ?? 'ONE_TIME',
|
||||
};
|
||||
const contractsRepository = {
|
||||
findByIdWithRelations: jest.fn().mockResolvedValue(contract),
|
||||
};
|
||||
const createUnderContract = opts.createThrows
|
||||
? jest.fn().mockRejectedValue(opts.createThrows)
|
||||
: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ booking: { id: 'rem-1', reference: 'BKG-R' }, warnings: [] });
|
||||
const contractBookingService = {
|
||||
splitOutstanding: jest.fn().mockResolvedValue(opts.outstanding),
|
||||
createUnderContract,
|
||||
};
|
||||
const bookingBatchService = {
|
||||
fittingTrainsForDay: jest
|
||||
.fn()
|
||||
.mockResolvedValue(opts.fittingTrains ?? [{ scheduleId: 's-2' }]),
|
||||
};
|
||||
// getRepository is only hit on the container path (recoverDeferredUnits).
|
||||
const lineRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 'line-1' }]),
|
||||
};
|
||||
const unitRepo = {
|
||||
find: jest.fn().mockResolvedValue(opts.deferredUnits ?? []),
|
||||
};
|
||||
const dataSource = {
|
||||
getRepository: jest.fn((entity: { name?: string }) => {
|
||||
const n = entity?.name ?? '';
|
||||
if (n.includes('Unit')) return unitRepo;
|
||||
return lineRepo;
|
||||
}),
|
||||
};
|
||||
const notifier = { remainderPlaced: jest.fn() };
|
||||
const service = new RemainderPlacementService(
|
||||
dataSource as never,
|
||||
contractsRepository as never,
|
||||
contractBookingService as never,
|
||||
bookingBatchService as never,
|
||||
notifier as never,
|
||||
);
|
||||
return {
|
||||
service,
|
||||
createUnderContract,
|
||||
contractBookingService,
|
||||
bookingBatchService,
|
||||
notifier,
|
||||
};
|
||||
}
|
||||
|
||||
const splitBooking = {
|
||||
id: 'bk-1',
|
||||
reference: 'BKG-1',
|
||||
contractId: 'c-1',
|
||||
scheduledDate: new Date('2026-07-20T06:00:00Z'),
|
||||
createdByUserId: 'u-1',
|
||||
} as never;
|
||||
|
||||
it('sizes a BULK remainder from the outstanding tons', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBe('rem-1');
|
||||
const dto = createUnderContract.mock.calls[0][1];
|
||||
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 40 }]);
|
||||
expect(dto.scheduledDate).toBe(DAY);
|
||||
});
|
||||
|
||||
it('rebuilds a CONTAINER remainder from the soft-deleted units', async () => {
|
||||
const deferredUnits = [
|
||||
{ containerNumber: 'ABCD1234567', vgmTons: 12, isReefer: true },
|
||||
{ containerNumber: 'ABCD7654321', vgmTons: 10, isHazardous: true },
|
||||
];
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'CONTAINER',
|
||||
outstanding: {
|
||||
bySize: new Map([['40ft', { total: 5, outstanding: 2 }]]),
|
||||
bulk: null,
|
||||
},
|
||||
deferredUnits,
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBe('rem-1');
|
||||
const dto = createUnderContract.mock.calls[0][1];
|
||||
expect(dto.containers).toHaveLength(1);
|
||||
const line = dto.containers[0];
|
||||
expect(line.containerSize).toBe('40ft');
|
||||
expect(line.quantity).toBe(2);
|
||||
expect(line.units.map((u: { containerNumber: string }) => u.containerNumber)).toEqual([
|
||||
'ABCD1234567',
|
||||
'ABCD7654321',
|
||||
]);
|
||||
expect(line.reeferQuantity).toBe(1);
|
||||
expect(line.hazardousQuantity).toBe(1);
|
||||
});
|
||||
|
||||
it('throws (→ no placement) when fewer units are recoverable than outstanding — never fabricates', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'CONTAINER',
|
||||
outstanding: {
|
||||
bySize: new Map([['40ft', { total: 5, outstanding: 3 }]]),
|
||||
bulk: null,
|
||||
},
|
||||
deferredUnits: [{ containerNumber: 'ABCD1234567', vgmTons: 12 }], // only 1, need 3
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op when there is no outstanding remainder', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 0 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('tells the customer the leftover wagons were booked on another train', async () => {
|
||||
const { service, notifier } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
await service.placeRemainder(splitBooking);
|
||||
expect(notifier.remainderPlaced).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'rem-1' }),
|
||||
'BKG-1',
|
||||
);
|
||||
});
|
||||
|
||||
it('never double-books the leftover when two payments land together', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
// Both callers enter before either create commits.
|
||||
await Promise.all([
|
||||
service.placeRemainder(splitBooking),
|
||||
service.placeRemainder(splitBooking),
|
||||
]);
|
||||
expect(createUnderContract).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// splitOutstanding subtracts a CONTRACT-WIDE booked total from ONE booking's
|
||||
// snapshot — coherent only for ONE_TIME. On GENERAL that mixes scopes and
|
||||
// either drops a real remainder or double-draws the cap, so we must not place.
|
||||
it('never auto-places on a GENERAL contract (cap ledger mismatch)', async () => {
|
||||
const { service, createUnderContract, contractBookingService } = make({
|
||||
freightType: 'BULK',
|
||||
contractKind: 'GENERAL',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
expect(contractBookingService.splitOutstanding).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// The paid booking has already boarded — a create-gate rejection (e.g. the
|
||||
// export whole-train gate) must leave the remainder rebookable, not escape.
|
||||
it('swallows a create rejection and leaves the remainder for manual rebook', async () => {
|
||||
const { service } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: { bySize: new Map(), bulk: { total: 100, outstanding: 40 } },
|
||||
createThrows: new Error('Not enough train space for this day.'),
|
||||
});
|
||||
await expect(service.placeRemainder(splitBooking)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('is a no-op when the contract has no split chain', async () => {
|
||||
const { service, createUnderContract } = make({
|
||||
freightType: 'BULK',
|
||||
outstanding: null,
|
||||
});
|
||||
const id = await service.placeRemainder(splitBooking);
|
||||
expect(id).toBeNull();
|
||||
expect(createUnderContract).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { Injectable, Logger, forwardRef, Inject } from '@nestjs/common';
|
||||
import { DataSource, IsNull, Not } from 'typeorm';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { Contract } from '../contracts/entities/contract.entity';
|
||||
import {
|
||||
ContractBookingService,
|
||||
SplitOutstanding,
|
||||
} from '../contracts/contract-booking.service';
|
||||
import { ContractsRepository } from '../contracts/contracts.repository';
|
||||
import {
|
||||
CreateBookingUnderContractDto,
|
||||
CreateContainerUnitDto,
|
||||
} from '../contracts/dto/create-booking-under-contract.dto';
|
||||
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
|
||||
import { BookingBatchService } from './booking-batch.service';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { eatDay } from './batch-window.util';
|
||||
|
||||
/**
|
||||
* Auto-creates and places the OUTSTANDING split remainder of a contract as a new
|
||||
* booking, so the customer doesn't have to manually rebook the wagons that did
|
||||
* not fit the train they just paid for.
|
||||
*
|
||||
* Fired (feature-flagged) right after `applySplit` runs on payment — i.e. only
|
||||
* once the customer has actually accepted+paid the offered part. Before payment
|
||||
* nothing is split: the booking stays whole and the customer may still edit or
|
||||
* cancel it. See the split lifecycle in {@link BookingSplitService.applySplit}.
|
||||
*
|
||||
* IMPORT/DOMESTIC: the remainder booking is created with the next fitting
|
||||
* shipment day set and then follows the normal windowed batch flow (train
|
||||
* assigned at window close, paid in its own window). It is NOT force-reserved on
|
||||
* a specific train — import is not FCFS.
|
||||
*
|
||||
* Container reconstruction is HYBRID: the remainder's quantities come from the
|
||||
* split snapshot (`splitOutstanding`), but the actual container numbers / VGM /
|
||||
* seals are read back from the units `applySplit` SOFT-DELETED off the parent
|
||||
* (they survive as valid ISO records). We never `restore()` those rows — the new
|
||||
* booking gets fresh rows — so the contract cap is never double-counted.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RemainderPlacementService {
|
||||
private readonly logger = new Logger(RemainderPlacementService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly contractsRepository: ContractsRepository,
|
||||
@Inject(forwardRef(() => ContractBookingService))
|
||||
private readonly contractBookingService: ContractBookingService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly notifier: BookingNotifierService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create + place the outstanding split remainder of the contract that owns
|
||||
* `splitBooking`. No-op when there is no live remainder or no fitting day.
|
||||
* Returns the created remainder booking id, or null when nothing was placed
|
||||
* (residual falls back to the customer's manual rebook, as today).
|
||||
*/
|
||||
async placeRemainder(splitBooking: Booking): Promise<string | null> {
|
||||
if (!splitBooking.contractId) return null;
|
||||
// Two payment webhooks for the same contract landing together would both see
|
||||
// the remainder as unbooked (the placing create has not committed yet) and
|
||||
// each create one — double-booking the leftover. Serialize per contract: the
|
||||
// second caller returns immediately and the first one's create is what the
|
||||
// (now smaller) outstanding reflects.
|
||||
if (this.inFlight.has(splitBooking.contractId)) {
|
||||
this.logger.debug(
|
||||
`Remainder placement already running for contract ${splitBooking.contractId} — skipped.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
this.inFlight.add(splitBooking.contractId);
|
||||
try {
|
||||
return await this.placeRemainderInner(splitBooking);
|
||||
} finally {
|
||||
this.inFlight.delete(splitBooking.contractId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Contracts with a placement in flight — see {@link placeRemainder}. */
|
||||
private readonly inFlight = new Set<string>();
|
||||
|
||||
private async placeRemainderInner(
|
||||
splitBooking: Booking,
|
||||
): Promise<string | null> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(
|
||||
splitBooking.contractId!,
|
||||
);
|
||||
if (!contract) return null;
|
||||
|
||||
// ONE_TIME only. `splitOutstanding` subtracts a CONTRACT-WIDE booked total
|
||||
// from a SINGLE booking's pre-split snapshot, which is only coherent when
|
||||
// the contract has exactly one live chain — that is the ONE_TIME invariant
|
||||
// (enforced by hasSplitBooking → assertExactRemainder). On a GENERAL
|
||||
// contract with other live bookings the subtraction mixes scopes: it either
|
||||
// clamps to 0 and silently drops a real remainder, or sizes one that then
|
||||
// draws the quantity cap a second time. GENERAL remainders keep the existing
|
||||
// manual-rebook behaviour until the remainder can be derived from the
|
||||
// offer's own dropped lines rather than from the contract-wide ledger.
|
||||
if (contract.contractKind !== 'ONE_TIME') {
|
||||
this.logger.debug(
|
||||
`Contract ${contract.id} is ${contract.contractKind} — remainder left ` +
|
||||
`for manual rebook (auto-placement is ONE_TIME only).`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const outstanding = await this.contractBookingService.splitOutstanding(
|
||||
contract,
|
||||
);
|
||||
if (!outstanding || !this.hasOutstanding(contract, outstanding)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The next fitting day: the earliest day on/after the split booking's own day
|
||||
// that still has an import train with room for this cargo type. We reuse the
|
||||
// split booking as the capacity probe — it carries the leg + cargo relations.
|
||||
const day = await this.nextFittingDay(splitBooking);
|
||||
if (!day) {
|
||||
this.logger.warn(
|
||||
`No train with room for the remainder of contract ${contract.id} ` +
|
||||
`(booking ${splitBooking.reference}) — left for manual rebook.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
let dto: CreateBookingUnderContractDto;
|
||||
try {
|
||||
dto = await this.buildRemainderDto(
|
||||
contract,
|
||||
outstanding,
|
||||
splitBooking.id,
|
||||
day,
|
||||
);
|
||||
} catch (err) {
|
||||
// A reconstruction shortfall (fewer recoverable units than outstanding)
|
||||
// must NOT fabricate container numbers — fail loudly, leave manual rebook.
|
||||
this.logger.error(
|
||||
`Could not reconstruct the remainder of contract ${contract.id}: ` +
|
||||
`${err instanceof Error ? err.message : String(err)} — left for manual rebook.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Any create-gate rejection (no train space, cap, container clash) must not
|
||||
// escape: the customer's paid booking has already boarded, and a thrown
|
||||
// error here would only be logged upstream while the remainder vanished
|
||||
// silently. Fall back to leaving it rebookable, which is the pre-feature
|
||||
// behaviour, and say so in the log.
|
||||
let created: Awaited<
|
||||
ReturnType<ContractBookingService['createUnderContract']>
|
||||
>;
|
||||
try {
|
||||
created = await this.contractBookingService.createUnderContract(
|
||||
contract.id,
|
||||
dto,
|
||||
{ id: splitBooking.createdByUserId ?? undefined },
|
||||
// System actor: a permission-bag carrying the contract create-booking key
|
||||
// so the GL gate (isGlActor → hasFreightPermission) passes for GL Path B
|
||||
// contracts; harmless for customer (Path A) contracts.
|
||||
{ permissions: [{ key: FREIGHT_PERMS.contracts.createBooking }] },
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Could not create the remainder booking for contract ${contract.id} ` +
|
||||
`(from ${splitBooking.reference}): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
} — left for manual rebook.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
// EXPORT is FCFS — there is no window to wait for, so the remainder is
|
||||
// reserved on the next export train right away (its own pay window opens).
|
||||
// If it does not fit one train whole either, the export accept offers it a
|
||||
// partial and the chain repeats on ITS payment: each pass leaves a strictly
|
||||
// smaller remainder, so it terminates at the day's train count.
|
||||
// IMPORT/DOMESTIC deliberately does NOT force a train: it carries the next
|
||||
// fitting day and rides the normal windowed batch flow.
|
||||
if (splitBooking.tradeDirection === 'EXPORT') {
|
||||
const fresh = await this.dataSource
|
||||
.getRepository(Booking)
|
||||
.findOne({
|
||||
where: { id: created.booking.id },
|
||||
relations: {
|
||||
company: true,
|
||||
bookingContainers: { containerType: true },
|
||||
cargoType: true,
|
||||
},
|
||||
});
|
||||
if (fresh) {
|
||||
await this.bookingBatchService
|
||||
.acceptExportBooking(fresh)
|
||||
.catch((err) =>
|
||||
// No export train took it — it stays created and rebookable, which
|
||||
// is the same place a customer-driven rebook would leave it.
|
||||
this.logger.warn(
|
||||
`Export remainder ${fresh.reference} created but not reserved: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.notifier.remainderPlaced(
|
||||
created.booking,
|
||||
splitBooking.reference ?? splitBooking.id,
|
||||
);
|
||||
this.logger.log(
|
||||
`Auto-placed split remainder of contract ${contract.id} as booking ` +
|
||||
`${created.booking.reference} on ${day}.`,
|
||||
);
|
||||
return created.booking.id;
|
||||
}
|
||||
|
||||
private hasOutstanding(
|
||||
contract: Contract,
|
||||
outstanding: SplitOutstanding,
|
||||
): boolean {
|
||||
if (contract.freightType === 'CONTAINER') {
|
||||
return [...outstanding.bySize.values()].some((s) => s.outstanding > 0);
|
||||
}
|
||||
return (outstanding.bulk?.outstanding ?? 0) > 0.001;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shipment day to create the remainder on — the split booking's own day.
|
||||
*
|
||||
* EXPORT is FCFS and must actually board a train that day, so a day with NO
|
||||
* export train having room is rejected (null → left for manual rebook on a day
|
||||
* the customer picks). IMPORT/DOMESTIC keeps the day regardless: its train is
|
||||
* assigned by the batch engine at window close, not now, and the window may
|
||||
* still free up — forcing a different day here would override the customer's
|
||||
* binding shipment day.
|
||||
*/
|
||||
private async nextFittingDay(booking: Booking): Promise<string | null> {
|
||||
if (!booking.scheduledDate) return null;
|
||||
const day = eatDay(new Date(booking.scheduledDate));
|
||||
if (booking.tradeDirection !== 'EXPORT') return day;
|
||||
|
||||
const fitting = await this.bookingBatchService.fittingTrainsForDay(
|
||||
booking,
|
||||
day,
|
||||
'EXPORT',
|
||||
);
|
||||
return fitting.length > 0 ? day : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the create-DTO for the WHOLE outstanding remainder. Bulk uses the
|
||||
* outstanding tonnage directly. Container reads the deferred (soft-deleted)
|
||||
* units of the split booking back into real unit records.
|
||||
*/
|
||||
private async buildRemainderDto(
|
||||
contract: Contract,
|
||||
outstanding: SplitOutstanding,
|
||||
splitBookingId: string,
|
||||
day: string,
|
||||
): Promise<CreateBookingUnderContractDto> {
|
||||
const dto: CreateBookingUnderContractDto = { scheduledDate: day };
|
||||
|
||||
if (contract.freightType !== 'CONTAINER') {
|
||||
const tons = outstanding.bulk?.outstanding ?? 0;
|
||||
dto.bulkLines = [{ cargoWeightTons: tons }];
|
||||
return dto;
|
||||
}
|
||||
|
||||
// Container: recover the deferred units per size from the split booking's
|
||||
// soft-deleted rows and reshape into DTO units.
|
||||
const containers: NonNullable<CreateBookingUnderContractDto['containers']> = [];
|
||||
for (const [size, { outstanding: need }] of outstanding.bySize) {
|
||||
if (need <= 0) continue;
|
||||
const units = await this.recoverDeferredUnits(splitBookingId, size, need);
|
||||
if (units.length < need) {
|
||||
throw new Error(
|
||||
`size ${size}: recovered ${units.length} deferred container(s) but ` +
|
||||
`${need} are outstanding`,
|
||||
);
|
||||
}
|
||||
const line: NonNullable<CreateBookingUnderContractDto['containers']>[number] = {
|
||||
containerSize: size,
|
||||
quantity: need,
|
||||
units,
|
||||
};
|
||||
line.hazardousQuantity = units.filter((u) => u.isHazardous).length;
|
||||
line.reeferQuantity = units.filter((u) => u.isReefer).length;
|
||||
containers.push(line);
|
||||
}
|
||||
dto.containers = containers;
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `need` deferred container units of a given size for the split booking,
|
||||
* read from the SOFT-DELETED unit rows (oldest sortOrder first — mirroring the
|
||||
* LIFO trim in applySplit so the same physical containers deferred are the
|
||||
* ones rebooked). Returns them as DTO units; does NOT restore the rows.
|
||||
*/
|
||||
private async recoverDeferredUnits(
|
||||
splitBookingId: string,
|
||||
containerSize: string,
|
||||
need: number,
|
||||
): Promise<CreateContainerUnitDto[]> {
|
||||
// The line ids of this booking for this size (live + soft-deleted): units
|
||||
// key on bookingContainerId, so gather every line of the size first.
|
||||
const lines = await this.dataSource
|
||||
.getRepository(BookingContainer)
|
||||
.find({
|
||||
where: { bookingId: splitBookingId, containerSize },
|
||||
withDeleted: true,
|
||||
select: { id: true },
|
||||
});
|
||||
const lineIds = lines.map((l) => l.id);
|
||||
if (!lineIds.length) return [];
|
||||
|
||||
// Only the DELETED units are the deferred ones (live units stayed on the
|
||||
// paid part). Oldest-first to match the deferred set.
|
||||
const deferred = await this.dataSource
|
||||
.getRepository(BookingContainerUnit)
|
||||
.find({
|
||||
where: lineIds.map((bookingContainerId) => ({
|
||||
bookingContainerId,
|
||||
deletedAt: Not(IsNull()),
|
||||
})),
|
||||
withDeleted: true,
|
||||
order: { sortOrder: 'ASC', createdAt: 'ASC' },
|
||||
take: need,
|
||||
});
|
||||
|
||||
return deferred.map((u) => ({
|
||||
containerNumber: u.containerNumber,
|
||||
sealNumber: u.sealNumber ?? undefined,
|
||||
vgmTons: Number(u.vgmTons),
|
||||
isHazardous: u.isHazardous,
|
||||
isReefer: u.isReefer,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,8 @@ export class TrainSchedulingController {
|
||||
@Get("batch-board/:scheduleId")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary: "Batch board detail for one schedule with EAT 3h windows",
|
||||
summary:
|
||||
"Batch board detail for one schedule: its booking window, in-window bookings and pending-contract bucket",
|
||||
})
|
||||
getBatchBoardDetail(@Param("scheduleId", ParseUUIDPipe) scheduleId: string) {
|
||||
return this.bookingBatchService.getBatchBoardDetail(scheduleId);
|
||||
|
||||
@@ -34,6 +34,7 @@ import { IntercityService } from './intercity.service';
|
||||
import { WsAuthService } from '../notification-inbox/ws-auth.service';
|
||||
import { BookingJourneyService } from './booking-journey.service';
|
||||
import { BookingSplitService } from './booking-split.service';
|
||||
import { RemainderPlacementService } from './remainder-placement.service';
|
||||
import { BookingBatchOffer } from './entities/booking-batch-offer.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
@@ -82,6 +83,7 @@ import { ContractsModule } from '../contracts/contracts.module';
|
||||
WsAuthService,
|
||||
BookingWindowService,
|
||||
BookingSplitService,
|
||||
RemainderPlacementService,
|
||||
IntercityService,
|
||||
BookingJourneyService,
|
||||
FacilityHandlingService,
|
||||
|
||||
@@ -965,4 +965,120 @@ describe('TrainSchedulingService', () => {
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('marshalling documents', () => {
|
||||
// Staff check these against the physical consist, so every wagon on the
|
||||
// train set has to appear — an empty wagon that renders no row reads as a
|
||||
// wagon that is not on the train.
|
||||
const makeWagon = (sequenceNo: number, wagonNumber: string, allocations: unknown[]) => ({
|
||||
sequenceNo,
|
||||
wagonNumber,
|
||||
physicalWagon: { wagonNumber },
|
||||
wagonType: { code: 'NW5', name: 'Flat Wagon', tareWeightTons: 22 },
|
||||
lengthMeters: 14,
|
||||
capacityTons: 70,
|
||||
allocations,
|
||||
});
|
||||
|
||||
const loadedAllocation = {
|
||||
bookingId: 'booking-1',
|
||||
bookingReference: 'BK-2026-000001',
|
||||
loadType: 'CONTAINER',
|
||||
allocatedWeightTons: 24.5,
|
||||
containerNumbers: ['CONT-001'],
|
||||
booking: { id: 'booking-1', reference: 'BK-2026-000001', companyId: 'company-1' },
|
||||
containerItems: [{ containerNumber: 'CONT-001', sealNumber: 'SEAL-1', chassisNumber: 'CH-1' }],
|
||||
};
|
||||
|
||||
const countRows = (html: string) => (html.match(/<tr(?: class="empty")?>\s*<td/g) ?? []).length;
|
||||
|
||||
it('lists an empty wagon on the export document and marks it EMPTY', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [
|
||||
makeWagon(1, 'W-001', [loadedAllocation]),
|
||||
makeWagon(2, 'W-002', []),
|
||||
makeWagon(3, 'W-003', []),
|
||||
],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(countRows(html)).toBe(3);
|
||||
expect(html).toContain('W-002');
|
||||
expect(html).toContain('W-003');
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(2);
|
||||
// The wagon count must agree with the rows the reader can see.
|
||||
expect(html).toContain('3 (2 empty)');
|
||||
});
|
||||
|
||||
it('lists an empty wagon on the import document and marks it EMPTY', () => {
|
||||
const loadList = {
|
||||
generatedAt: '2026-07-17T08:00:00.000Z',
|
||||
trainScheduleId: 'schedule-1',
|
||||
trainNumber: '8002',
|
||||
route: 'Djibouti → Indode',
|
||||
origin: 'Djibouti Port',
|
||||
destination: 'Indode',
|
||||
totalBookings: 1,
|
||||
wagons: [
|
||||
{ sequenceNo: 1, wagonNumber: 'W-001', allocations: [loadedAllocation] },
|
||||
{ sequenceNo: 2, wagonNumber: 'W-002', allocations: [] },
|
||||
],
|
||||
operation: { status: {} },
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildImportLoadListHtml: (l: unknown) => string;
|
||||
}).buildImportLoadListHtml(loadList);
|
||||
|
||||
expect(countRows(html)).toBe(2);
|
||||
expect(html).toContain('W-002');
|
||||
expect(html.match(/EMPTY — no cargo allocated/g)).toHaveLength(1);
|
||||
expect(html).toContain('2 (1 empty)');
|
||||
});
|
||||
|
||||
it('renders wagons in consist order regardless of the order the relation returns', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: {
|
||||
wagons: [makeWagon(3, 'W-003', []), makeWagon(1, 'W-001', []), makeWagon(2, 'W-002', [])],
|
||||
},
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(html.indexOf('W-001')).toBeLessThan(html.indexOf('W-002'));
|
||||
expect(html.indexOf('W-002')).toBeLessThan(html.indexOf('W-003'));
|
||||
});
|
||||
|
||||
it('omits the empty-count suffix when every wagon is loaded', () => {
|
||||
const schedule = {
|
||||
id: 'schedule-1',
|
||||
trainNumber: '8302',
|
||||
direction: 'EXPORT',
|
||||
trainSet: { wagons: [makeWagon(1, 'W-001', [loadedAllocation])] },
|
||||
scheduleBookings: [],
|
||||
};
|
||||
|
||||
const html = (service as never as {
|
||||
buildExportLoadListHtml: (s: unknown) => string;
|
||||
}).buildExportLoadListHtml(schedule);
|
||||
|
||||
expect(html).not.toContain('empty)');
|
||||
expect(html).not.toContain('EMPTY');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -267,6 +267,8 @@ export interface CompositionUnassignedBookingRow {
|
||||
freightType: string | null;
|
||||
priorityScore: number;
|
||||
cargoTotalWeightVgm: number;
|
||||
/** GROSS: cargo VGM + tare of every wagon the booking occupies. */
|
||||
grossWeightTons: number;
|
||||
status: string | null;
|
||||
schedulingStatus: string | null;
|
||||
wagonsRequired: number;
|
||||
@@ -477,6 +479,38 @@ export class TrainSchedulingService {
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* A built train makes at most ONE departure per route per EAT day. Returns
|
||||
* the non-cancelled schedule already holding this train on this route for
|
||||
* `departure`'s EAT day, or null when the day is free. Route+day GROUPS stay
|
||||
* legal — siblings must be different trains.
|
||||
*/
|
||||
private async findTrainRouteDayConflict(
|
||||
trainId: string,
|
||||
routeId: string,
|
||||
departure: Date,
|
||||
excludeScheduleId?: string,
|
||||
): Promise<TrainSchedule | null> {
|
||||
const day = eatDay(departure);
|
||||
const dayStart = eatDayToUtc(day, 0);
|
||||
const nextDayStart = eatDayToUtc(shiftEatDay(day, 1), 0);
|
||||
const qb = this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.createQueryBuilder('s')
|
||||
.innerJoin('s.trainSet', 'ts')
|
||||
.where('ts.trainId = :trainId', { trainId })
|
||||
.andWhere('s.routeId = :routeId', { routeId })
|
||||
.andWhere('s.scheduledDepartureDate >= :dayStart', { dayStart })
|
||||
.andWhere('s.scheduledDepartureDate < :nextDayStart', { nextDayStart })
|
||||
.andWhere('s.status != :cancelledStatus', {
|
||||
cancelledStatus: TrainScheduleStatusEnum.Cancelled,
|
||||
});
|
||||
if (excludeScheduleId) {
|
||||
qb.andWhere('s.id != :excludeScheduleId', { excludeScheduleId });
|
||||
}
|
||||
return qb.getOne();
|
||||
}
|
||||
|
||||
/**
|
||||
* The window timeline a brand-new schedule must adopt to join its route+day
|
||||
* group. Returns the canonical open/close times + rule snapshot copied from an
|
||||
@@ -880,6 +914,28 @@ export class TrainSchedulingService {
|
||||
);
|
||||
}
|
||||
|
||||
// Moving onto a day where this same built train already runs this route
|
||||
// would double-book the physical train — blocked for planning moves.
|
||||
if (schedule.trainSetId && schedule.routeId) {
|
||||
const trainSet = await this.dataSource
|
||||
.getRepository(TrainSet)
|
||||
.findOne({ where: { id: schedule.trainSetId } });
|
||||
if (trainSet?.trainId) {
|
||||
const conflict = await this.findTrainRouteDayConflict(
|
||||
trainSet.trainId,
|
||||
schedule.routeId,
|
||||
departure,
|
||||
id,
|
||||
);
|
||||
if (conflict) {
|
||||
throw new ConflictException(
|
||||
`This train is already scheduled on this route for that day ` +
|
||||
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-derive the window from the schedule's own rule snapshot (falling back to
|
||||
// the live config where a legacy row has no snapshot) against the new date.
|
||||
const merged = effectiveWindowConfig(schedule, windowCfg);
|
||||
@@ -913,10 +969,37 @@ export class TrainSchedulingService {
|
||||
scheduledDepartureDate: departure,
|
||||
...windowFields,
|
||||
});
|
||||
|
||||
// Only customers whose bookings already HOLD wagons on this train are told
|
||||
// about the move (SMS + email + portal inbox). Linked-but-unallocated
|
||||
// bookings are skipped — nothing of theirs is riding this departure yet.
|
||||
let notifiedCount = 0;
|
||||
if (schedule.trainSetId) {
|
||||
const allocations = await this.dataSource
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('a')
|
||||
.innerJoin('a.trainSetWagon', 'slot')
|
||||
.where('slot.trainSetId = :trainSetId', { trainSetId: schedule.trainSetId })
|
||||
.getMany();
|
||||
const allocatedBookingIds = [...new Set(allocations.map((a) => a.bookingId))];
|
||||
if (allocatedBookingIds.length) {
|
||||
const allocatedBookings = await this.dataSource.getRepository(Booking).find({
|
||||
where: { id: In(allocatedBookingIds) },
|
||||
relations: { company: true },
|
||||
});
|
||||
for (const booking of allocatedBookings) {
|
||||
if (['CANCELLED', 'EXPIRED', 'REJECTED'].includes(booking.status)) continue;
|
||||
this.bookingNotifier.rescheduled(booking, departure);
|
||||
notifiedCount += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Departure date changed for schedule ${id} → ${departure.toISOString()} ` +
|
||||
`(window reopens ${windowFields.windowOpensAt?.toISOString() ?? 'n/a'}` +
|
||||
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''})`,
|
||||
`${anchor ? `, joined route+day group anchor ${anchor.id}` : ''}); ` +
|
||||
`${notifiedCount} allocated customer booking(s) notified`,
|
||||
);
|
||||
void this.emitWindowState(id);
|
||||
|
||||
@@ -1200,7 +1283,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
if (
|
||||
builtTrain.status === Freight.TrainStatus.OutOfService ||
|
||||
builtTrain.status === Freight.TrainStatus.UnderMaintenance
|
||||
builtTrain.status === Freight.TrainStatus.UnderMaintenance ||
|
||||
builtTrain.status === Freight.TrainStatus.Deactivated
|
||||
) {
|
||||
throw new ConflictException(
|
||||
`Train ${builtTrain.code} is ${builtTrain.status.toLowerCase().replace(/_/g, ' ')}`,
|
||||
@@ -1220,6 +1304,17 @@ export class TrainSchedulingService {
|
||||
`Train ${builtTrain.code} is not at the origin yard yet; it must arrive before this departure dispatches`,
|
||||
);
|
||||
}
|
||||
const conflict = await this.findTrainRouteDayConflict(
|
||||
builtTrain.id,
|
||||
route.id,
|
||||
new Date(dto.scheduleDate),
|
||||
);
|
||||
if (conflict) {
|
||||
throw new ConflictException(
|
||||
`Train ${builtTrain.code} is already scheduled on this route for that day ` +
|
||||
`(${conflict.reference ?? conflict.id}) — one departure per route per day`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
locomotiveIds = [...new Set(dto.locomotiveIds ?? [])];
|
||||
if (locomotiveIds.length < 2) {
|
||||
@@ -1679,6 +1774,7 @@ export class TrainSchedulingService {
|
||||
scheduleId,
|
||||
schedule.originStationId,
|
||||
savedWagons,
|
||||
schedule.reverseWagonOrder ?? false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2590,19 +2686,24 @@ export class TrainSchedulingService {
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
// Every wagon on the train set, loaded or not, in consist order. An empty
|
||||
// wagon has an empty `allocations` array — it is still part of the train
|
||||
// and still belongs on the marshalling document.
|
||||
wagons: [...(schedule.trainSet?.wagons ?? [])]
|
||||
.sort((a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0))
|
||||
.map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
})),
|
||||
})),
|
||||
})),
|
||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||
};
|
||||
}
|
||||
@@ -2652,9 +2753,33 @@ export class TrainSchedulingService {
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
const rows = (schedule.trainSet?.wagons ?? [])
|
||||
.flatMap((wagon) =>
|
||||
(wagon.allocations ?? []).map((allocation) => {
|
||||
// The document is checked against the physical train, so it has to run in
|
||||
// consist order — the relation comes back unordered.
|
||||
const wagons = [...(schedule.trainSet?.wagons ?? [])].sort(
|
||||
(a, b) => Number(a.sequenceNo ?? 0) - Number(b.sequenceNo ?? 0),
|
||||
);
|
||||
const rows = wagons
|
||||
.flatMap((wagon) => {
|
||||
// Wagon identity is the same on every row the wagon produces, loaded or not.
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>`;
|
||||
const allocations = wagon.allocations ?? [];
|
||||
// An empty wagon still runs in the consist, so it still gets a line. Staff
|
||||
// check this document against the physical train — a wagon with no row
|
||||
// reads as a wagon that is not there, and the count stops matching.
|
||||
if (allocations.length === 0) {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="6">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return allocations.map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
@@ -2664,12 +2789,7 @@ export class TrainSchedulingService {
|
||||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.wagonType?.tareWeightTons ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||
${wagonCells}
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
@@ -2677,10 +2797,11 @@ export class TrainSchedulingService {
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
<td>${esc(sealNumbers)}</td>
|
||||
</tr>`;
|
||||
}),
|
||||
)
|
||||
});
|
||||
})
|
||||
.join('');
|
||||
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
||||
const emptyWagons = wagons.filter((wagon) => (wagon.allocations ?? []).length === 0).length;
|
||||
const totalWeight = wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
@@ -2708,6 +2829,8 @@ export class TrainSchedulingService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tr.empty td { background: #f8fafc; color: #64748b; }
|
||||
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
@@ -2735,7 +2858,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||
@@ -2759,7 +2882,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
||||
${rows || '<tr><td colspan="12">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -2802,19 +2925,31 @@ export class TrainSchedulingService {
|
||||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
const emptyWagons = loadList.wagons.filter((wagon) => wagon.allocations.length === 0).length;
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) =>
|
||||
wagon.allocations.map(
|
||||
.flatMap((wagon) => {
|
||||
const wagonCells = `<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>`;
|
||||
// An empty wagon still runs in the consist, so it still gets a line — see
|
||||
// buildExportLoadListHtml.
|
||||
if (wagon.allocations.length === 0) {
|
||||
return [
|
||||
`<tr class="empty">
|
||||
${wagonCells}
|
||||
<td colspan="4">EMPTY — no cargo allocated</td>
|
||||
</tr>`,
|
||||
];
|
||||
}
|
||||
return wagon.allocations.map(
|
||||
(allocation) => `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>
|
||||
${wagonCells}
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`,
|
||||
),
|
||||
)
|
||||
);
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
@@ -2846,6 +2981,8 @@ export class TrainSchedulingService {
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
tr.empty td { background: #f8fafc; color: #64748b; }
|
||||
tr.empty td[colspan] { font-weight: 700; letter-spacing: .04em; }
|
||||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||||
@@ -2872,7 +3009,7 @@ export class TrainSchedulingService {
|
||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}${emptyWagons ? ` (${emptyWagons} empty)` : ''}</strong></div>
|
||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
@@ -2900,7 +3037,7 @@ export class TrainSchedulingService {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
|
||||
${allocationRows || '<tr><td colspan="6">No wagons on this train set.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -3866,11 +4003,18 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
const totalWeightTons = totalAssignedWeight(fittingBookings);
|
||||
// Every weight limit below (global max, loco pull) is a GROSS axis, so the
|
||||
// figure spent against it must be gross too — cargo alone under-reports the
|
||||
// train by the full consist tare and disagrees with the assign path.
|
||||
const totalTareTons = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + (Number(w.tareWeightTons) || 0), 0),
|
||||
);
|
||||
const grossWeightTons = roundTons(totalWeightTons + totalTareTons);
|
||||
const totalLengthMeters = roundTons(
|
||||
wagonPlan.reduce((sum, w) => sum + w.lengthMeters, 0),
|
||||
);
|
||||
if (totalWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total booking weight ${totalWeightTons}T exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (grossWeightTons > trainLimits.maxWeightTons) {
|
||||
const message = `Total gross weight ${grossWeightTons}T (${totalWeightTons}T cargo + ${totalTareTons}T wagon tare) exceeds max train weight ${trainLimits.maxWeightTons}T`;
|
||||
if (!violations.includes(message) && !warnings.includes(message)) {
|
||||
pushLimit([message]);
|
||||
}
|
||||
@@ -3897,7 +4041,7 @@ export class TrainSchedulingService {
|
||||
if (
|
||||
setLimits &&
|
||||
(setLimits.maxPullWeightTons + (Number(setLimits.overageToleranceTons) || 0) <
|
||||
totalWeightTons ||
|
||||
grossWeightTons ||
|
||||
setLimits.maxTrainLengthMeters + (Number(setLimits.overageToleranceMeters) || 0) <
|
||||
totalLengthMeters)
|
||||
) {
|
||||
@@ -3918,7 +4062,7 @@ export class TrainSchedulingService {
|
||||
!inServiceLocomotives.some(
|
||||
(l) =>
|
||||
Number(l.maxPullWeightTons) + (Number(l.overageToleranceTons) || 0) >=
|
||||
totalWeightTons &&
|
||||
grossWeightTons &&
|
||||
Number(l.maxTrainLengthMeters) + (Number(l.overageToleranceMeters) || 0) >=
|
||||
totalLengthMeters,
|
||||
)
|
||||
@@ -3940,6 +4084,9 @@ export class TrainSchedulingService {
|
||||
summary: {
|
||||
totalBookings: fittingBookings.length,
|
||||
totalWeightTons,
|
||||
/** GROSS: cargo + the tare of every wagon in the plan. */
|
||||
grossWeightTons,
|
||||
totalTareTons,
|
||||
// Human-readable wagon type(s) of the plan — mixed consists list all.
|
||||
wagonType: plannedTypeCodes.join('/') || 'NONE',
|
||||
wagonsNeeded: wagonPlan.length,
|
||||
@@ -4271,6 +4418,7 @@ export class TrainSchedulingService {
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
slots: TrainSetWagon[],
|
||||
reverseWagonOrder = false,
|
||||
) {
|
||||
const wagons = await manager.getRepository(Wagon).find();
|
||||
const wagonTypes = await manager.getRepository(WagonType).find();
|
||||
@@ -4316,6 +4464,7 @@ export class TrainSchedulingService {
|
||||
assignedPhysicalIds,
|
||||
builtTrainId,
|
||||
pinnedToScheduleIds,
|
||||
reverseWagonOrder,
|
||||
);
|
||||
if (!physical) continue;
|
||||
|
||||
@@ -4409,6 +4558,7 @@ export class TrainSchedulingService {
|
||||
assignedPhysicalIds: Set<string>,
|
||||
builtTrainId: string | null = null,
|
||||
pinnedToScheduleIds: Set<string> = new Set(),
|
||||
reverseWagonOrder = false,
|
||||
): Wagon | undefined {
|
||||
const usable = (wagon: Wagon): boolean => {
|
||||
if (wagon.wagonTypeId !== slot.wagonTypeId) return false;
|
||||
@@ -4429,12 +4579,26 @@ export class TrainSchedulingService {
|
||||
// wherever they currently sit (they travel with the train), never a loose
|
||||
// yard wagon.
|
||||
if (builtTrainId) {
|
||||
return wagons.find(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
!assignedPhysicalIds.has(w.id),
|
||||
);
|
||||
// Pin in the train's as-built coupling order (wagon.sequenceNumber) so the
|
||||
// consist views draw the schedule exactly like the train builder; a schedule
|
||||
// created with reverseWagonOrder pins back-to-front (physically-last wagon
|
||||
// takes slot #1). Unsequenced wagons sort after every sequenced one.
|
||||
const candidates = wagons
|
||||
.filter(
|
||||
(w) =>
|
||||
w.trainId === builtTrainId &&
|
||||
w.wagonTypeId === slot.wagonTypeId &&
|
||||
!assignedPhysicalIds.has(w.id),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.sequenceNumber == null || b.sequenceNumber == null) {
|
||||
return (a.sequenceNumber == null ? 1 : 0) - (b.sequenceNumber == null ? 1 : 0);
|
||||
}
|
||||
return reverseWagonOrder
|
||||
? b.sequenceNumber - a.sequenceNumber
|
||||
: a.sequenceNumber - b.sequenceNumber;
|
||||
});
|
||||
return candidates[0];
|
||||
}
|
||||
// Prefer a wagon already waiting at the slot's board yard (no empty haul);
|
||||
// fall back to one riding from the train's origin.
|
||||
@@ -5097,7 +5261,11 @@ export class TrainSchedulingService {
|
||||
const trains = await this.dataSource.getRepository(Train).find({
|
||||
where: {
|
||||
status: Not(
|
||||
In([Freight.TrainStatus.OutOfService, Freight.TrainStatus.UnderMaintenance]),
|
||||
In([
|
||||
Freight.TrainStatus.OutOfService,
|
||||
Freight.TrainStatus.UnderMaintenance,
|
||||
Freight.TrainStatus.Deactivated,
|
||||
]),
|
||||
),
|
||||
},
|
||||
relations: {
|
||||
@@ -5497,8 +5665,8 @@ export class TrainSchedulingService {
|
||||
* Re-derive a built train's lifecycle status from its schedules after one of
|
||||
* them changes: any DISPATCHED schedule → IN_SERVICE; any DRAFT/SCHEDULED →
|
||||
* SCHEDULED; otherwise AVAILABLE. `moveToYardId` relocates the train (arrival
|
||||
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE)
|
||||
* keep their status — staff own that flag, not the scheduler.
|
||||
* at destination). Manually parked trains (UNDER_MAINTENANCE / OUT_OF_SERVICE
|
||||
* / DEACTIVATED) keep their status — staff own that flag, not the scheduler.
|
||||
*/
|
||||
private async syncBuiltTrainAfterScheduleChange(
|
||||
manager: EntityManager,
|
||||
@@ -6208,11 +6376,21 @@ export class TrainSchedulingService {
|
||||
* engine's representative fallbacks for bookings whose cargo/container type
|
||||
* has no wagon type configured. Loaded once per request before mapping.
|
||||
*/
|
||||
/** Wagon types are near-static reference data — a short TTL cache spares one
|
||||
* table scan per detail/board request without letting edits go stale long. */
|
||||
private wagonTareDimsCache: {
|
||||
value: Awaited<ReturnType<TrainSchedulingService['loadWagonTareDims']>>;
|
||||
expiresAt: number;
|
||||
} | null = null;
|
||||
|
||||
private async loadWagonTareDims(): Promise<{
|
||||
byWagonTypeId: Map<string, { tareWeightTons: number; capacityTons: number }>;
|
||||
bulk: { tareWeightTons: number; capacityTons: number };
|
||||
container: { tareWeightTons: number; capacityTons: number };
|
||||
}> {
|
||||
if (this.wagonTareDimsCache && this.wagonTareDimsCache.expiresAt > Date.now()) {
|
||||
return this.wagonTareDimsCache.value;
|
||||
}
|
||||
const types = await this.dataSource.getRepository(WagonType).find();
|
||||
const byWagonTypeId = new Map(
|
||||
types.map((t) => [
|
||||
@@ -6223,7 +6401,7 @@ export class TrainSchedulingService {
|
||||
},
|
||||
]),
|
||||
);
|
||||
return {
|
||||
const value = {
|
||||
byWagonTypeId,
|
||||
bulk: {
|
||||
tareWeightTons: DEFAULT_BULK_WAGON_TARE_TONS,
|
||||
@@ -6234,6 +6412,8 @@ export class TrainSchedulingService {
|
||||
capacityTons: DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
|
||||
},
|
||||
};
|
||||
this.wagonTareDimsCache = { value, expiresAt: Date.now() + 60_000 };
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6290,49 +6470,14 @@ export class TrainSchedulingService {
|
||||
);
|
||||
const allocationIds = allocations.map((a) => a.id);
|
||||
const allocatedBookingIds = new Set(allocations.map((a) => a.bookingId));
|
||||
// Booking weights are reported GROSS (cargo + wagon tare) — the number the
|
||||
// locomotive actually hauls and the axis its pull limit is compared against.
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
|
||||
// Import-from-Djibouti trains can only dispatch once loading is confirmed
|
||||
// (loadedOnTrainAt on the operation). Other directions have no departure
|
||||
// loading gate, so the workspace shows the confirm button as already done.
|
||||
const requiresLoadingConfirmation = this.isImportDjiboutiSchedule(schedule);
|
||||
let loadingConfirmed = !requiresLoadingConfirmation;
|
||||
if (requiresLoadingConfirmation) {
|
||||
const op = await this.dataSource
|
||||
.getRepository(ImportDjiboutiOperation)
|
||||
.findOne({ where: { trainScheduleId: schedule.id } });
|
||||
loadingConfirmed = Boolean(op?.loadedOnTrainAt);
|
||||
}
|
||||
|
||||
const windowCfg = await this.getWindowConfig();
|
||||
|
||||
const [containerItems, bulkLoads] = await Promise.all([
|
||||
allocationIds.length
|
||||
? this.wagonAllocationContainerItemsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { containerType: true, bookingContainer: true },
|
||||
})
|
||||
: [],
|
||||
allocationIds.length
|
||||
? this.wagonAllocationBulkLoadsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { cargoType: true },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
|
||||
const containerItemsByAllocation = new Map<string, typeof containerItems>();
|
||||
for (const item of containerItems) {
|
||||
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
|
||||
list.push(item);
|
||||
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
|
||||
}
|
||||
const bulkLoadsByAllocation = new Map(
|
||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||||
);
|
||||
|
||||
// Snapshot state decides below whether the live consist may be drawn at
|
||||
// all, so it is derived before the consist wagons are fetched.
|
||||
// Once a schedule leaves DRAFT/SCHEDULED, its physical wagons are released
|
||||
// and re-pinned onto later trains — the live wagon↔slot joins no longer
|
||||
// describe THIS train. If a frozen snapshot was captured at the transition,
|
||||
@@ -6347,6 +6492,55 @@ export class TrainSchedulingService {
|
||||
(snapshot?.slots ?? []).map((slot) => [slot.trainSetWagonId, slot]),
|
||||
);
|
||||
|
||||
// All independent lookups fired at once — they used to run one after
|
||||
// another, stacking round-trips onto every detail request.
|
||||
// tareDims: booking weights are reported GROSS (cargo + wagon tare) — the
|
||||
// number the locomotive actually hauls against its pull limit.
|
||||
const [tareDims, importOp, windowCfg, containerItems, bulkLoads, rawConsistWagons] =
|
||||
await Promise.all([
|
||||
this.loadWagonTareDims(),
|
||||
requiresLoadingConfirmation
|
||||
? this.dataSource
|
||||
.getRepository(ImportDjiboutiOperation)
|
||||
.findOne({ where: { trainScheduleId: schedule.id } })
|
||||
: null,
|
||||
this.getWindowConfig(),
|
||||
allocationIds.length
|
||||
? this.wagonAllocationContainerItemsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { containerType: true, bookingContainer: true },
|
||||
})
|
||||
: [],
|
||||
allocationIds.length
|
||||
? this.wagonAllocationBulkLoadsRepository.findAll({
|
||||
where: { wagonBookingAllocationId: In(allocationIds) },
|
||||
relations: { cargoType: true },
|
||||
})
|
||||
: [],
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
// Mirror the pinning direction: a reverse-order schedule draws the
|
||||
// whole consist back-to-front, empties included.
|
||||
order: { sequenceNumber: schedule.reverseWagonOrder ? 'DESC' : 'ASC' },
|
||||
})
|
||||
: [],
|
||||
]);
|
||||
const loadingConfirmed = requiresLoadingConfirmation
|
||||
? Boolean(importOp?.loadedOnTrainAt)
|
||||
: true;
|
||||
|
||||
const containerItemsByAllocation = new Map<string, typeof containerItems>();
|
||||
for (const item of containerItems) {
|
||||
const list = containerItemsByAllocation.get(item.wagonBookingAllocationId) ?? [];
|
||||
list.push(item);
|
||||
containerItemsByAllocation.set(item.wagonBookingAllocationId, list);
|
||||
}
|
||||
const bulkLoadsByAllocation = new Map(
|
||||
bulkLoads.map((load) => [load.wagonBookingAllocationId, load]),
|
||||
);
|
||||
|
||||
// The trainSet slots below are the PLANNED wagons (one per allocation). A
|
||||
// schedule tied to a built train hauls EVERY coupled wagon — empty ones
|
||||
// included (the pull-limit check already counts their tare) — so append the
|
||||
@@ -6369,41 +6563,32 @@ export class TrainSchedulingService {
|
||||
0,
|
||||
...(schedule.trainSet?.wagons ?? []).map((w) => w.sequenceNo),
|
||||
);
|
||||
const emptyConsistWagons =
|
||||
schedule.trainSet?.trainId && !isWagonAllocationFrozen
|
||||
? (
|
||||
await this.dataSource.getRepository(Wagon).find({
|
||||
where: { trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
order: { sequenceNumber: 'ASC' },
|
||||
})
|
||||
)
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}))
|
||||
: [];
|
||||
const emptyConsistWagons = rawConsistWagons
|
||||
.filter((wagon) => !coveredPhysicalIds.has(wagon.id))
|
||||
.map((wagon, index) => ({
|
||||
// Physical wagon id — there is no TrainSetWagon slot behind this
|
||||
// row, so remove/edit affordances must stay disabled (consistOnly).
|
||||
id: wagon.id,
|
||||
sequenceNo: maxSlotSequenceNo + index + 1,
|
||||
capacityTons: roundTons(Number(wagon.wagonType?.capacityTons ?? 0)),
|
||||
lengthMeters: roundTons(Number(wagon.wagonType?.lengthMeters ?? 0)),
|
||||
assignedWeightTons: 0,
|
||||
tareWeightTons: wagon.wagonType
|
||||
? roundTons(Number(wagon.wagonType.tareWeightTons))
|
||||
: null,
|
||||
status: 'EMPTY',
|
||||
physicalWagonId: wagon.id,
|
||||
physicalWagonNumber: wagon.wagonNumber ?? null,
|
||||
wagonType: wagon.wagonType
|
||||
? {
|
||||
id: wagon.wagonType.id,
|
||||
code: wagon.wagonType.code,
|
||||
name: wagon.wagonType.name,
|
||||
}
|
||||
: null,
|
||||
allocations: [],
|
||||
consistOnly: true,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: schedule.id,
|
||||
@@ -6413,6 +6598,7 @@ export class TrainSchedulingService {
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
maxWagons: schedule.maxWagons ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
reverseWagonOrder: schedule.reverseWagonOrder ?? false,
|
||||
requiresLoadingConfirmation,
|
||||
loadingConfirmed,
|
||||
// Booking-window phase + phase deadlines drive the countdown timers in the
|
||||
@@ -6713,8 +6899,13 @@ export class TrainSchedulingService {
|
||||
/** Preview wagon allocation issues per linked booking without mutating the schedule. */
|
||||
async previewAllocationForSchedule(
|
||||
scheduleId: string,
|
||||
// Callers that already hold the full schedule graph (batch board detail)
|
||||
// pass it in so the preview doesn't re-load the same heavy graph.
|
||||
preloadedSchedule?: TrainSchedule,
|
||||
): Promise<WagonAllocationAttemptResult> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
const schedule =
|
||||
preloadedSchedule ??
|
||||
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
@@ -6761,7 +6952,10 @@ export class TrainSchedulingService {
|
||||
);
|
||||
if (!eligible.length) return empty;
|
||||
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(schedule.id);
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(
|
||||
schedule.id,
|
||||
schedule,
|
||||
);
|
||||
const previewDto = {
|
||||
bookingIds: eligible.map((b) => b.id),
|
||||
scheduleDate: schedule.scheduledDepartureDate.toISOString(),
|
||||
@@ -7036,6 +7230,15 @@ export class TrainSchedulingService {
|
||||
shortfall: 0,
|
||||
}));
|
||||
|
||||
// Gross weight needs the scheduling graph (containers, cargo type, wagon
|
||||
// types) that the trimmed select above deliberately skips.
|
||||
const tareDims = await this.loadWagonTareDims();
|
||||
const fullById = new Map(
|
||||
(await this.bookingsRepository.findByIdsForScheduling(unassigned.map((b) => b.id))).map(
|
||||
(b) => [b.id, b],
|
||||
),
|
||||
);
|
||||
|
||||
const bookings = await Promise.all(
|
||||
unassigned.map(async (b) => {
|
||||
const assignability = await this.previewUnassignedBookingAssignability(
|
||||
@@ -7050,6 +7253,11 @@ export class TrainSchedulingService {
|
||||
freightType: b.freightType ?? null,
|
||||
priorityScore: b.priorityScore ?? 0,
|
||||
cargoTotalWeightVgm: Number(b.cargoTotalWeightVgm ?? 0),
|
||||
// GROSS: cargo + tare of the wagons the booking occupies.
|
||||
grossWeightTons: this.grossBookingWeightTons(
|
||||
(fullById.get(b.id) ?? b) as Booking,
|
||||
tareDims,
|
||||
),
|
||||
status: b.status ?? null,
|
||||
schedulingStatus: b.schedulingStatus ?? null,
|
||||
...assignability,
|
||||
@@ -7278,8 +7486,15 @@ export class TrainSchedulingService {
|
||||
return this.trainCompositionRemovalLogRepository.findByScheduleId(scheduleId);
|
||||
}
|
||||
|
||||
private async getWagonAssignedBookingIds(scheduleId: string): Promise<Set<string>> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
private async getWagonAssignedBookingIds(
|
||||
scheduleId: string,
|
||||
// Pass when the caller already holds the schedule with trainSet.wagons —
|
||||
// only wagon ids are read here, the old full-graph reload was pure waste.
|
||||
preloadedSchedule?: TrainSchedule,
|
||||
): Promise<Set<string>> {
|
||||
const schedule =
|
||||
preloadedSchedule ??
|
||||
(await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId));
|
||||
const wagonIds = (schedule?.trainSet?.wagons ?? []).map((w) => w.id);
|
||||
if (!wagonIds.length) return new Set();
|
||||
|
||||
|
||||
@@ -44,6 +44,15 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.listBuilt(query);
|
||||
}
|
||||
|
||||
// Must be declared before @Get(':id') so the path isn't captured as an id.
|
||||
@Get('used-train-numbers')
|
||||
@ApiOperation({
|
||||
summary: 'Import/export run numbers already claimed by existing trains',
|
||||
})
|
||||
usedTrainNumbers() {
|
||||
return this.trainBuilderService.usedTrainNumbers();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Full train composition: locomotives, ordered wagons, totals vs. limits' })
|
||||
composition(@Param('id', ParseUUIDPipe) id: string) {
|
||||
@@ -115,6 +124,22 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.reorderWagons(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/deactivate')
|
||||
@FleetManage()
|
||||
@ApiOperation({
|
||||
summary: 'Deactivate the train (park it) — only allowed with no active schedule',
|
||||
})
|
||||
deactivate(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.deactivate(id);
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@FleetManage()
|
||||
@ApiOperation({ summary: 'Reactivate a deactivated train back to AVAILABLE' })
|
||||
activate(@Param('id', ParseUUIDPipe) id: string) {
|
||||
return this.trainBuilderService.activate(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@FleetManage()
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
|
||||
@@ -153,6 +153,38 @@ export class TrainBuilderService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run numbers already claimed by live (non-deleted) trains, split by
|
||||
* direction. Legacy single `train_number` values are sorted into a side by
|
||||
* parity (even = import, odd = export) so the pickers can grey them out too.
|
||||
*/
|
||||
async usedTrainNumbers() {
|
||||
const rows: {
|
||||
import_train_number: string | null;
|
||||
export_train_number: string | null;
|
||||
train_number: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
`SELECT import_train_number, export_train_number, train_number
|
||||
FROM freight.trains
|
||||
WHERE deleted_at IS NULL`,
|
||||
);
|
||||
|
||||
const importTrainNumbers = new Set<string>();
|
||||
const exportTrainNumbers = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.import_train_number) importTrainNumbers.add(row.import_train_number);
|
||||
if (row.export_train_number) exportTrainNumbers.add(row.export_train_number);
|
||||
const legacy = row.train_number?.trim();
|
||||
if (legacy && /^\d+$/.test(legacy)) {
|
||||
(Number(legacy) % 2 === 0 ? importTrainNumbers : exportTrainNumbers).add(legacy);
|
||||
}
|
||||
}
|
||||
return {
|
||||
importTrainNumbers: [...importTrainNumbers].sort(),
|
||||
exportTrainNumbers: [...exportTrainNumbers].sort(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* One ACTIVE schedule per train for the page (prefer the DISPATCHED run,
|
||||
* else the earliest upcoming departure) — feeds the list's direction tint
|
||||
@@ -532,6 +564,50 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Park the train indefinitely (status DEACTIVATED). Blocked while it still
|
||||
* has a live (DRAFT/SCHEDULED/DISPATCHED) schedule. The consist stays
|
||||
* coupled; like UNDER_MAINTENANCE / OUT_OF_SERVICE the flag is staff-owned —
|
||||
* the scheduler never overwrites it and refuses the train for new schedules.
|
||||
*/
|
||||
async deactivate(id: string) {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const train = await manager.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.Deactivated) return;
|
||||
const active: { count: string }[] = await manager.query(
|
||||
`SELECT COUNT(*)::text AS count
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')`,
|
||||
[id],
|
||||
);
|
||||
if (Number(active[0]?.count ?? 0) > 0) {
|
||||
throw new ConflictException(
|
||||
'Train has active schedules; cancel them before deactivating the train',
|
||||
);
|
||||
}
|
||||
await manager
|
||||
.getRepository(Train)
|
||||
.update(id, { status: Freight.TrainStatus.Deactivated });
|
||||
});
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Reactivate a DEACTIVATED train back to AVAILABLE so it can be scheduled again. */
|
||||
async activate(id: string) {
|
||||
const train = await this.dataSource.getRepository(Train).findOne({ where: { id } });
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.Deactivated) {
|
||||
await this.dataSource
|
||||
.getRepository(Train)
|
||||
.update(id, { status: Freight.TrainStatus.Available });
|
||||
}
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Disband the train: release wagons and locomotives, then delete it. */
|
||||
async disband(id: string): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
|
||||
41
apps/edr-freight-api/src/modules/wagons/train-runs.const.ts
Normal file
41
apps/edr-freight-api/src/modules/wagons/train-runs.const.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* EDR run-number pairs, keyed by the odd EXPORT run (Ethiopia → Djibouti). The
|
||||
* even IMPORT run (Djibouti → Ethiopia) is fixed by the export run.
|
||||
*
|
||||
* Run numbers are always 4 digits (8401, never 84001). Pairs are listed out
|
||||
* rather than computed from the 8001/+100/+1 pattern, so a run that ever breaks
|
||||
* the convention stays correct here.
|
||||
*
|
||||
* SeedWagonRunNumbers2280000000000 carries its own frozen copy on purpose: a
|
||||
* migration must keep doing what it did when it was applied, whereas this list
|
||||
* is live config for the update script. Add or retire runs HERE.
|
||||
*/
|
||||
export const TRAIN_RUN_PAIRS: Record<string, string> = {
|
||||
'8001': '8002',
|
||||
'8101': '8102',
|
||||
'8201': '8202',
|
||||
'8301': '8302',
|
||||
'8401': '8402',
|
||||
'8501': '8502',
|
||||
'8601': '8602',
|
||||
'8701': '8702',
|
||||
'8801': '8802',
|
||||
'8901': '8902',
|
||||
'9001': '9002',
|
||||
};
|
||||
|
||||
/** Even IMPORT run -> its odd EXPORT run. Derived so the two cannot drift. */
|
||||
export const EXPORT_BY_IMPORT: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(TRAIN_RUN_PAIRS).map(([exportRun, importRun]) => [importRun, exportRun]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Normalise any run number to its EXPORT run. Accepts either half of a pair, so
|
||||
* a sheet listing "8002" and one listing "8001" both resolve to the same train.
|
||||
* Returns null when the number belongs to no known run.
|
||||
*/
|
||||
export const toExportRun = (run: string): string | null => {
|
||||
const value = run.trim();
|
||||
if (TRAIN_RUN_PAIRS[value]) return value;
|
||||
return EXPORT_BY_IMPORT[value] ?? null;
|
||||
};
|
||||
@@ -1926,7 +1926,8 @@ export class WarehouseInventoryService {
|
||||
|
||||
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
|
||||
const [schedule] = await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
`SELECT ts.id, ts.status, ts.destination_station_id AS "destinationStationId",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
@@ -1957,14 +1958,20 @@ export class WarehouseInventoryService {
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
// Only bookings whose destination IS this train's final yard unload into
|
||||
// this (final-destination) warehouse. A mid-corridor import that alighted
|
||||
// at an intermediate yard was already unloaded there by the checkpoint
|
||||
// auto-unload; without this filter it would be mis-located into the final
|
||||
// yard's inventory too.
|
||||
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
AND b.destination_yard_id = $2`,
|
||||
[scheduleId, schedule.destinationStationId],
|
||||
);
|
||||
|
||||
const requestedLocation = warehouseId ? await this.pickDefaultLocation(warehouseId) : null;
|
||||
|
||||
Reference in New Issue
Block a user