wagon work space, container validation

This commit is contained in:
Marshal
2026-07-10 23:29:28 +00:00
parent 5d8658b5d6
commit dd87c2a722
12 changed files with 878 additions and 47 deletions

View File

@@ -1,5 +1,6 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
@@ -192,6 +193,11 @@ export class ContractBookingService {
// their only chance to hard-block an unbalanceable set. Entry order is
// irrelevant (the check sorts by weight before pairing).
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(dto, {
originYardId: route?.originYardId ?? null,
destinationYardId: route?.destinationYardId ?? null,
});
}
// Denormalize route/direction/freight onto the booking for the scheduling engine.
@@ -575,6 +581,15 @@ export class ContractBookingService {
if (freightType === 'CONTAINER') {
await this.assertWithinMaxCapacity(contract, dto);
await this.assert20ftPairableAtCreate(dto);
// A container number may appear once per train (same day + route).
await this.assertContainerNumbersAvailable(
dto,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
await this.persistContainers(booking.id, contract, dto);
}
await this.bookingsRepository.update(booking.id, {
@@ -653,6 +668,10 @@ export class ContractBookingService {
Boolean(contract.customsClearingEnabled);
await this.finalizeContractBooking(booking.id, contract, generalCustoms);
await this.maybeCompleteContract(contract);
} else if (freightType === 'CONTAINER') {
// Resubmit only re-picks the shipment day — the persisted container
// numbers must be free on the newly chosen train day too.
await this.assertPersistedContainersAvailable(booking, dto.scheduledDate);
}
// Binding day + open-departure validation, status OPERATION_REQUEST_PENDING
@@ -1344,6 +1363,131 @@ export class ContractBookingService {
* balanced onto wagons (pair diff over the global cap). Same rule the
* shipment-form preview reports as `pairingErrors`, enforced server-side.
*/
/**
* A physical container rides one train only. Reject the submission when a
* container number is entered twice in the same booking (the portal checks
* this client-side, the API must not trust it) or already sits on another
* customer's active booking for the same train — same shipment day AND same
* route (origin/destination yards).
*/
private async assertContainerNumbersAvailable(
dto: CreateBookingUnderContractDto,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const numbers = (dto.containers ?? []).flatMap((line) =>
(line.units ?? [])
.map((u) => (u.containerNumber ?? '').trim().toUpperCase())
.filter((n) => n.length > 0),
);
if (!numbers.length) return;
const seen = new Set<string>();
const withinBooking = new Set<string>();
for (const n of numbers) {
if (seen.has(n)) withinBooking.add(n);
seen.add(n);
}
if (withinBooking.size) {
throw new BadRequestException(
`Duplicate container number(s) in this booking: ${[...withinBooking].join(', ')} — each container can only be entered once.`,
);
}
// Intercity bookings have no shipment day yet — nothing to clash with.
if (!dto.scheduledDate) return;
await this.assertNumbersFreeOnTrain(
numbers,
dto.scheduledDate,
route,
excludeBookingId,
);
}
/**
* Same train guard for a booking whose containers are already persisted
* (resubmit after OPERATION_CHANGES_REQUESTED only re-picks the day): its
* stored numbers must be free on the newly chosen day for its route.
*/
private async assertPersistedContainersAvailable(
booking: Booking,
scheduledDate: string,
): Promise<void> {
const rows: Array<{ containerNumber: string }> = await this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.select('unit.container_number', 'containerNumber')
.where('line.booking_id = :bookingId', { bookingId: booking.id })
.getRawMany();
const numbers = rows.map((r) => r.containerNumber).filter(Boolean);
if (!numbers.length) return;
await this.assertNumbersFreeOnTrain(
numbers,
scheduledDate,
{
originYardId: booking.originYardId,
destinationYardId: booking.destinationYardId,
},
booking.id,
);
}
/**
* Reject when any of `numbers` sits on another active booking of the same
* train — same day and same route. Bookings without route yards (legacy
* rows) are matched on the day alone rather than let through.
*/
private async assertNumbersFreeOnTrain(
numbers: string[],
scheduledDate: string,
route: { originYardId?: string | null; destinationYardId?: string | null },
excludeBookingId?: string,
): Promise<void> {
const qb = this.dataSource
.getRepository(BookingContainerUnit)
.createQueryBuilder('unit')
.innerJoin(BookingContainer, 'line', 'line.id = unit.booking_container_id')
.innerJoin(Booking, 'b', 'b.id = line.booking_id')
.select('unit.container_number', 'containerNumber')
.addSelect('b.reference', 'reference')
.where('unit.container_number IN (:...numbers)', { numbers })
.andWhere('b.scheduled_date::date = :day::date', { day: scheduledDate })
.andWhere('b.status NOT IN (:...terminal)', {
terminal: TERMINAL_BOOKING_STATUSES,
})
.andWhere('b.deleted_at IS NULL');
if (route.originYardId && route.destinationYardId) {
// Same train = same day + same corridor. A clashing booking whose yards
// were never denormalized still blocks (NULL yards match any route).
qb.andWhere(
'(b.origin_yard_id IS NULL OR b.origin_yard_id = :originYardId)',
{ originYardId: route.originYardId },
).andWhere(
'(b.destination_yard_id IS NULL OR b.destination_yard_id = :destinationYardId)',
{ destinationYardId: route.destinationYardId },
);
}
if (excludeBookingId) {
qb.andWhere('b.id != :excludeBookingId', { excludeBookingId });
}
const clashes: Array<{ containerNumber: string; reference: string }> =
await qb.getRawMany();
if (clashes.length) {
const detail = [
...new Map(clashes.map((c) => [c.containerNumber, c])).values(),
]
.map((c) => `${c.containerNumber} (booking ${c.reference})`)
.join(', ');
throw new ConflictException(
`Container(s) already booked on this route for ${scheduledDate}: ${detail}. ` +
'A container can only be on one booking per train — remove it or pick another shipment day.',
);
}
}
private async assert20ftPairableAtCreate(
dto: CreateBookingUnderContractDto,
): Promise<void> {

View File

@@ -0,0 +1,12 @@
import { WagonStatus } from '@edr/types';
import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator';
export class BulkSetWagonStatusDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonIds!: string[];
@IsEnum(WagonStatus)
status!: WagonStatus;
}

View File

@@ -0,0 +1,11 @@
import { ArrayNotEmpty, IsArray, IsUUID } from 'class-validator';
export class BulkTransferWagonsDto {
@IsArray()
@ArrayNotEmpty()
@IsUUID('4', { each: true })
wagonIds!: string[];
@IsUUID()
toYardId!: string;
}

View File

@@ -10,12 +10,16 @@ import {
Query,
} from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { FleetManage, FleetView } from '../../common/booking-guards';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { WagonsService } from './wagons.service';
@ApiTags('wagons')
@@ -78,6 +82,20 @@ export class WagonsController {
unassignFromTrain(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.unassignFromTrain(id);
}
@Post('bulk-transfer')
@FleetManage()
@ApiOperation({ summary: 'Transfer multiple wagons to a destination yard' })
bulkTransfer(@Body() dto: BulkTransferWagonsDto, @CurrentUser() user: TCurrentUser) {
return this.wagonsService.bulkTransfer(dto, user?.id);
}
@Post('bulk-status')
@FleetManage()
@ApiOperation({ summary: 'Set the status of multiple wagons' })
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
return this.wagonsService.bulkSetStatus(dto);
}
}
// Separate controller for trainspecific reorder (registered in module)

View File

@@ -1,15 +1,18 @@
import { WagonMovementKind, WagonStatus } from '@edr/types';
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike } from 'typeorm';
import { Repository, DataSource, FindOptionsOrder, FindOptionsWhere, ILike, In } from 'typeorm';
import { CreateWagonDto } from './dto/create-wagon.dto';
import { ListWagonsQueryDto } from './dto/list-wagons-query.dto';
import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { BulkTransferWagonsDto } from './dto/bulk-transfer-wagons.dto';
import { BulkSetWagonStatusDto } from './dto/bulk-set-wagon-status.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@Injectable()
export class WagonsService {
@@ -168,6 +171,101 @@ export class WagonsService {
return this.wagonRepo.save(wagon);
}
/**
* Relocate many wagons to one destination yard in a single transaction. Each
* wagon whose yard actually changes gets a `wagon_movements` ledger row (kind
* `Manual`) so the yard history stays auditable — mirrors the single-wagon
* `update` path. Wagons already in the destination yard are skipped.
*/
async bulkTransfer(
dto: BulkTransferWagonsDto,
userId?: string | null,
): Promise<{ moved: number }> {
const { wagonIds, toYardId } = dto;
if (!wagonIds.length) return { moved: 0 };
const yard = await this.dataSource
.getRepository(Yard)
.findOne({ where: { id: toYardId } });
if (!yard) throw new NotFoundException('Destination yard not found');
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const wagons = await queryRunner.manager.find(Wagon, {
where: { id: In(wagonIds) },
});
if (wagons.length !== wagonIds.length) {
throw new NotFoundException('One or more wagons not found');
}
let moved = 0;
for (const wagon of wagons) {
const previousYardId = wagon.currentYardId ?? null;
if (previousYardId === toYardId) continue;
wagon.currentYardId = toYardId;
// Drop the eager relation so the scalar FK wins on save (see `update`).
wagon.currentYard = null;
await queryRunner.manager.save(Wagon, wagon);
await queryRunner.manager.save(
queryRunner.manager.create(WagonMovement, {
wagonId: wagon.id,
fromYardId: previousYardId,
toYardId,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
occurredAt: new Date(),
}),
);
moved++;
}
await queryRunner.commitTransaction();
return { moved };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
/**
* Set the same status on many wagons in one transaction (e.g. flip a batch
* from Available to Assigned in the yard workspace). Only the `status` column
* is touched — train assignment is managed through the assign/unassign flow.
*/
async bulkSetStatus(dto: BulkSetWagonStatusDto): Promise<{ updated: number }> {
const { wagonIds, status } = dto;
if (!wagonIds.length) return { updated: 0 };
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();
await queryRunner.startTransaction();
try {
const wagons = await queryRunner.manager.find(Wagon, {
where: { id: In(wagonIds) },
});
if (wagons.length !== wagonIds.length) {
throw new NotFoundException('One or more wagons not found');
}
for (const wagon of wagons) {
wagon.status = status;
}
await queryRunner.manager.save(Wagon, wagons);
await queryRunner.commitTransaction();
return { updated: wagons.length };
} catch (err) {
await queryRunner.rollbackTransaction();
throw err;
} finally {
await queryRunner.release();
}
}
async reorderWagons(_trainId: string, dto: ReorderWagonsDto): Promise<void> {
const queryRunner = this.dataSource.createQueryRunner();
await queryRunner.connect();