mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 07:22:53 +00:00
feat: full wagon cancel, leg board, wagon dates
feat(freight): editable train leg times, SL invoice payer
This commit is contained in:
@@ -252,7 +252,46 @@ export class BillingService {
|
||||
this.applyInvoiceFilters(qb, filter);
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
return { items, total };
|
||||
return { items: await this.attachShippingLineCompanies(items), total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch-hydrate `shippingLineCompany` for any invoice billed to a shipping
|
||||
* line (`companyId` null). No relation on `Invoice` to eager-load — see the
|
||||
* entity's doc comment — so this is a second query keyed off the ids
|
||||
* already loaded, same shape as `company`.
|
||||
*/
|
||||
private async attachShippingLineCompanies<T extends Invoice>(
|
||||
invoices: T[],
|
||||
): Promise<T[]> {
|
||||
const ids = [
|
||||
...new Set(
|
||||
invoices
|
||||
.map((i) => i.shippingLineCompanyId)
|
||||
.filter((id): id is string => id != null),
|
||||
),
|
||||
];
|
||||
if (!ids.length) return invoices;
|
||||
const lines = await this.dataSource
|
||||
.getRepository(ShippingLineCompany)
|
||||
.find({ where: { id: In(ids) } });
|
||||
const byId = new Map(lines.map((l) => [l.id, l]));
|
||||
return invoices.map((invoice) => {
|
||||
const line = invoice.shippingLineCompanyId
|
||||
? byId.get(invoice.shippingLineCompanyId)
|
||||
: undefined;
|
||||
return line
|
||||
? ({
|
||||
...invoice,
|
||||
shippingLineCompany: {
|
||||
id: line.id,
|
||||
name: line.name,
|
||||
email: line.email,
|
||||
phoneNumber: line.phoneNumber,
|
||||
},
|
||||
} as T)
|
||||
: invoice;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -432,11 +471,12 @@ export class BillingService {
|
||||
relations: { company: true, companyProfile: true },
|
||||
});
|
||||
if (!invoice) throw new NotFoundException(`Invoice ${id} not found`);
|
||||
const [hydrated] = await this.attachShippingLineCompanies([invoice]);
|
||||
const lines = await this.invoiceLines.findAll({
|
||||
where: { invoiceId: id },
|
||||
order: { createdAt: "ASC" },
|
||||
});
|
||||
return { ...invoice, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
return { ...hydrated, lines } as Invoice & { lines: InvoiceLine[] };
|
||||
}
|
||||
|
||||
// ── Documents (central PDF) ──────────────────────────────────────────────────
|
||||
|
||||
@@ -121,6 +121,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingsService,
|
||||
BookingsRepository,
|
||||
BookingPricingService,
|
||||
ContainerValidationService,
|
||||
BookingInvoiceService,
|
||||
BookingLifecycleNotifierService,
|
||||
BookingTransitionService,
|
||||
|
||||
@@ -67,9 +67,16 @@ export class ContainerValidationService {
|
||||
const has20ft = containerLines.some((bc) => (bc.containerSize ?? '').includes('20'));
|
||||
if (!has20ft) return [];
|
||||
|
||||
const units = await this.load20ftUnits(booking);
|
||||
if (units.length < 2) return [];
|
||||
return this.validate20ftPairingUnits(await this.load20ftUnits(booking));
|
||||
}
|
||||
|
||||
/**
|
||||
* Same rule over units that are not (yet) persisted — a completion payload
|
||||
* being previewed or submitted. Shipping-line completion uses this: its
|
||||
* cargo only hits the DB after the check passes.
|
||||
*/
|
||||
async validate20ftPairingUnits(units: Container20ftUnit[]): Promise<PairingViolation[]> {
|
||||
if (units.length < 2) return [];
|
||||
const maxDiff = await this.maxPairDiffTons();
|
||||
return validate20ftWeightPairing(units, maxDiff);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import { In, Repository } from "typeorm";
|
||||
import { BookingPricingService } from "../bookings/booking-pricing.service";
|
||||
import { BookingTransitionService } from "../bookings/booking-transition.service";
|
||||
import { BookingsService } from "../bookings/bookings.service";
|
||||
import type { Container20ftUnit } from "../bookings/container-pairing.util";
|
||||
import { ContainerValidationService } from "../bookings/container-validation.service";
|
||||
import { BookingContainer } from "../bookings/entities/booking-container.entity";
|
||||
import { BookingContainerUnit } from "../bookings/entities/booking-container-unit.entity";
|
||||
import { Booking } from "../bookings/entities/booking.entity";
|
||||
@@ -57,8 +59,35 @@ export class ShippingLineBookingCompletionService {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
private readonly bookingBatchService: BookingBatchService,
|
||||
private readonly creditsService: ShippingLineCreditsService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 20ft weight-pairing check over the completion payload — the same rule the
|
||||
* customer shipment form enforces (`max20ftPairWeightDiffTons`, default 10t):
|
||||
* two 20ft sharing a wagon must be within the cap. Preview surfaces the
|
||||
* messages; completion hard-blocks on them. Runs off the DTO so nothing is
|
||||
* persisted before the check passes.
|
||||
*/
|
||||
private async pairingViolationMessages(
|
||||
dto: CompleteShippingLineBookingDto,
|
||||
): Promise<string[]> {
|
||||
const units: Container20ftUnit[] = [];
|
||||
for (const line of dto.containers ?? []) {
|
||||
const containerType = await this.resolveContainerType(line);
|
||||
if (containerType.sizeFt !== 20) continue;
|
||||
(line.units ?? []).forEach((u, idx) =>
|
||||
units.push({
|
||||
label: u.containerNumber || `20ft-${idx + 1}`,
|
||||
grossWeightTons: Number(u.vgmTons ?? 0),
|
||||
}),
|
||||
);
|
||||
}
|
||||
const violations =
|
||||
await this.containerValidationService.validate20ftPairingUnits(units);
|
||||
return violations.map((v) => v.message);
|
||||
}
|
||||
|
||||
/** Same session→owner resolution every shipping-line entry point uses. */
|
||||
private async requireShippingLine(userId: string) {
|
||||
const shippingLine =
|
||||
@@ -193,6 +222,17 @@ export class ShippingLineBookingCompletionService {
|
||||
);
|
||||
}
|
||||
|
||||
// Unbalanced 20ft pairs can never be planned onto wagons — refuse before
|
||||
// any cargo/credit write below. Same block the contract path applies.
|
||||
if (booking.freightType === "CONTAINER") {
|
||||
const pairing = await this.pairingViolationMessages(dto);
|
||||
if (pairing.length) {
|
||||
throw new BadRequestException(
|
||||
`Cannot complete booking — 20ft containers cannot be paired on wagons: ${pairing.join(" ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Completion is booking time. A lane with trains DEDICATED to this line
|
||||
// has no window concept at all: the line books whenever it wants until the
|
||||
// train's close offset. Only a lane with no dedicated train falls back to
|
||||
@@ -526,11 +566,21 @@ export class ShippingLineBookingCompletionService {
|
||||
computed.appliedModifiers,
|
||||
);
|
||||
|
||||
// Pairing is reported, not thrown: the confirm modal shows it next to the
|
||||
// price (as the customer form does) and disables confirm; /complete
|
||||
// hard-blocks the same payload.
|
||||
const pairingErrors =
|
||||
booking.freightType === "CONTAINER"
|
||||
? await this.pairingViolationMessages(dto)
|
||||
: [];
|
||||
|
||||
return {
|
||||
totalAmount: computed.totalAmount,
|
||||
currency: computed.currency,
|
||||
lineItems: computed.lineItems,
|
||||
warnings: computed.warnings,
|
||||
overweightLines: computed.overweightLines,
|
||||
pairingErrors,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UserTradeAccessService } from "../../user-trade-access/user-trade-acces
|
||||
import { resolveAuthUserId } from "../../../common/resolve-auth-user-id";
|
||||
|
||||
import {
|
||||
Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res,
|
||||
Body, Controller, Delete, Get, Param, ParseIntPipe, ParseUUIDPipe, Patch, Post, Query, Res,
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
@@ -37,7 +37,11 @@ import { UpdateImportLoadingStatusDto } from "../dto/update-import-loading-statu
|
||||
import { PreviewBulkTrainScheduleDto } from "../dto/preview-bulk-train-schedule.dto";
|
||||
import { PreviewContainerTrainScheduleDto } from "../dto/preview-container-train-schedule.dto";
|
||||
import { PreviewTrainScheduleDto } from "../dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "../dto/record-checkpoint.dto";
|
||||
import {
|
||||
DispatchScheduleDto,
|
||||
RecordCheckpointDto,
|
||||
UpdateCheckpointDto,
|
||||
} from "../dto/record-checkpoint.dto";
|
||||
import {
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
@@ -516,9 +520,14 @@ export class TrainSchedulingController {
|
||||
|
||||
@Post("schedules/:id/dispatch")
|
||||
@BookingStaff(FREIGHT_PERMS.trainScheduling.dispatch)
|
||||
@ApiOperation({ summary: "Dispatch a scheduled train" })
|
||||
dispatchSchedule(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
@ApiOperation({
|
||||
summary: "Dispatch a scheduled train (optional actual departure time, past allowed)",
|
||||
})
|
||||
dispatchSchedule(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: DispatchScheduleDto,
|
||||
) {
|
||||
return this.trainSchedulingService.dispatchSchedule(id, dto);
|
||||
}
|
||||
|
||||
@Get("intercity/bookings")
|
||||
@@ -956,6 +965,20 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.recordCheckpoint(id, dto);
|
||||
}
|
||||
|
||||
@Patch("schedules/:id/checkpoints/:sequenceNo")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Edit a logged leg's time/note (no side effects; allowed while dispatched or after arrival)",
|
||||
})
|
||||
updateCheckpoint(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("sequenceNo", ParseIntPipe) sequenceNo: number,
|
||||
@Body() dto: UpdateCheckpointDto,
|
||||
) {
|
||||
return this.trainSchedulingService.updateCheckpoint(id, sequenceNo, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/arrive")
|
||||
@TrainSchedulingUpdate()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -10,8 +10,6 @@ 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()
|
||||
@@ -24,17 +22,17 @@ export class RecordCheckpointDto {
|
||||
kind?: TrainCheckpointKind;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* When the train was actually at the station — staff often log after the
|
||||
* fact, so a past value is allowed. The service rejects the future and any
|
||||
* value out of order with the neighbouring legs.
|
||||
*/
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'ISO timestamp; defaults to now. Cannot be earlier than now.',
|
||||
description:
|
||||
'ISO timestamp; defaults to now. Past allowed, future rejected, must be in corridor order.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
@IsNotBackdated()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false })
|
||||
@@ -43,3 +41,30 @@ export class RecordCheckpointDto {
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Edit an already-logged leg's time/note — no side effects (no unload, no arrival). */
|
||||
export class UpdateCheckpointDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'ISO timestamp. Past allowed, future rejected, must be in corridor order.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
occurredAt?: string;
|
||||
|
||||
@ApiProperty({ required: false, nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export class DispatchScheduleDto {
|
||||
@ApiProperty({
|
||||
required: false,
|
||||
description: 'Actual departure time; defaults to now. Past allowed, future rejected.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsISO8601()
|
||||
actualDepartureAt?: string;
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ describe('TrainSchedulingService', () => {
|
||||
let wagonBookingAllocationsRepository: Record<string, jest.Mock>;
|
||||
let wagonAllocationContainerItemsRepository: Record<string, jest.Mock>;
|
||||
let wagonAllocationBulkLoadsRepository: Record<string, jest.Mock>;
|
||||
let trainCheckpointEventsRepository: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
// findGroupSiblings runs a query builder off dataSource.manager; default it
|
||||
@@ -127,6 +128,7 @@ describe('TrainSchedulingService', () => {
|
||||
findByIdWithFullGraph: jest.fn(),
|
||||
findAll: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
update: jest.fn(),
|
||||
maxReferenceSequence: jest.fn().mockResolvedValue(0),
|
||||
};
|
||||
trainScheduleBookingsRepository = {
|
||||
@@ -150,7 +152,7 @@ describe('TrainSchedulingService', () => {
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const trainCheckpointEventsRepository = {
|
||||
trainCheckpointEventsRepository = {
|
||||
findBySchedule: jest.fn().mockResolvedValue([]),
|
||||
findAll: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn(),
|
||||
@@ -1638,6 +1640,61 @@ describe('TrainSchedulingService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCheckpoint — leg time correction', () => {
|
||||
const t = (h: number) => new Date(Date.UTC(2026, 0, 1, h));
|
||||
const schedule = {
|
||||
id: 'sch-track',
|
||||
status: 'ARRIVED',
|
||||
routeId: null,
|
||||
originStationId: 'y0',
|
||||
destinationStationId: 'y1',
|
||||
actualDepartureAt: t(8),
|
||||
};
|
||||
const events = () => [
|
||||
{ id: 'e0', yardId: 'y0', sequenceNo: 0, kind: 'DEPARTED', occurredAt: t(8) },
|
||||
{ id: 'e1', yardId: 'y1', sequenceNo: 1, kind: 'ARRIVED', occurredAt: t(12) },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockResolvedValue(schedule);
|
||||
trainCheckpointEventsRepository.findBySchedule.mockImplementation(async () => events());
|
||||
});
|
||||
|
||||
it('rejects a leg time earlier than the previous leg', async () => {
|
||||
await expect(
|
||||
service.updateCheckpoint('sch-track', 1, { occurredAt: t(7).toISOString() }),
|
||||
).rejects.toThrow(/cannot be earlier than/);
|
||||
expect(trainCheckpointEventsRepository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a leg time later than the next leg', async () => {
|
||||
await expect(
|
||||
service.updateCheckpoint('sch-track', 0, { occurredAt: t(13).toISOString() }),
|
||||
).rejects.toThrow(/cannot be later than/);
|
||||
});
|
||||
|
||||
it('rejects a future time', async () => {
|
||||
const future = new Date(Date.now() + 3_600_000).toISOString();
|
||||
await expect(
|
||||
service.updateCheckpoint('sch-track', 1, { occurredAt: future }),
|
||||
).rejects.toThrow(/future/);
|
||||
});
|
||||
|
||||
it('accepts an in-order past time and re-stamps arrival for the final leg', async () => {
|
||||
await service.updateCheckpoint('sch-track', 1, {
|
||||
occurredAt: t(11).toISOString(),
|
||||
note: 'late log',
|
||||
});
|
||||
expect(trainCheckpointEventsRepository.update).toHaveBeenCalledWith('e1', {
|
||||
occurredAt: t(11),
|
||||
note: 'late log',
|
||||
});
|
||||
expect(trainSchedulesRepository.update).toHaveBeenCalledWith('sch-track', {
|
||||
actualArrivalAt: t(11),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectiveWagonsRequired', () => {
|
||||
const effective = (booking: unknown): number =>
|
||||
(service as never as { effectiveWagonsRequired(b: unknown): number })
|
||||
|
||||
@@ -174,7 +174,11 @@ import {
|
||||
import { TrainCheckpointEvent } from '../entities/train-checkpoint-event.entity';
|
||||
import { BookingJourneyService } from '../booking-journey.service';
|
||||
import { TrainCheckpointEventsRepository } from '../repositories/train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from '../dto/record-checkpoint.dto';
|
||||
import {
|
||||
DispatchScheduleDto,
|
||||
RecordCheckpointDto,
|
||||
UpdateCheckpointDto,
|
||||
} from '../dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../../routes/entities/route-milestone.entity';
|
||||
import { deriveTradeDirection } from '../../../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../../warehouses/warehouse-inventory.service';
|
||||
@@ -2645,7 +2649,7 @@ export class TrainSchedulingService {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async dispatchSchedule(scheduleId: string) {
|
||||
async dispatchSchedule(scheduleId: string, dto: DispatchScheduleDto = {}) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -2653,6 +2657,9 @@ export class TrainSchedulingService {
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
// Staff may record the departure after the fact — past is fine, future is not.
|
||||
const now = dto.actualDepartureAt ? new Date(dto.actualDepartureAt) : new Date();
|
||||
this.assertNotFuture(now, 'Departure time');
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
// Cargo readiness (in the warehouse, not inspected, not loaded onto a wagon)
|
||||
// never blocks departure — the dispatch confirm dialog warns and staff decide.
|
||||
@@ -2677,7 +2684,6 @@ export class TrainSchedulingService {
|
||||
}
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
if (setLocomotiveIds.length) {
|
||||
@@ -4160,6 +4166,7 @@ export class TrainSchedulingService {
|
||||
? TrainCheckpointKind.Arrived
|
||||
: TrainCheckpointKind.Passed);
|
||||
const occurredAt = dto.occurredAt ? new Date(dto.occurredAt) : new Date();
|
||||
await this.assertCheckpointTime(schedule, stations, dto.sequenceNo, occurredAt);
|
||||
|
||||
// Upsert by (scheduleId, sequenceNo) so re-logging a station updates rather than duplicates.
|
||||
const [existing] = await this.trainCheckpointEventsRepository.findAll({
|
||||
@@ -4183,8 +4190,14 @@ export class TrainSchedulingService {
|
||||
});
|
||||
}
|
||||
|
||||
// The origin DEPARTED checkpoint IS the departure — keep the schedule's
|
||||
// headline timestamp on the same clock the operator just entered.
|
||||
if (dto.sequenceNo === 0) {
|
||||
await this.trainSchedulesRepository.update(scheduleId, { actualDepartureAt: occurredAt });
|
||||
}
|
||||
|
||||
if (dto.sequenceNo === finalSeq) {
|
||||
await this.arriveSchedule(scheduleId);
|
||||
await this.arriveSchedule(scheduleId, occurredAt);
|
||||
} else {
|
||||
// Mid-corridor auto-unload: bookings destined for this yard alight the
|
||||
// moment the train is recorded here — the yard operator no longer has to
|
||||
@@ -4220,11 +4233,133 @@ export class TrainSchedulingService {
|
||||
return this.getScheduleCheckpoints(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Correct an already-logged leg's time/note. Pure edit: no auto-unload, no
|
||||
* position fix, no arrival — those already happened when the leg was logged.
|
||||
* Allowed on DISPATCHED and ARRIVED trains (a journey is corrected after the
|
||||
* fact as often as during it). The origin/final legs also re-stamp the
|
||||
* schedule's departure/arrival so the headline figures follow the edit.
|
||||
*/
|
||||
async updateCheckpoint(scheduleId: string, sequenceNo: number, dto: UpdateCheckpointDto) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (
|
||||
schedule.status !== TrainScheduleStatusEnum.Dispatched &&
|
||||
schedule.status !== TrainScheduleStatusEnum.Arrived
|
||||
) {
|
||||
throw new BadRequestException('Only DISPATCHED or ARRIVED trains have checkpoints to edit');
|
||||
}
|
||||
const stations = await this.buildScheduleStations(schedule);
|
||||
const station = stations.find((s) => s.sequenceNo === sequenceNo);
|
||||
if (!station) {
|
||||
throw new BadRequestException(`Station ${sequenceNo} is not on this route`);
|
||||
}
|
||||
// Match by yard, like getScheduleCheckpoints — legacy rows may carry an
|
||||
// older station numbering.
|
||||
const events = await this.trainCheckpointEventsRepository.findBySchedule(scheduleId);
|
||||
const existing =
|
||||
events.find((e) => e.yardId === station.yardId) ??
|
||||
events.find((e) => e.sequenceNo === sequenceNo);
|
||||
if (!existing) {
|
||||
throw new BadRequestException(`Station ${station.label} has not been logged yet`);
|
||||
}
|
||||
|
||||
const patch: Partial<TrainCheckpointEvent> = {};
|
||||
if (dto.occurredAt) {
|
||||
const occurredAt = new Date(dto.occurredAt);
|
||||
await this.assertCheckpointTime(schedule, stations, sequenceNo, occurredAt, existing.id);
|
||||
patch.occurredAt = occurredAt;
|
||||
}
|
||||
if (dto.note !== undefined) patch.note = dto.note;
|
||||
if (Object.keys(patch).length) {
|
||||
await this.trainCheckpointEventsRepository.update(existing.id, patch);
|
||||
}
|
||||
|
||||
if (patch.occurredAt) {
|
||||
const finalSeq = stations[stations.length - 1].sequenceNo;
|
||||
if (sequenceNo === 0) {
|
||||
await this.trainSchedulesRepository.update(scheduleId, {
|
||||
actualDepartureAt: patch.occurredAt,
|
||||
});
|
||||
} else if (sequenceNo === finalSeq && schedule.status === TrainScheduleStatusEnum.Arrived) {
|
||||
await this.trainSchedulesRepository.update(scheduleId, {
|
||||
actualArrivalAt: patch.occurredAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return this.getScheduleCheckpoints(scheduleId);
|
||||
}
|
||||
|
||||
private assertNotFuture(at: Date, what: string) {
|
||||
if (Number.isNaN(at.getTime())) {
|
||||
throw new BadRequestException(`${what} is not a valid date`);
|
||||
}
|
||||
// Small skew allowance so an honest "now" from a client clock passes.
|
||||
if (at.getTime() > Date.now() + 60_000) {
|
||||
throw new BadRequestException(`${what} cannot be in the future`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A leg's time must not be in the future and must sit in corridor order:
|
||||
* no earlier than every logged leg before it (and the dispatch time, for
|
||||
* legs after the origin), no later than every logged leg after it.
|
||||
* `ignoreEventId` excludes the row being edited from its own bounds.
|
||||
*/
|
||||
private async assertCheckpointTime(
|
||||
schedule: TrainSchedule,
|
||||
stations: { sequenceNo: number; yardId: string; label: string }[],
|
||||
sequenceNo: number,
|
||||
occurredAt: Date,
|
||||
ignoreEventId?: string,
|
||||
) {
|
||||
this.assertNotFuture(occurredAt, 'Checkpoint time');
|
||||
|
||||
const seqByYard = new Map(stations.map((s) => [s.yardId, s.sequenceNo]));
|
||||
const labelBySeq = new Map(stations.map((s) => [s.sequenceNo, s.label]));
|
||||
const events = (await this.trainCheckpointEventsRepository.findBySchedule(schedule.id)).filter(
|
||||
(e) => e.id !== ignoreEventId,
|
||||
);
|
||||
const seqOf = (e: TrainCheckpointEvent) => seqByYard.get(e.yardId) ?? e.sequenceNo;
|
||||
const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 16) + ' UTC';
|
||||
|
||||
let floor: { at: Date; label: string } | null = null;
|
||||
let ceil: { at: Date; label: string } | null = null;
|
||||
for (const e of events) {
|
||||
const s = seqOf(e);
|
||||
if (s < sequenceNo && (!floor || e.occurredAt > floor.at)) {
|
||||
floor = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
|
||||
}
|
||||
if (s > sequenceNo && (!ceil || e.occurredAt < ceil.at)) {
|
||||
ceil = { at: e.occurredAt, label: labelBySeq.get(s) ?? `station ${s}` };
|
||||
}
|
||||
}
|
||||
// The origin leg rewrites the departure itself; every later leg must
|
||||
// follow it.
|
||||
if (sequenceNo > 0 && schedule.actualDepartureAt && (!floor || schedule.actualDepartureAt > floor.at)) {
|
||||
floor = { at: schedule.actualDepartureAt, label: 'departure' };
|
||||
}
|
||||
|
||||
if (floor && occurredAt < floor.at) {
|
||||
throw new BadRequestException(
|
||||
`Checkpoint time cannot be earlier than ${floor.label} (${fmt(floor.at)})`,
|
||||
);
|
||||
}
|
||||
if (ceil && occurredAt > ceil.at) {
|
||||
throw new BadRequestException(
|
||||
`Checkpoint time cannot be later than ${ceil.label} (${fmt(ceil.at)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a dispatched train arrived: close out the schedule, move the locomotive
|
||||
* and wagons to the destination yard, and free the assets for re-use.
|
||||
*/
|
||||
async arriveSchedule(scheduleId: string) {
|
||||
async arriveSchedule(scheduleId: string, arrivedAt?: Date) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
@@ -4233,7 +4368,9 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Only DISPATCHED trains can arrive');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
// The arrival clock: the operator's entered time when arriving via the final
|
||||
// checkpoint (already order/future-checked there), else now.
|
||||
const now = arrivedAt ?? new Date();
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.trainSchedulesRepository.updateStatus(
|
||||
@@ -9025,33 +9162,88 @@ export class TrainSchedulingService {
|
||||
throw new BadRequestException('Source wagon has no load to move');
|
||||
}
|
||||
|
||||
// Target: a slot of this train set, or an empty consist-only wagon of the
|
||||
// built train (physical wagon with no slot row yet).
|
||||
// Leg spans: a physical wagon carries one slot PER LEG (cross-leg sharing —
|
||||
// Gelan→Adama and Adama→Doraleh loads ride the same wagon in two slots), so
|
||||
// "the slot on that wagon" only means the one whose leg overlaps the moving
|
||||
// load's leg. Null board/alight = the schedule's own endpoints.
|
||||
const stops = await this.stopYardsForSchedule(schedule);
|
||||
const spanOf = (slot: {
|
||||
boardYardId?: string | null;
|
||||
alightYardId?: string | null;
|
||||
}): [number, number] => {
|
||||
const from = slot.boardYardId ? stops.indexOf(slot.boardYardId) : 0;
|
||||
const to = slot.alightYardId ? stops.indexOf(slot.alightYardId) : stops.length - 1;
|
||||
return [from < 0 ? 0 : from, to < 0 ? Math.max(1, stops.length - 1) : to];
|
||||
};
|
||||
const overlaps = (a: [number, number], b: [number, number]) => a[0] < b[1] && b[0] < a[1];
|
||||
const sourceSpan = spanOf(source);
|
||||
|
||||
// Target: a slot of this train set, or a physical wagon of this train —
|
||||
// coupled-but-empty consist wagon (built train), or a wagon already pinned
|
||||
// by another slot of this set (then: the overlapping-leg slot, or a fresh
|
||||
// slot for a free leg).
|
||||
const slotById = slots.find((w) => w.id === dto.targetWagonId) ?? null;
|
||||
const wagonForTarget = slotById
|
||||
? null
|
||||
: schedule.trainSet?.trainId
|
||||
? await this.dataSource.getRepository(Wagon).findOne({
|
||||
where: { id: dto.targetWagonId, trainId: schedule.trainSet.trainId },
|
||||
relations: { wagonType: true },
|
||||
})
|
||||
: null;
|
||||
let wagonForTarget: Wagon | null = null;
|
||||
if (!slotById) {
|
||||
const wagon = await this.dataSource.getRepository(Wagon).findOne({
|
||||
where: { id: dto.targetWagonId },
|
||||
relations: { wagonType: true },
|
||||
});
|
||||
const onThisTrain =
|
||||
!!wagon &&
|
||||
((!!schedule.trainSet?.trainId && wagon.trainId === schedule.trainSet.trainId) ||
|
||||
slots.some((w) => w.physicalWagonId === wagon.id));
|
||||
wagonForTarget = onThisTrain ? wagon : null;
|
||||
}
|
||||
if (!slotById && !wagonForTarget) {
|
||||
throw new NotFoundException('Target wagon is not part of this schedule');
|
||||
}
|
||||
// A physical wagon holds at most one slot. When the caller addressed the
|
||||
// wagon directly but a slot is already pinned to it, move into that slot
|
||||
// rather than minting a second one on the same wagon.
|
||||
const targetSlot =
|
||||
slotById ??
|
||||
(wagonForTarget
|
||||
? (slots.find((w) => w.physicalWagonId === wagonForTarget.id) ?? null)
|
||||
? (slots.find(
|
||||
(w) =>
|
||||
w.physicalWagonId === wagonForTarget.id && overlaps(spanOf(w), sourceSpan),
|
||||
) ?? null)
|
||||
: null);
|
||||
const consistWagon = targetSlot ? null : wagonForTarget;
|
||||
|
||||
// Leg clash guard: after the move, no two slots on one physical wagon may
|
||||
// ride the same edge. Source load → target wagon; on a swap, target load →
|
||||
// source wagon.
|
||||
const targetPhysicalId = targetSlot?.physicalWagonId ?? consistWagon?.id ?? null;
|
||||
const clashOn = (
|
||||
physicalWagonId: string | null,
|
||||
excludeSlotId: string | null,
|
||||
span: [number, number],
|
||||
) =>
|
||||
!!physicalWagonId &&
|
||||
slots.some(
|
||||
(w) =>
|
||||
w.physicalWagonId === physicalWagonId &&
|
||||
w.id !== excludeSlotId &&
|
||||
w.id !== source.id &&
|
||||
(w.allocations?.length ?? 0) > 0 &&
|
||||
overlaps(spanOf(w), span),
|
||||
);
|
||||
if (clashOn(targetPhysicalId, targetSlot?.id ?? null, sourceSpan)) {
|
||||
throw new BadRequestException(
|
||||
'That wagon already carries another load on the same leg — pick a wagon free on that leg.',
|
||||
);
|
||||
}
|
||||
const targetAllocs = targetSlot ? await loadAllocations(targetSlot.id) : [];
|
||||
if (targetSlot && targetSlot.id === source.id) {
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
if (
|
||||
targetSlot &&
|
||||
targetAllocs.length &&
|
||||
clashOn(source.physicalWagonId ?? null, source.id, spanOf(targetSlot))
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
'Swap refused: the source wagon already carries another load on the incoming load’s leg.',
|
||||
);
|
||||
}
|
||||
|
||||
const loadTypesOf = (allocs: WagonBookingAllocation[]) => [
|
||||
...new Set(allocs.map((a) => (a.loadType ?? 'CONTAINER').toUpperCase())),
|
||||
|
||||
@@ -121,8 +121,46 @@ export class WagonsService {
|
||||
* (yard workspace, coupling pickers) walk the pages client-side — see
|
||||
* `wagonService.listAll` in the backoffice.
|
||||
*/
|
||||
findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||
return paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
||||
async findAll(query: ListWagonsQueryDto = {}): Promise<PaginatedResponse<Wagon>> {
|
||||
const page = await paginateQuery(this.buildListQuery(query), query, { defaultPageSize: 10 });
|
||||
await this.attachStatusDates(page.items);
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest status-flip dates from the audit log, for the wagons desk columns:
|
||||
* when the wagon last went to MAINTENANCE and when it last became AVAILABLE.
|
||||
* One grouped query per page; null when the log has no such flip.
|
||||
*/
|
||||
private async attachStatusDates(wagons: Wagon[]): Promise<void> {
|
||||
if (!wagons.length) return;
|
||||
const rows: Array<{
|
||||
wagonId: string;
|
||||
lastMaintenanceAt: Date | null;
|
||||
lastAvailableAt: Date | null;
|
||||
}> = await this.dataSource
|
||||
.getRepository(WagonStatusLog)
|
||||
.createQueryBuilder('l')
|
||||
.select('l.wagon_id', 'wagonId')
|
||||
.addSelect(
|
||||
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Maintenance}')`,
|
||||
'lastMaintenanceAt',
|
||||
)
|
||||
.addSelect(
|
||||
`MAX(l.created_at) FILTER (WHERE l.to_status = '${WagonStatus.Available}')`,
|
||||
'lastAvailableAt',
|
||||
)
|
||||
.where('l.wagon_id IN (:...ids)', { ids: wagons.map((w) => w.id) })
|
||||
.groupBy('l.wagon_id')
|
||||
.getRawMany();
|
||||
const byId = new Map(rows.map((r) => [r.wagonId, r]));
|
||||
for (const w of wagons) {
|
||||
const r = byId.get(w.id);
|
||||
Object.assign(w, {
|
||||
lastMaintenanceAt: r?.lastMaintenanceAt ?? null,
|
||||
lastAvailableAt: r?.lastAvailableAt ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Wagon> {
|
||||
|
||||
Reference in New Issue
Block a user