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.',
);

View File

@@ -0,0 +1,65 @@
import { ContractBookingService } from './contract-booking.service';
/**
* Wagon-cancellation credit rebook must work after the contract lapses (the
* freight was paid while it was live), while every other create path stays
* blocked. assertGate is the status gate createUnderContract runs; this pins
* the EXPIRED carve-out to the allowExpired flag.
*/
describe('ContractBookingService.assertGate expired-contract rebook carve-out', () => {
// assertGate only reads contract fields — no constructor deps needed.
const service = Object.create(
ContractBookingService.prototype,
) as ContractBookingService;
const gate = (
contract: Record<string, unknown>,
allowExpired: boolean,
): Promise<string> =>
(
service as unknown as {
assertGate: (
c: unknown,
gl: boolean,
init: boolean,
allowExpired: boolean,
) => Promise<string>;
}
).assertGate(contract, true, false, allowExpired);
it('refuses an EXPIRED contract on the normal create path', async () => {
await expect(
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, false),
).rejects.toThrow(/fully executed/i);
});
it('lets a credit rebook through on an EXPIRED contract (Path A)', async () => {
await expect(
gate({ status: 'EXPIRED', contractKind: 'GENERAL' }, true),
).resolves.toBe('STAFF');
});
it('lets a credit rebook through on an EXPIRED customs contract (Path B)', async () => {
await expect(
gate(
{
status: 'EXPIRED',
contractKind: 'GENERAL',
customsClearingEnabled: true,
},
true,
),
).resolves.toBe('GL_ET');
});
it('still refuses a SUSPENDED contract even for a rebook', async () => {
await expect(
gate({ status: 'SUSPENDED', contractKind: 'GENERAL' }, true),
).rejects.toThrow(/suspended/i);
});
it('does not open the gate for other non-executed statuses', async () => {
await expect(
gate({ status: 'DRAFT', contractKind: 'GENERAL' }, true),
).rejects.toThrow(/fully executed/i);
});
});