mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
wagon work space, container validation
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 train‑specific reorder (registered in module)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user