mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 10:40:58 +00:00
Merge pull request #1434 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import {
|
||||
bulkTonWagonsRequired,
|
||||
bulkTonsPerWagonFor,
|
||||
} from '../train-scheduling/train-capacity.util';
|
||||
|
||||
/**
|
||||
* Sizing of a bulk quantity cut (no DB touched on this branch): a whole-booking
|
||||
@@ -107,3 +111,91 @@ describe('BookingWagonCancellationService.rebook (odd-20ft consolidation)', () =
|
||||
).rejects.toThrow(/already shares a wagon/i);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A NUMBER_OF_WAGONS booking pins its count in `bulkRequestedWagons`, and
|
||||
* bulkTonWagonsRequired honours that verbatim. Partial cancel must shrink it
|
||||
* alongside wagonsRequired/cargoTotalWeightVgm — left stale, the booking
|
||||
* re-inflates to its pre-cancel count on the next allocation and each wagon
|
||||
* carries tons / stale-count instead of the real even share.
|
||||
*/
|
||||
describe('partial cancel of a NUMBER_OF_WAGONS bulk booking', () => {
|
||||
// 980T over 14 wagons (70T each), 2 wagons cancelled.
|
||||
const before = { freightType: 'BULK', cargoTotalWeightVgm: 980, bulkRequestedWagons: 14 };
|
||||
const droppedWeight = 140;
|
||||
const wagonsCancelled = 2;
|
||||
|
||||
// The decrement applied in applyPaidCut's booking update.
|
||||
const after = {
|
||||
...before,
|
||||
cargoTotalWeightVgm: before.cargoTotalWeightVgm - droppedWeight,
|
||||
bulkRequestedWagons: Math.max(
|
||||
0,
|
||||
Math.floor(before.bulkRequestedWagons - wagonsCancelled),
|
||||
),
|
||||
};
|
||||
|
||||
it('reallocates at the reduced count, not the pre-cancel one', () => {
|
||||
expect(bulkTonWagonsRequired(before, undefined, 'nw5', 70)).toBe(14);
|
||||
expect(bulkTonWagonsRequired(after, undefined, 'nw5', 70)).toBe(12);
|
||||
});
|
||||
|
||||
it('keeps tons-per-wagon at the real even share', () => {
|
||||
// Stale count would spread 840T over 14 wagons → 60T each.
|
||||
expect(bulkTonsPerWagonFor(after, undefined, 'nw5', 70)).toBe(70);
|
||||
});
|
||||
|
||||
it('cancelling every wagon leaves no requested count behind', () => {
|
||||
const all = Math.max(0, Math.floor(before.bulkRequestedWagons - 14));
|
||||
expect(all).toBe(0);
|
||||
expect(bulkTonWagonsRequired(
|
||||
{ ...before, cargoTotalWeightVgm: 0, bulkRequestedWagons: all },
|
||||
undefined,
|
||||
'nw5',
|
||||
70,
|
||||
)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Rebooking a NUMBER_OF_WAGONS bulk credit: the create path rejects the rebook
|
||||
* unless the DTO carries a wagon count ("<cargo> is booked by wagons — enter
|
||||
* the number of wagons needed"), and the quantities snapshot holds tons only.
|
||||
* The count therefore has to come off the cancellation row itself.
|
||||
*/
|
||||
describe('BookingWagonCancellationService.buildRebookDto (bulk wagon count)', () => {
|
||||
const svc = Object.create(BookingWagonCancellationService.prototype) as {
|
||||
buildRebookDto(
|
||||
row: unknown,
|
||||
scheduledDate: string,
|
||||
overrides?: unknown,
|
||||
): { bulkLines?: { cargoWeightTons: number }[]; requestedWagons?: number };
|
||||
};
|
||||
|
||||
it('carries the cancelled wagon count onto the rebook', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 2, weightTons: 140, cancelledQuantities: { bulkTons: 140 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
expect(dto.bulkLines).toEqual([{ cargoWeightTons: 140 }]);
|
||||
// Without this the create path throws before the booking is ever made.
|
||||
expect(dto.requestedWagons).toBe(2);
|
||||
});
|
||||
|
||||
it('rounds a fractional cut up to a whole wagon', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 0.5, weightTons: 35, cancelledQuantities: { bulkTons: 35 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
// Flooring would send 0 into a check that demands >= 1.
|
||||
expect(dto.requestedWagons).toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the count off when nothing was cancelled', () => {
|
||||
const dto = svc.buildRebookDto(
|
||||
{ wagonsCancelled: 0, weightTons: 0, cancelledQuantities: { bulkTons: 12 } },
|
||||
'2026-09-10',
|
||||
);
|
||||
expect(dto.requestedWagons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,8 @@ import { Rate } from '../rule-engine/entities/rate.entity';
|
||||
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/services/train-scheduling.service';
|
||||
import { TrainScheduleBooking } from '../train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSetWagon } from '../train-sets/entities/train-set-wagon.entity';
|
||||
import { WagonAllocationBulkLoad } from '../train-schedules/entities/wagon-allocation-bulk-load.entity';
|
||||
import { WagonAllocationContainerItem } from '../train-schedules/entities/wagon-allocation-container-item.entity';
|
||||
import { WagonBookingAllocation } from '../train-schedules/entities/wagon-booking-allocation.entity';
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
} from './booking-wagon-cancellations.repository';
|
||||
import { BookingsRepository } from './bookings.repository';
|
||||
import {
|
||||
CancelRemainingWagonsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
RebookContainerLineDto,
|
||||
RequestWagonCancellationDto,
|
||||
@@ -625,7 +628,14 @@ export class BookingWagonCancellationService {
|
||||
this.logger.warn(`No wagon cancellation for paid fee invoice ${feeInvoiceId}.`);
|
||||
return;
|
||||
}
|
||||
if (row.status !== 'FEE_PENDING') return;
|
||||
if (row.status !== 'FEE_PENDING') {
|
||||
// At-loading cancels apply the cut immediately and leave the invoice
|
||||
// open — settle only the payment stamp when the customer pays later.
|
||||
if (!row.feePaidAt) {
|
||||
await this.repo.update(row.id, { feePaidAt: new Date() });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// The fee can settle after loading started (slow payment). Never cut
|
||||
// loaded cargo: leave the row FEE_PENDING and alert staff to resolve
|
||||
@@ -653,6 +663,25 @@ export class BookingWagonCancellationService {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.applyCut(row, releasedEarly, { feeSettled: true });
|
||||
this.logger.log(
|
||||
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the cut to the booking: reduce quantities/wagons/amount, release the
|
||||
* cancelled allocations, flip the row to CREDIT_AVAILABLE. Runs at fee
|
||||
* settlement for the customer-requested flow (feeSettled: true) and
|
||||
* immediately for at-loading cancels (feeSettled only when no fee is owed —
|
||||
* EDR fault; a customer-fault cut leaves feePaidAt null until the open
|
||||
* invoice settles via onFeePaid).
|
||||
*/
|
||||
private async applyCut(
|
||||
row: BookingWagonCancellation,
|
||||
releasedEarly: boolean,
|
||||
opts: { feeSettled: boolean },
|
||||
): Promise<void> {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const booking = await manager.getRepository(Booking).findOne({
|
||||
where: { id: row.bookingId },
|
||||
@@ -704,8 +733,21 @@ export class BookingWagonCancellationService {
|
||||
Number(booking.wagonsRequired ?? 0) - Number(row.wagonsCancelled),
|
||||
);
|
||||
const isFull = wagonsLeft <= 0;
|
||||
// NUMBER_OF_WAGONS bookings pin their count in bulkRequestedWagons, which
|
||||
// bulkTonWagonsRequired honours verbatim. Left stale it re-inflates the
|
||||
// booking to its pre-cancel count on the next allocation (and shrinks
|
||||
// tons-per-wagon to tons / stale-count), so shrink it with the cut.
|
||||
const requestedWagonsLeft = booking.bulkRequestedWagons
|
||||
? Math.max(
|
||||
0,
|
||||
Math.floor(Number(booking.bulkRequestedWagons) - Number(row.wagonsCancelled)),
|
||||
)
|
||||
: null;
|
||||
await manager.getRepository(Booking).update(booking.id, {
|
||||
wagonsRequired: Math.max(0, wagonsLeft),
|
||||
...(requestedWagonsLeft !== null
|
||||
? { bulkRequestedWagons: requestedWagonsLeft }
|
||||
: {}),
|
||||
cargoTotalWeightVgm: Math.max(
|
||||
0,
|
||||
round3(Number(booking.cargoTotalWeightVgm) - droppedWeight),
|
||||
@@ -723,7 +765,7 @@ export class BookingWagonCancellationService {
|
||||
|
||||
await manager.getRepository(BookingWagonCancellation).update(row.id, {
|
||||
status: 'CREDIT_AVAILABLE',
|
||||
feePaidAt: new Date(),
|
||||
...(opts.feeSettled ? { feePaidAt: new Date() } : {}),
|
||||
weightTons: droppedWeight,
|
||||
cancelledQuantities: quantities,
|
||||
});
|
||||
@@ -741,9 +783,145 @@ export class BookingWagonCancellationService {
|
||||
: `${row.wagonsCancelled} wagon(s) of ${booking.reference} are cancelled. Your paid freight is kept as credit — rebook it on any coming train day.`,
|
||||
);
|
||||
}
|
||||
this.logger.log(
|
||||
`Wagon cancellation ${row.id}: fee paid, booking ${row.bookingId} reduced by ${row.wagonsCancelled} wagon(s).`,
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel of the never-loaded remainder mid-load: the operator loaded
|
||||
* what physically rides and cuts the rest, so the booking shrinks to its
|
||||
* loaded wagons, dispatch unblocks, and the warehouse only ever sees the
|
||||
* final (smaller) booking. Unlike the customer flow the cut applies
|
||||
* IMMEDIATELY — the train cannot wait for a fee payment:
|
||||
* - CUSTOMER fault: cancellation fee invoiced, payable after; the credit
|
||||
* row opens right away (feePaidAt stamps when the invoice settles).
|
||||
* - EDR fault: no fee at all; the credit is rebookable in full.
|
||||
*/
|
||||
async cancelRemainingAtLoading(
|
||||
bookingId: string,
|
||||
dto: CancelRemainingWagonsDto,
|
||||
userId?: string,
|
||||
): Promise<BookingWagonCancellation> {
|
||||
const booking = await this.bookingsRepository.findById(bookingId);
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found.`);
|
||||
if (booking.paymentStatus !== 'PAID' || booking.status !== 'PAID') {
|
||||
throw new BadRequestException(
|
||||
'Only a paid booking still loading can cancel its remaining wagons.',
|
||||
);
|
||||
}
|
||||
if (booking.loadedAt) {
|
||||
throw new BadRequestException(
|
||||
'This booking is already fully loaded — there is nothing left to cancel.',
|
||||
);
|
||||
}
|
||||
if (!booking.contractId) {
|
||||
throw new BadRequestException(
|
||||
'Wagon cancellation needs a contract booking (the credit is rebooked under the contract).',
|
||||
);
|
||||
}
|
||||
const open = await this.repo.findOpenForBooking(bookingId);
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
'This booking already has a cancellation awaiting its fee. Pay or withdraw it first.',
|
||||
);
|
||||
}
|
||||
|
||||
const allocations = await this.dataSource
|
||||
.getRepository(WagonBookingAllocation)
|
||||
.createQueryBuilder('alloc')
|
||||
.innerJoin(TrainSetWagon, 'slot', 'slot.id = alloc.train_set_wagon_id')
|
||||
.innerJoin(
|
||||
TrainSchedule,
|
||||
'schedule',
|
||||
'schedule.train_set_id = slot.train_set_id AND schedule.id = :scheduleId',
|
||||
{ scheduleId: dto.scheduleId },
|
||||
)
|
||||
.where('alloc.booking_id = :bookingId', { bookingId })
|
||||
.getMany();
|
||||
const loaded = allocations.filter(
|
||||
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
|
||||
);
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.status !== 'LOADED' && a.status !== 'DEPARTED',
|
||||
);
|
||||
if (!loaded.length) {
|
||||
throw new BadRequestException(
|
||||
'Loading has not started for this booking — use the normal wagon cancellation flow.',
|
||||
);
|
||||
}
|
||||
if (!remaining.length) {
|
||||
throw new BadRequestException(
|
||||
'Every wagon of this booking is loaded — there is nothing to cancel.',
|
||||
);
|
||||
}
|
||||
|
||||
const cut = await this.resolveRequestedCut(booking, {
|
||||
wagonAllocationIds: remaining.map((r) => r.id),
|
||||
} as RequestWagonCancellationDto);
|
||||
if (booking.consolidationPartnerId) this.assertCutSparesSharedWagon(cut);
|
||||
|
||||
const edrFault = !!dto.edrFault;
|
||||
const fee = edrFault ? null : await this.priceFee(booking, cut);
|
||||
const creditAmount = this.creditFor(booking, cut.wagons);
|
||||
|
||||
const row = await this.repo.create({
|
||||
bookingId,
|
||||
wagonsCancelled: cut.wagons,
|
||||
weightTons: cut.weightTons,
|
||||
cancelledQuantities: cut.quantities,
|
||||
creditAmount,
|
||||
feeRateId: fee?.rates[0]?.id ?? null,
|
||||
feeAmount: fee?.amount ?? 0,
|
||||
feeCurrency: fee?.currency ?? booking.paymentCurrency ?? 'ETB',
|
||||
status: 'FEE_PENDING',
|
||||
reason: dto.reason,
|
||||
fault: edrFault ? 'EDR' : 'CUSTOMER',
|
||||
requestedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
let current = row;
|
||||
if (fee && fee.amount > 0) {
|
||||
const invoice = await this.billing.generateInvoice({
|
||||
source: Freight.InvoiceSource.Booking,
|
||||
sourceId: bookingId,
|
||||
type: WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
companyId: booking.companyId,
|
||||
companyProfileId: booking.companyProfileId,
|
||||
currency: fee.currency,
|
||||
lines: [
|
||||
{
|
||||
chargeType: 'CANCELLATION_FEE',
|
||||
description: `Wagon cancellation fee — ${cut.wagons} wagon(s) of booking ${booking.reference} cancelled at loading`,
|
||||
quantity: cut.wagons,
|
||||
unitRate: fee.perWagon,
|
||||
amount: fee.amount,
|
||||
currency: fee.currency,
|
||||
metadata: { wagonCancellationId: row.id },
|
||||
},
|
||||
],
|
||||
totalAmount: fee.amount,
|
||||
status: Freight.InvoiceStatus.Issued,
|
||||
});
|
||||
current = (await this.repo.update(row.id, { feeInvoiceId: invoice.id })) ?? row;
|
||||
}
|
||||
|
||||
// The cut applies NOW — booking shrinks, allocations release, credit opens.
|
||||
// EDR fault (or a zero fee) settles the fee side immediately; a customer-
|
||||
// fault fee stays owed and stamps feePaidAt via onFeePaid when it settles.
|
||||
await this.applyCut(current, false, { feeSettled: edrFault || !fee || fee.amount <= 0 });
|
||||
|
||||
// The booking now holds only loaded wagons — let the journey complete the
|
||||
// load (PAID → IN_TRANSIT, warehouse inventory, milestones).
|
||||
this.events.emit('booking.wagonsCancelledAtLoading', {
|
||||
bookingId,
|
||||
scheduleId: dto.scheduleId,
|
||||
userId: userId ?? null,
|
||||
});
|
||||
|
||||
this.notifyStaff(
|
||||
booking,
|
||||
'Wagons cancelled at loading',
|
||||
`${booking.reference}: ${cut.wagons} unloaded wagon(s) cancelled (${edrFault ? 'EDR fault — no fee' : `customer fault — fee invoiced`}). Reason: ${dto.reason}`,
|
||||
);
|
||||
return this.mustFind(row.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1712,6 +1890,14 @@ export class BookingWagonCancellationService {
|
||||
}
|
||||
|
||||
dto.bulkLines = [{ cargoWeightTons: Number(q.bulkTons ?? row.weightTons) }];
|
||||
// NUMBER_OF_WAGONS cargo is booked by wagon count, not by tons: the create
|
||||
// path rejects the rebook outright without it. The count is not in the
|
||||
// quantities snapshot (which only carries tons) — it is the cancellation's
|
||||
// own wagonsCancelled, so every existing credit rebooks without a backfill.
|
||||
// Rounded UP: a fractional cut still needs a whole wagon to ride on, and
|
||||
// flooring 0.5 would send 0 into a check that demands >= 1.
|
||||
const cancelledWagons = Math.ceil(Number(row.wagonsCancelled ?? 0));
|
||||
if (cancelledWagons >= 1) dto.requestedWagons = cancelledWagons;
|
||||
return dto;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,7 @@ import { BookingWagonCancellationService } from "./booking-wagon-cancellation.se
|
||||
import {
|
||||
FilterWagonCancellationsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
CancelRemainingWagonsDto,
|
||||
RequestWagonCancellationDto,
|
||||
} from "./dto/wagon-cancellation.dto";
|
||||
import {
|
||||
@@ -640,6 +641,20 @@ export class BookingsController {
|
||||
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Post(":id/wagon-cancellations/at-loading")
|
||||
@BookingStaff(FREIGHT_PERMS.trainScheduling.load)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Staff: cancel the never-loaded remainder of a booking mid-load. The cut applies immediately (the train cannot wait); CUSTOMER fault invoices the fee to pay after, EDR fault charges nothing.",
|
||||
})
|
||||
async cancelRemainingWagonsAtLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CancelRemainingWagonsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.wagonCancellationService.cancelRemainingAtLoading(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Get(":id/wagon-cancellations")
|
||||
@ApiOperation({
|
||||
summary: "Wagon-cancellation history of one booking (owner or staff)",
|
||||
|
||||
@@ -3,9 +3,11 @@ import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -169,3 +171,28 @@ export class FilterWagonCancellationsDto {
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff cancel of the never-loaded remainder of a booking mid-load: everything
|
||||
* not yet LOADED on the schedule is cut, the booking shrinks to its loaded
|
||||
* wagons, and the freed credit is rebookable. Fault decides the fee: CUSTOMER
|
||||
* — cancellation fee invoiced (payable after the cut); EDR — no fee.
|
||||
*/
|
||||
export class CancelRemainingWagonsDto {
|
||||
@ApiProperty({ description: 'Schedule the booking is being loaded on' })
|
||||
@IsUUID('4')
|
||||
scheduleId!: string;
|
||||
|
||||
@ApiProperty({ description: 'Why the remaining wagons are not riding' })
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(2000)
|
||||
reason!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'The shortfall is EDR\'s fault (wagon shortage, yard problem) — no fee charged',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
edrFault?: boolean;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,15 @@ export class BookingWagonCancellation extends BaseEntity {
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
/**
|
||||
* At-loading cancels of the never-loaded remainder: who caused it.
|
||||
* CUSTOMER — cancellation fee applies (invoice payable after the cut);
|
||||
* EDR — no fee, the full credit is rebookable. Null for customer-requested
|
||||
* cancellations (the pre-loading flow).
|
||||
*/
|
||||
@Column({ name: 'fault', type: 'varchar', length: 16, nullable: true })
|
||||
fault?: 'CUSTOMER' | 'EDR' | null;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -588,6 +588,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
/**
|
||||
* First wagon of this booking confirmed loaded (per-wagon loading). The
|
||||
* booking stays PAID until every remaining wagon is LOADED — loadedAt then
|
||||
* stamps the completion. Also shields the booking from the dispatch
|
||||
* "left behind" unassign while mid-load.
|
||||
*/
|
||||
@Column({ name: 'loading_started_at', type: 'timestamptz', nullable: true })
|
||||
loadingStartedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||
loadedByUserId?: string | null;
|
||||
|
||||
|
||||
@@ -201,6 +201,9 @@ export class ContractsRepository extends BaseRepository<Contract> {
|
||||
.leftJoinAndSelect('routes.destinationYard', 'routeDestination')
|
||||
.leftJoinAndSelect('contract.cargoScope', 'cargoScope')
|
||||
.leftJoinAndSelect('cargoScope.cargoType', 'cargoType')
|
||||
// Wagon types carry the rated capacity the booking forms need to reject
|
||||
// a wagon count whose even share overloads a wagon (see maxTonsPerWagon).
|
||||
.leftJoinAndSelect('cargoType.wagonTypes', 'cargoTypeWagonTypes')
|
||||
.leftJoinAndSelect('contract.rateSnapshots', 'rateSnapshots')
|
||||
.leftJoinAndSelect('contract.signatures', 'signatures')
|
||||
.leftJoinAndSelect('signatures.signatureFile', 'signatureFile')
|
||||
|
||||
@@ -13,7 +13,9 @@ import { YardCountry } from '@edr/types';
|
||||
//
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { CargoType } from '../rule-engine/entities/cargo-type.entity';
|
||||
import { ServiceType } from '../rule-engine/entities/service-type.entity';
|
||||
import { bulkTonsPerWagon } from '../train-scheduling/train-capacity.util';
|
||||
import { Yard } from '../rule-engine/entities/yard.entity';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
@@ -858,6 +860,29 @@ export class ContractsService {
|
||||
);
|
||||
}
|
||||
|
||||
// NUMBER_OF_WAGONS booking forms need the heaviest load one wagon may take
|
||||
// so they can reject a wagon count whose even share overloads a wagon —
|
||||
// the client-side twin of ContractBookingService.assertWagonShareFits.
|
||||
// The raw wagonTypes join rows are dropped: only the derived cap ships.
|
||||
for (const scope of contract.cargoScope ?? []) {
|
||||
const cargoType = scope.cargoType as
|
||||
| (CargoType & { maxTonsPerWagon?: number | null })
|
||||
| null
|
||||
| undefined;
|
||||
if (!cargoType) continue;
|
||||
const allowed = (cargoType.wagonTypes ?? []).filter(
|
||||
(wt) => Number(wt.capacityTons) > 0,
|
||||
);
|
||||
cargoType.maxTonsPerWagon = allowed.length
|
||||
? Math.max(
|
||||
...allowed.map((wt) =>
|
||||
bulkTonsPerWagon(cargoType, wt.id, Number(wt.capacityTons)),
|
||||
),
|
||||
)
|
||||
: null;
|
||||
delete cargoType.wagonTypes;
|
||||
}
|
||||
|
||||
// Surface the staff "request changes" note so the portal can show the
|
||||
// customer what to fix. Degrade to null on lookup failure — a missing note
|
||||
// must never 500 a contract fetch.
|
||||
|
||||
@@ -39,6 +39,14 @@ export class ScheduleWagonAdjustmentLog extends BaseEntity {
|
||||
@Column({ name: 'yard_id', type: 'uuid', nullable: true })
|
||||
yardId!: string | null;
|
||||
|
||||
/**
|
||||
* Why the wagon left/joined the consist. Required by the builder for a
|
||||
* detach or maintenance move on a SCHEDULED run (that reason replaced the
|
||||
* old second-staff approval); null for every other adjustment.
|
||||
*/
|
||||
@Column({ name: 'reason', type: 'varchar', length: 500, nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ name: 'occurred_at', type: 'timestamptz', default: () => 'now()' })
|
||||
occurredAt!: Date;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,20 @@ export class WagonBookingAllocation extends BaseEntity {
|
||||
@Column({ name: 'confirmed_by_user_id', type: 'uuid', nullable: true })
|
||||
confirmedByUserId?: string | null;
|
||||
|
||||
/** Per-wagon loading confirmation (status LOADED) — when and by whom. */
|
||||
@Column({ name: 'loaded_at', type: 'timestamptz', nullable: true })
|
||||
loadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_by_user_id', type: 'uuid', nullable: true })
|
||||
loadedByUserId?: string | null;
|
||||
|
||||
/** Per-wagon unloading confirmation (status DEPARTED) — when and by whom. */
|
||||
@Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true })
|
||||
unloadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'unloaded_by_user_id', type: 'uuid', nullable: true })
|
||||
unloadedByUserId?: string | null;
|
||||
|
||||
@OneToMany(() => WagonAllocationContainerItem, (item) => item.allocation)
|
||||
containerItems?: WagonAllocationContainerItem[];
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
NotFoundException,
|
||||
Optional,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In } from 'typeorm';
|
||||
import { Freight } from '@edr/types';
|
||||
@@ -72,6 +72,117 @@ export class BookingJourneyService {
|
||||
|
||||
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingLoadable(schedule, booking);
|
||||
return this.completeLoad(schedule, booking, userId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm ONE wagon of the booking loaded (per-wagon loading). The booking
|
||||
* stays PAID while wagons remain; loading the last remaining wagon runs the
|
||||
* whole-booking completion (IN_TRANSIT, warehouse inventory, GRN,
|
||||
* milestones) exactly as the one-shot load does. Wagons that will NOT ride
|
||||
* must be cancelled via the at-loading cancellation before the booking can
|
||||
* complete (and before the train may dispatch).
|
||||
*/
|
||||
async loadWagon(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingLoadable(schedule, booking);
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (!allocations.length) {
|
||||
throw new BadRequestException(
|
||||
'This booking has no wagon allocations on the schedule — use the whole-booking load.',
|
||||
);
|
||||
}
|
||||
const target = allocations.find((a) => a.id === allocationId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
|
||||
}
|
||||
if (target.status === 'LOADED' || target.status === 'DEPARTED') {
|
||||
throw new BadRequestException('This wagon is already loaded');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WagonBookingAllocation).update(target.id, {
|
||||
status: 'LOADED',
|
||||
loadedAt: now,
|
||||
loadedByUserId: userId ?? null,
|
||||
});
|
||||
if (!booking.loadingStartedAt) {
|
||||
await manager
|
||||
.getRepository(Booking)
|
||||
.update(bookingId, { loadingStartedAt: now } as never);
|
||||
}
|
||||
// PARTIAL keeps the dispatch-readiness badge honest; completion below
|
||||
// flips it to LOADED.
|
||||
await manager
|
||||
.getRepository(TrainScheduleBooking)
|
||||
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'PARTIAL' });
|
||||
});
|
||||
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.id !== target.id && a.status !== 'LOADED' && a.status !== 'DEPARTED',
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
const done = await this.completeLoad(schedule, booking, userId ?? null);
|
||||
return {
|
||||
...done,
|
||||
allocationId,
|
||||
loadedWagons: allocations.length,
|
||||
totalWagons: allocations.length,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bookingId,
|
||||
allocationId,
|
||||
status: booking.status,
|
||||
loadedWagons: allocations.length - remaining,
|
||||
totalWagons: allocations.length,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The at-loading cancel shrank the booking to its loaded wagons — if every
|
||||
* wagon left on it is LOADED, the load is complete: run the whole-booking
|
||||
* completion. Fired by BookingWagonCancellationService.cancelRemainingAtLoading.
|
||||
*/
|
||||
@OnEvent('booking.wagonsCancelledAtLoading')
|
||||
async onWagonsCancelledAtLoading(payload: {
|
||||
bookingId: string;
|
||||
scheduleId: string;
|
||||
userId?: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
payload.scheduleId,
|
||||
payload.bookingId,
|
||||
);
|
||||
const loaded = allocations.filter(
|
||||
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
|
||||
).length;
|
||||
if (!allocations.length || loaded < allocations.length) return;
|
||||
await this.loadBooking(payload.scheduleId, payload.bookingId, payload.userId);
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Post-cancel load completion failed for booking ${payload.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** The pre-load gates shared by whole-booking and per-wagon loading. */
|
||||
private async assertBookingLoadable(schedule: TrainSchedule, booking: Booking): Promise<void> {
|
||||
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
|
||||
throw new BadRequestException('Booking is already loaded');
|
||||
}
|
||||
@@ -86,6 +197,16 @@ export class BookingJourneyService {
|
||||
// Export cargo must be in the warehouse with a GRN before it can be loaded,
|
||||
// however it arrived and whatever it is allocated to.
|
||||
await assertExportReceivedWithGrn(this.dataSource, booking);
|
||||
}
|
||||
|
||||
/** The whole-booking load side effects — gates already passed. */
|
||||
private async completeLoad(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
userId: string | null,
|
||||
) {
|
||||
const scheduleId = schedule.id;
|
||||
const bookingId = booking.id;
|
||||
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
|
||||
// handover moment — the carriage acceptance sheet must go out to the
|
||||
// customer right here, not on a receive event that will never fire.
|
||||
@@ -165,6 +286,77 @@ export class BookingJourneyService {
|
||||
|
||||
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingUnloadable(schedule, booking);
|
||||
return this.completeUnload(schedule, booking, userId ?? null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm ONE wagon of the booking unloaded (per-wagon unloading). Tracking
|
||||
* only while wagons remain on the train — the booking stays IN_TRANSIT;
|
||||
* unloading the last wagon runs the whole-booking completion (ARRIVED/
|
||||
* COMPLETED, wagon settlement, events) exactly as the one-shot unload does.
|
||||
*/
|
||||
async unloadWagon(
|
||||
scheduleId: string,
|
||||
bookingId: string,
|
||||
allocationId: string,
|
||||
userId?: string | null,
|
||||
) {
|
||||
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
|
||||
await this.assertBookingUnloadable(schedule, booking);
|
||||
const allocations = await this.allocationsForBooking(
|
||||
this.dataSource.manager,
|
||||
scheduleId,
|
||||
bookingId,
|
||||
);
|
||||
if (!allocations.length) {
|
||||
throw new BadRequestException(
|
||||
'This booking has no wagon allocations on the schedule — use the whole-booking unload.',
|
||||
);
|
||||
}
|
||||
const target = allocations.find((a) => a.id === allocationId);
|
||||
if (!target) {
|
||||
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
|
||||
}
|
||||
if (target.status === 'DEPARTED') {
|
||||
throw new BadRequestException('This wagon is already unloaded');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.getRepository(WagonBookingAllocation).update(target.id, {
|
||||
status: 'DEPARTED',
|
||||
unloadedAt: now,
|
||||
unloadedByUserId: userId ?? null,
|
||||
});
|
||||
|
||||
const remaining = allocations.filter(
|
||||
(a) => a.id !== target.id && a.status !== 'DEPARTED',
|
||||
).length;
|
||||
if (remaining === 0) {
|
||||
const done = await this.completeUnload(schedule, booking, userId ?? null);
|
||||
return {
|
||||
...done,
|
||||
allocationId,
|
||||
unloadedWagons: allocations.length,
|
||||
totalWagons: allocations.length,
|
||||
completed: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
bookingId,
|
||||
allocationId,
|
||||
status: booking.status,
|
||||
unloadedWagons: allocations.length - remaining,
|
||||
totalWagons: allocations.length,
|
||||
completed: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** The pre-unload gates shared by whole-booking and per-wagon unloading. */
|
||||
private async assertBookingUnloadable(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
): Promise<void> {
|
||||
if (booking.status !== 'IN_TRANSIT') {
|
||||
throw new BadRequestException(
|
||||
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
|
||||
@@ -173,7 +365,16 @@ export class BookingJourneyService {
|
||||
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
|
||||
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
|
||||
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
|
||||
}
|
||||
|
||||
/** The whole-booking unload side effects — gates already passed. */
|
||||
private async completeUnload(
|
||||
schedule: TrainSchedule,
|
||||
booking: Booking,
|
||||
userId: string | null,
|
||||
) {
|
||||
const scheduleId = schedule.id;
|
||||
const bookingId = booking.id;
|
||||
// Intercity has no clearance/delivery tail — unloading completes it. Import/
|
||||
// export continue into clearance, keyed on the booking's own arrival.
|
||||
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
|
||||
@@ -498,7 +699,17 @@ export class BookingJourneyService {
|
||||
.findOne({ where: { id: bookingId } });
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (booking.trainScheduleId !== scheduleId) {
|
||||
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||
// The schedule↔booking LINK is the same authority the workspace list
|
||||
// (listYardWork) renders from — some flows (export train pick) create it
|
||||
// with wagon allocations before bookings.train_schedule_id is stamped.
|
||||
// Trusting only the column made those rows show a Load button that
|
||||
// always 400'd.
|
||||
const linked = await this.dataSource.getRepository(TrainScheduleBooking).findOne({
|
||||
where: { trainScheduleId: scheduleId, bookingId },
|
||||
});
|
||||
if (!linked) {
|
||||
throw new BadRequestException('Booking is not assigned to this schedule');
|
||||
}
|
||||
}
|
||||
return { schedule, booking };
|
||||
}
|
||||
|
||||
@@ -707,6 +707,46 @@ export class TrainSchedulingController {
|
||||
return this.bookingJourneyService.unloadBooking(id, bookingId);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/load")
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm ONE wagon of the booking loaded (per-wagon loading). The booking stays PAID until every remaining wagon is LOADED; the last wagon runs the whole-booking load completion.",
|
||||
})
|
||||
loadScheduleBookingWagon(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Param("allocationId", ParseUUIDPipe) allocationId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.loadWagon(
|
||||
id,
|
||||
bookingId,
|
||||
allocationId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/unload")
|
||||
@TrainSchedulingUnload()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Confirm ONE wagon of the booking unloaded (per-wagon unloading). The booking stays IN_TRANSIT until the last wagon, which runs the whole-booking unload completion.",
|
||||
})
|
||||
unloadScheduleBookingWagon(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Param("bookingId", ParseUUIDPipe) bookingId: string,
|
||||
@Param("allocationId", ParseUUIDPipe) allocationId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.bookingJourneyService.unloadWagon(
|
||||
id,
|
||||
bookingId,
|
||||
allocationId,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/intercity/:bookingId/load")
|
||||
@TrainSchedulingLoad()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -2946,6 +2946,10 @@ export class TrainSchedulingService {
|
||||
// The yard plan this departure was SOLD against must match where the steel
|
||||
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
|
||||
await this.assertPlannedYardsAligned(schedule);
|
||||
// Per-wagon loading: a booking mid-load is neither ridable nor removable —
|
||||
// every wagon must be LOADED, or the never-loaded remainder cancelled
|
||||
// (at-loading cancellation), before the train departs.
|
||||
await this.assertNoPartiallyLoadedBookings(schedule);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
const trainNumber = await this.assignTrainNumber(manager, schedule);
|
||||
@@ -3144,6 +3148,42 @@ export class TrainSchedulingService {
|
||||
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
|
||||
* (their charge sits on the credit ledger) yet ride from accept.
|
||||
*/
|
||||
/**
|
||||
* Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
|
||||
* SOME still PLANNED/RESERVED must resolve before departure — load the rest
|
||||
* or cancel it (which shrinks the booking to its loaded wagons). Blocking
|
||||
* here beats silently unassigning: unassign would delete LOADED allocations
|
||||
* and strand cargo that is physically on the train.
|
||||
*/
|
||||
private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise<void> {
|
||||
if (!schedule.trainSetId) return;
|
||||
const rows: Array<{ reference: string; loaded: string; total: string }> =
|
||||
await this.dataSource.query(
|
||||
`SELECT b.reference,
|
||||
COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded,
|
||||
COUNT(*) AS total
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
|
||||
JOIN freight.bookings b ON b.id = a.booking_id
|
||||
WHERE tsw.train_set_id = $1
|
||||
AND a.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
AND b.deleted_at IS NULL
|
||||
GROUP BY b.id, b.reference
|
||||
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
|
||||
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
|
||||
[schedule.trainSetId],
|
||||
);
|
||||
if (rows.length) {
|
||||
const detail = rows
|
||||
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async unloadedOriginBoarderIds(
|
||||
scheduleId: string,
|
||||
originYardId: string,
|
||||
@@ -3157,6 +3197,7 @@ export class TrainSchedulingService {
|
||||
AND b.deleted_at IS NULL
|
||||
AND b.origin_yard_id = $2
|
||||
AND b.loaded_at IS NULL
|
||||
AND b.loading_started_at IS NULL
|
||||
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
|
||||
AND b.is_government = false
|
||||
AND (b.status = 'PAID'
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
export class SendWagonToMaintenanceDto {
|
||||
@ApiPropertyOptional({
|
||||
@ApiProperty({
|
||||
description:
|
||||
"Why the wagon is going to maintenance. Stored on the wagon's status-history " +
|
||||
'log alongside the train it was detached from, matching the fleet desk flow.',
|
||||
"Why the wagon is going to maintenance — required. Stored on the wagon's " +
|
||||
'status-history log alongside the train it was detached from, and on the ' +
|
||||
"train's wagon-adjustment history.",
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
note!: string;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
|
||||
|
||||
import { WagonDetachRequestAction } from '../entities/wagon-detach-request.entity';
|
||||
|
||||
export class CreateWagonDetachRequestDto {
|
||||
/**
|
||||
* Detach a wagon from the consist. The reason is always required — it is
|
||||
* recorded both as an auto-approved wagon_detach_requests audit row and on the
|
||||
* train's wagon-adjustment history (the History tab).
|
||||
*/
|
||||
export class DetachWagonDto {
|
||||
@ApiProperty({
|
||||
enum: WagonDetachRequestAction,
|
||||
description: 'What approval is being asked for: a plain detach, or detach + MAINTENANCE.',
|
||||
})
|
||||
@IsEnum(WagonDetachRequestAction)
|
||||
action!: WagonDetachRequestAction;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'Why the wagon must leave the scheduled consist. Shown to the approver.',
|
||||
description: 'Why the wagon leaves the consist — required.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsString()
|
||||
@@ -20,14 +16,3 @@ export class CreateWagonDetachRequestDto {
|
||||
@MaxLength(500)
|
||||
reason!: string;
|
||||
}
|
||||
|
||||
export class DecideWagonDetachRequestDto {
|
||||
@ApiPropertyOptional({
|
||||
description: 'Decision note — required when rejecting, optional when approving.',
|
||||
maxLength: 500,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
@@ -25,10 +25,7 @@ import { BuildTrainDto } from './dto/build-train.dto';
|
||||
import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { SendWagonToMaintenanceDto } from './dto/send-wagon-to-maintenance.dto';
|
||||
import {
|
||||
CreateWagonDetachRequestDto,
|
||||
DecideWagonDetachRequestDto,
|
||||
} from './dto/wagon-detach-request.dto';
|
||||
import { DetachWagonDto } from './dto/wagon-detach-request.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { UpdateTrainYardDto } from './dto/update-train-yard.dto';
|
||||
@@ -51,7 +48,6 @@ import { TrainBuilderService } from './train-builder.service';
|
||||
FREIGHT_PERMS.trains.changeWagonYard,
|
||||
FREIGHT_PERMS.trains.toggleActive,
|
||||
FREIGHT_PERMS.trains.disband,
|
||||
FREIGHT_PERMS.trains.approveWagonDetach,
|
||||
])
|
||||
export class TrainBuilderController {
|
||||
constructor(private readonly trainBuilderService: TrainBuilderService) {}
|
||||
@@ -185,29 +181,38 @@ export class TrainBuilderController {
|
||||
|
||||
@Delete(':id/wagons/:wagonId')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist' })
|
||||
@ApiOperation({ summary: 'Detach one wagon from the consist — a reason is required' })
|
||||
removeWagon(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DetachWagonDto,
|
||||
) {
|
||||
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
|
||||
return this.trainBuilderService.removeWagon(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto.reason,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/maintenance')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Detach one wagon and move it to MAINTENANCE status' })
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Detach one wagon and move it to MAINTENANCE status — a reason (note) is required',
|
||||
})
|
||||
sendWagonToMaintenance(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: SendWagonToMaintenanceDto,
|
||||
@Body() dto: SendWagonToMaintenanceDto,
|
||||
) {
|
||||
return this.trainBuilderService.sendWagonToMaintenance(
|
||||
id,
|
||||
wagonId,
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,65 +225,6 @@ export class TrainBuilderController {
|
||||
return this.trainBuilderService.listDetachRequests(id);
|
||||
}
|
||||
|
||||
@Post(':id/wagons/:wagonId/detach-requests')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Request approval to detach a wagon (or send it to maintenance) while the train is on a SCHEDULED run',
|
||||
})
|
||||
createDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('wagonId', ParseUUIDPipe) wagonId: string,
|
||||
@Body() dto: CreateWagonDetachRequestDto,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
) {
|
||||
return this.trainBuilderService.createDetachRequest(
|
||||
id,
|
||||
wagonId,
|
||||
dto,
|
||||
resolveAuthUserId(user),
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/approve')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Approve a detach/maintenance request — the detach executes immediately; the approver must not be the requester',
|
||||
})
|
||||
approveDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto?: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'APPROVE',
|
||||
resolveAuthUserId(user),
|
||||
dto?.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/detach-requests/:requestId/reject')
|
||||
@FleetManage(FREIGHT_PERMS.trains.approveWagonDetach)
|
||||
@ApiOperation({ summary: 'Reject a detach/maintenance request — a note explaining why is required' })
|
||||
rejectDetachRequest(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Param('requestId', ParseUUIDPipe) requestId: string,
|
||||
@CurrentUser() user: AuthUserPayload,
|
||||
@Body() dto: DecideWagonDetachRequestDto,
|
||||
) {
|
||||
return this.trainBuilderService.decideDetachRequest(
|
||||
id,
|
||||
requestId,
|
||||
'REJECT',
|
||||
resolveAuthUserId(user),
|
||||
dto.note,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/reorder-wagons')
|
||||
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
|
||||
@ApiOperation({ summary: 'Persist a drag-reorder of the full consist' })
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { TrainBuilderService } from './train-builder.service';
|
||||
import { WagonDetachRequestAction } from './entities/wagon-detach-request.entity';
|
||||
|
||||
/**
|
||||
* Every detach / send-to-maintenance carries a reason — scheduled run or not
|
||||
* (the reason replaced the old second-staff approval). The recorder rejects a
|
||||
* missing/blank one before anything is written, and stores a trimmed, capped
|
||||
* copy on the audit row otherwise.
|
||||
*/
|
||||
describe('TrainBuilderService — detach reason is always required', () => {
|
||||
const svc = Object.create(TrainBuilderService.prototype) as {
|
||||
recordDetachReason(
|
||||
manager: unknown,
|
||||
trainId: string,
|
||||
wagonId: string,
|
||||
action: WagonDetachRequestAction,
|
||||
reason: string | null | undefined,
|
||||
userId?: string | null,
|
||||
): Promise<void>;
|
||||
};
|
||||
|
||||
/** Minimal EntityManager: records what the recorder would persist. */
|
||||
const managerSpy = () => {
|
||||
const saved: Array<Record<string, unknown>> = [];
|
||||
return {
|
||||
saved,
|
||||
getRepository: (entity: { name: string }) =>
|
||||
entity.name === 'Wagon'
|
||||
? { findOne: async () => ({ wagonNumber: 'NW5-0412' }) }
|
||||
: {
|
||||
create: (row: Record<string, unknown>) => row,
|
||||
save: async (row: Record<string, unknown>) => {
|
||||
saved.push(row);
|
||||
return row;
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it.each([undefined, null, '', ' '])('refuses a blank reason (%p)', async (reason) => {
|
||||
const manager = managerSpy();
|
||||
await expect(
|
||||
svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Detach,
|
||||
reason,
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
// Nothing is written when the reason is missing.
|
||||
expect(manager.saved).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('records the reason as an auto-approved audit row', async () => {
|
||||
const manager = managerSpy();
|
||||
await svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Maintenance,
|
||||
' Brake shoe worn through ',
|
||||
'user-1',
|
||||
);
|
||||
expect(manager.saved).toHaveLength(1);
|
||||
const row = manager.saved[0];
|
||||
expect(row).toMatchObject({
|
||||
trainId: 'train-1',
|
||||
wagonId: 'wagon-1',
|
||||
wagonNumber: 'NW5-0412',
|
||||
action: WagonDetachRequestAction.Maintenance,
|
||||
reason: 'Brake shoe worn through',
|
||||
// No second person: the actor is both requester and decider.
|
||||
status: 'APPROVED',
|
||||
requestedBy: 'user-1',
|
||||
decidedBy: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('caps an over-long reason at the column width', async () => {
|
||||
const manager = managerSpy();
|
||||
await svc.recordDetachReason(
|
||||
manager,
|
||||
'train-1',
|
||||
'wagon-1',
|
||||
WagonDetachRequestAction.Detach,
|
||||
'x'.repeat(900),
|
||||
null,
|
||||
);
|
||||
expect(String(manager.saved[0].reason)).toHaveLength(500);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,6 @@ import { ListBuiltTrainsQueryDto } from './dto/list-built-trains-query.dto';
|
||||
import { ReorderTrainWagonsDto } from './dto/reorder-train-wagons.dto';
|
||||
import { UpdateTrainDetailsDto } from './dto/update-train-details.dto';
|
||||
import { UpdateTrainLocomotivesDto } from './dto/update-train-locomotives.dto';
|
||||
import { CreateWagonDetachRequestDto } from './dto/wagon-detach-request.dto';
|
||||
import { TrainLocomotive } from './entities/train-locomotive.entity';
|
||||
import { Train } from './entities/train.entity';
|
||||
import {
|
||||
@@ -253,6 +252,7 @@ export class TrainBuilderService {
|
||||
yardLabel: string | null;
|
||||
actor: string | null;
|
||||
scheduleReference: string | null;
|
||||
reason: string | null;
|
||||
occurredAt: Date;
|
||||
}>,
|
||||
] = await Promise.all([
|
||||
@@ -270,6 +270,7 @@ export class TrainBuilderService {
|
||||
COALESCE(y.label, y.code) AS "yardLabel",
|
||||
COALESCE(u.username, u.email) AS "actor",
|
||||
ts.reference AS "scheduleReference",
|
||||
l.reason,
|
||||
l.occurred_at AS "occurredAt"
|
||||
FROM freight.schedule_wagon_adjustment_logs l
|
||||
LEFT JOIN freight.yards y ON y.id = l.yard_id
|
||||
@@ -491,8 +492,9 @@ export class TrainBuilderService {
|
||||
: null,
|
||||
},
|
||||
activeSchedules: schedules,
|
||||
// Composition is frozen while the train is out on a dispatched run.
|
||||
editable: !schedules.some((s) => s.status === 'DISPATCHED'),
|
||||
// The built train is always editable — dispatched/arrived runs render from
|
||||
// their frozen snapshot, so consist edits reach only DRAFT/SCHEDULED runs.
|
||||
editable: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -754,22 +756,40 @@ export class TrainBuilderService {
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Detach one wagon and close the sequence gap it leaves. */
|
||||
async removeWagon(id: string, wagonId: string, userId?: string | null) {
|
||||
/**
|
||||
* Detach one wagon and close the sequence gap it leaves. On a SCHEDULED run
|
||||
* the detach still executes directly, but a reason is required and recorded
|
||||
* in wagon_detach_requests (auto-approved) — the audit trail without the
|
||||
* former second-staff approval step.
|
||||
*/
|
||||
async removeWagon(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
reason?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId);
|
||||
await this.recordDetachReason(
|
||||
manager,
|
||||
id,
|
||||
wagonId,
|
||||
WagonDetachRequestAction.Detach,
|
||||
reason,
|
||||
userId,
|
||||
);
|
||||
return this.removeWagonCore(manager, id, wagonId, userId, reason);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/** Transactional body of removeWagon — also runs under an approved detach request. */
|
||||
/** Transactional body of removeWagon — `reason` rides into the history log. */
|
||||
private async removeWagonCore(
|
||||
manager: EntityManager,
|
||||
id: string,
|
||||
wagonId: string,
|
||||
userId?: string | null,
|
||||
reason?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
@@ -791,6 +811,7 @@ export class TrainBuilderService {
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
wagon.currentYardId ?? train.currentYardId ?? null,
|
||||
reason,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -805,8 +826,16 @@ export class TrainBuilderService {
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
// `note` is the required reason — recordDetachReason rejects it empty.
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
await this.assertDetachNeedsNoApproval(manager, id);
|
||||
await this.recordDetachReason(
|
||||
manager,
|
||||
id,
|
||||
wagonId,
|
||||
WagonDetachRequestAction.Maintenance,
|
||||
note,
|
||||
userId,
|
||||
);
|
||||
return this.sendWagonToMaintenanceCore(manager, id, wagonId, userId, note);
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
@@ -883,96 +912,54 @@ export class TrainBuilderService {
|
||||
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
|
||||
userId ?? null,
|
||||
yardId,
|
||||
note,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Direct-detach guard: while this train carries a live SCHEDULED run,
|
||||
* removing a wagon changes a departure customers already booked against, so
|
||||
* it is a two-person action — refuse here and point at the request flow.
|
||||
* DRAFT stays freely editable; DISPATCHED is already frozen by
|
||||
* getEditableTrain (the train is IN_SERVICE).
|
||||
* Every detach / send-to-maintenance carries a REASON — scheduled run or
|
||||
* not — and an auto-approved wagon_detach_requests row records who did it
|
||||
* and why (the audit trail that replaced the former second-staff approval).
|
||||
* DISPATCHED trains never reach here: getEditableTrain freezes them.
|
||||
*/
|
||||
private async assertDetachNeedsNoApproval(
|
||||
private async recordDetachReason(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<void> {
|
||||
const scheduled = await this.findScheduledRun(manager, trainId);
|
||||
if (scheduled) {
|
||||
throw new ConflictException(
|
||||
`Train is on scheduled run ${scheduled.reference ?? scheduled.id} — detaching a wagon needs an approved detach request`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async findScheduledRun(
|
||||
manager: EntityManager,
|
||||
trainId: string,
|
||||
): Promise<{ id: string; reference: string | null } | null> {
|
||||
const rows: { id: string; reference: string | null }[] = await manager.query(
|
||||
`SELECT ts.id, ts.reference
|
||||
FROM freight.train_schedules ts
|
||||
JOIN freight.train_sets tset ON tset.id = ts.train_set_id
|
||||
WHERE tset.train_id = $1
|
||||
AND ts.status = 'SCHEDULED'
|
||||
AND ts.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[trainId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* File a detach/maintenance approval request for a wagon on a SCHEDULED
|
||||
* train. The request carries the reason; a different staffer with
|
||||
* trains:approve_wagon_detach decides it (approval executes the detach).
|
||||
*/
|
||||
async createDetachRequest(
|
||||
id: string,
|
||||
wagonId: string,
|
||||
dto: CreateWagonDetachRequestDto,
|
||||
action: WagonDetachRequestAction,
|
||||
reason: string | null | undefined,
|
||||
userId?: string | null,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const train = await this.getEditableTrain(manager, id);
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
if (!wagon || wagon.trainId !== train.id) {
|
||||
throw new NotFoundException(`Wagon ${wagonId} is not part of this train`);
|
||||
}
|
||||
const scheduled = await this.findScheduledRun(manager, train.id);
|
||||
if (!scheduled) {
|
||||
throw new ConflictException(
|
||||
'This train has no SCHEDULED run — detach the wagon directly, no approval needed',
|
||||
);
|
||||
}
|
||||
// Refuse up front what an approval could never execute (booked
|
||||
// allocations pin the wagon) — but release nothing yet: slots are only
|
||||
// touched when the approved detach actually runs.
|
||||
await this.assertDetachableAndReleaseStaleSlots(manager, wagon, { checkOnly: true });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const open = await repo.findOne({
|
||||
where: { trainId: train.id, wagonId: wagon.id, status: WagonDetachRequestStatus.Pending },
|
||||
});
|
||||
if (open) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} already has a pending detach request`,
|
||||
);
|
||||
}
|
||||
return repo.save(
|
||||
repo.create({
|
||||
trainId: train.id,
|
||||
wagonId: wagon.id,
|
||||
wagonNumber: wagon.wagonNumber,
|
||||
action: dto.action,
|
||||
reason: dto.reason.trim(),
|
||||
requestedBy: userId ?? null,
|
||||
}),
|
||||
): Promise<void> {
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) {
|
||||
throw new BadRequestException(
|
||||
`Give a reason for ${
|
||||
action === WagonDetachRequestAction.Maintenance
|
||||
? 'sending this wagon to maintenance'
|
||||
: 'detaching this wagon'
|
||||
}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const now = new Date();
|
||||
await repo.save(
|
||||
repo.create({
|
||||
trainId,
|
||||
wagonId,
|
||||
wagonNumber: wagon?.wagonNumber ?? wagonId,
|
||||
action,
|
||||
reason: trimmed.slice(0, 500),
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
requestedBy: userId ?? null,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: now,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** All detach/maintenance requests of this train, newest first — the approval audit trail. */
|
||||
/** All detach/maintenance records of this train, newest first — the audit trail. */
|
||||
async listDetachRequests(trainId: string) {
|
||||
const rows: Array<{
|
||||
id: string;
|
||||
@@ -1012,68 +999,6 @@ export class TrainBuilderService {
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a pending request. Approve executes the detach (or maintenance
|
||||
* move) in the same transaction that stamps the decision, so an approved row
|
||||
* can never exist without its detach having happened. The requester cannot
|
||||
* approve their own request; a rejection must carry a note.
|
||||
*/
|
||||
async decideDetachRequest(
|
||||
id: string,
|
||||
requestId: string,
|
||||
decision: 'APPROVE' | 'REJECT',
|
||||
userId?: string | null,
|
||||
note?: string | null,
|
||||
) {
|
||||
const pending = await this.dataSource.transaction(async (manager) => {
|
||||
const repo = manager.getRepository(WagonDetachRequest);
|
||||
const request = await repo.findOne({
|
||||
where: { id: requestId, trainId: id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!request) {
|
||||
throw new NotFoundException(`Detach request ${requestId} not found on this train`);
|
||||
}
|
||||
if (request.status !== WagonDetachRequestStatus.Pending) {
|
||||
throw new ConflictException(
|
||||
`This request was already ${request.status.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
const decisionNote = note?.trim() || null;
|
||||
if (decision === 'REJECT') {
|
||||
if (!decisionNote) {
|
||||
throw new BadRequestException('A note explaining the rejection is required');
|
||||
}
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Rejected,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
// The 4-eyes point of the gate: requester and approver are different people.
|
||||
if (request.requestedBy && userId && request.requestedBy === userId) {
|
||||
throw new ConflictException(
|
||||
'You filed this request — a different staff member must approve it',
|
||||
);
|
||||
}
|
||||
const pendingCheck =
|
||||
request.action === WagonDetachRequestAction.Maintenance
|
||||
? await this.sendWagonToMaintenanceCore(manager, id, request.wagonId, userId, request.reason)
|
||||
: await this.removeWagonCore(manager, id, request.wagonId, userId);
|
||||
await repo.update(request.id, {
|
||||
status: WagonDetachRequestStatus.Approved,
|
||||
decidedBy: userId ?? null,
|
||||
decidedAt: new Date(),
|
||||
decisionNote,
|
||||
});
|
||||
return pendingCheck;
|
||||
});
|
||||
await this.reconcileWindowAfterConsistChange(pending);
|
||||
return this.getComposition(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule occupancy lives on TrainSetWagon slots (per-schedule snapshot),
|
||||
* not on the Wagon entity — a wagon is busy when any live (DRAFT/SCHEDULED/
|
||||
@@ -1113,6 +1038,9 @@ export class TrainBuilderService {
|
||||
wagon: Wagon,
|
||||
opts: { checkOnly?: boolean } = {},
|
||||
): Promise<void> {
|
||||
// Only DRAFT/SCHEDULED runs still follow the live consist, so only they can
|
||||
// pin a wagon. A DISPATCHED/ARRIVED run reads its frozen snapshot and is
|
||||
// unaffected by what happens to the physical train behind it.
|
||||
const rows: { id: string; train_set_id: string; status: string; allocs: string }[] =
|
||||
await manager.query(
|
||||
`SELECT tsw.id, tsw.train_set_id, ts.status,
|
||||
@@ -1123,15 +1051,18 @@ export class TrainBuilderService {
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = $1
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED', 'DISPATCHED')
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL`,
|
||||
[wagon.id],
|
||||
);
|
||||
if (!rows.length) return;
|
||||
if (rows.some((r) => Number(r.allocs) > 0 || r.status === 'DISPATCHED')) {
|
||||
// Cargo already allocated to a live run keeps its wagon: the booking must be
|
||||
// unassigned from the slot first, so a customer's shipment can never lose its
|
||||
// wagon as a side effect of editing the train.
|
||||
if (rows.some((r) => Number(r.allocs) > 0)) {
|
||||
throw new ConflictException(
|
||||
`Wagon ${wagon.wagonNumber} is pinned to an active schedule and cannot be removed`,
|
||||
`Wagon ${wagon.wagonNumber} is carrying cargo on a live schedule — unassign its bookings before removing it`,
|
||||
);
|
||||
}
|
||||
if (opts.checkOnly) return;
|
||||
@@ -1162,25 +1093,9 @@ export class TrainBuilderService {
|
||||
if (current.size !== incoming.size || [...current].some((wid) => !incoming.has(wid))) {
|
||||
throw new BadRequestException('Reorder must include every wagon of the train exactly once');
|
||||
}
|
||||
// Only a rolling train is frozen. Pre-dispatch (DRAFT/SCHEDULED) reorder
|
||||
// is allowed — the pinned schedules' consists are resequenced below so
|
||||
// they can never desync from the built train's real order.
|
||||
const dispatched: { exists: boolean }[] = await manager.query(
|
||||
`SELECT TRUE AS exists
|
||||
FROM freight.train_set_wagons tsw
|
||||
JOIN freight.train_schedules ts ON ts.train_set_id = tsw.train_set_id
|
||||
WHERE tsw.physical_wagon_id = ANY($1::uuid[])
|
||||
AND ts.status = 'DISPATCHED'
|
||||
AND ts.deleted_at IS NULL
|
||||
AND tsw.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[[...current]],
|
||||
);
|
||||
if (dispatched.length > 0) {
|
||||
throw new ConflictException(
|
||||
'This train is dispatched — wagons cannot be reordered while it is rolling.',
|
||||
);
|
||||
}
|
||||
// Reorder is allowed at any time, dispatched runs included: a DISPATCHED
|
||||
// schedule renders the order frozen in its snapshot, and only the
|
||||
// DRAFT/SCHEDULED consists resequenced below follow the built train.
|
||||
for (let i = 0; i < dto.wagonIds.length; i++) {
|
||||
await manager.getRepository(Wagon).update(dto.wagonIds[i], { sequenceNumber: i + 1 });
|
||||
}
|
||||
@@ -1402,6 +1317,7 @@ export class TrainBuilderService {
|
||||
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
|
||||
userId: string | null,
|
||||
yardId: string | null,
|
||||
reason?: string | null,
|
||||
): Promise<PendingWindowCheck | null> {
|
||||
if (!changes.length) return null;
|
||||
const trainSet = await manager
|
||||
@@ -1443,6 +1359,7 @@ export class TrainBuilderService {
|
||||
wagonNumber: c.wagonNumber,
|
||||
adjustedByUserId: userId,
|
||||
yardId,
|
||||
reason: reason?.trim() || null,
|
||||
occurredAt: now,
|
||||
}),
|
||||
),
|
||||
@@ -1481,17 +1398,21 @@ export class TrainBuilderService {
|
||||
}
|
||||
|
||||
/** Load + freeze the train row for edit; block edits while it is out on a run. */
|
||||
/**
|
||||
* The built train is editable at ANY time, including while it is out on a
|
||||
* dispatched run. A dispatched/arrived schedule froze its own wagon plan into
|
||||
* `wagonAllocationSnapshot` at the transition and renders from that, so it can
|
||||
* never be disturbed by later consist edits; only DRAFT/SCHEDULED runs follow
|
||||
* the live train (see syncLiveScheduleAfterConsistChange). Per-wagon safety
|
||||
* still applies — assertDetachableAndReleaseStaleSlots refuses to pull a wagon
|
||||
* whose cargo is allocated to a live run.
|
||||
*/
|
||||
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
|
||||
const train = await manager.getRepository(Train).findOne({
|
||||
where: { id },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!train) throw new NotFoundException(`Train ${id} not found`);
|
||||
if (train.status === Freight.TrainStatus.InService) {
|
||||
throw new ConflictException(
|
||||
`Train ${train.code} is out on a dispatched run; its composition is frozen until arrival`,
|
||||
);
|
||||
}
|
||||
return train;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user