feat(train-scheduling): mid-route consist changes, audit history, safer workspace

- planned couples: loose wagons join the train at a route stop, added
  from the schedule yards tab; capacity credits them per corridor edge
  and coupling validates locomotive weight/length caps per leg
- real-cut toggle: a cut wagon permanently leaves the train build at
  its cut yard (soft cut still sits out one trip only)
- fix heaviest-leg display counting a shared slot's full cargo on
  every spanned edge (phantom pull-weight overload on S-2026-00045)
- confirmation dialogs for workspace add/load/unload/remove actions
- train-builder History and Detached-wagons tabs, backed by paginated
  endpoints; builder detaches now always write adjustment-log rows

Migrations 3660 (planned_wagon_couples, planned_wagon_real_cuts) and
3670 (adjustment log train_schedule_id nullable) — both applied to the
dev DB by hand; watch mode does not run migrations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Marshal
2026-08-23 03:55:54 +00:00
parent ba89b670c8
commit 8e6fc09aac
34 changed files with 2532 additions and 202 deletions

View File

@@ -140,6 +140,14 @@ export class ContractBookingService {
dto: CreateBookingUnderContractDto,
user?: { id?: string } | null,
actorPermissions?: unknown,
opts?: {
/**
* Wagon-cancellation credit rebook only: the freight was paid while the
* contract was live, so redeeming the credit is allowed even after the
* contract's validity lapsed. Never set for a genuinely new booking.
*/
allowExpiredContract?: boolean;
},
): Promise<CreateBookingUnderContractResult> {
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
@@ -180,8 +188,13 @@ export class ContractBookingService {
actorPermissions != null &&
hasFreightPermission(actorPermissions, FREIGHT_PERMS.contracts.createBooking);
await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(contract, isGlActor);
if (!opts?.allowExpiredContract) await this.assertNotExpired(contract);
const createdByRole = await this.assertGate(
contract,
isGlActor,
false,
opts?.allowExpiredContract,
);
// ONE_TIME: a single shipment at a time. The slot frees only if the prior
// booking reached a terminal state (e.g. payment expired without shipping),
@@ -1203,6 +1216,7 @@ export class ContractBookingService {
contract: Contract,
isGlActor: boolean,
isInitiate = false,
allowExpired = false,
): Promise<string> {
// Suspended contracts are frozen for everyone, GL included — say so instead
// of letting the executed-status check below give a misleading reason.
@@ -1225,7 +1239,10 @@ export class ContractBookingService {
}
// No contract clearance cycle exists on either kind now — clearance runs
// on the booking, so an executed/active contract is the only gate here.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
if (
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
!(allowExpired && contract.status === 'EXPIRED')
) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);
@@ -1234,7 +1251,10 @@ export class ContractBookingService {
}
// Path A — customer (or staff) once the contract is executed.
if (!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status)) {
if (
!['FULLY_EXECUTED', 'CONTRACT_ACTIVE'].includes(contract.status) &&
!(allowExpired && contract.status === 'EXPIRED')
) {
throw new BadRequestException(
'Contract must be fully executed before booking a shipment.',
);