fix issue

This commit is contained in:
Marshal
2026-07-31 12:11:47 +00:00
parent 091d655baa
commit 8b9dd76710
5 changed files with 161 additions and 22 deletions

View File

@@ -3718,6 +3718,30 @@ export class TrainSchedulingService {
// unload each one by hand. The final station is covered by
// arriveSchedule's bulk fallback above.
await this.bookingJourneyService.autoUnloadAtYard(scheduleId, station.yardId);
// A pass is also a position fix: the locomotives, every wagon still
// aboard, and the built train are physically AT this yard now — not at
// the origin they departed from. Wagons released at earlier stops no
// longer carry this schedule id and stay where they alighted; the final
// arrival settle still writes the wagon-movement ledger rows.
await this.dataSource.transaction(async (manager) => {
const locoIds = this.locomotivesOfTrainSet(schedule.trainSet).map((l) => l.id);
if (locoIds.length) {
await manager
.getRepository(Locomotive)
.update({ id: In(locoIds) }, { currentYardId: station.yardId });
}
await manager
.getRepository(Wagon)
.update(
{ currentTrainScheduleId: scheduleId },
{ currentYardId: station.yardId },
);
if (schedule.trainSet?.trainId) {
await manager
.getRepository(Train)
.update(schedule.trainSet.trainId, { currentYardId: station.yardId });
}
});
}
return this.getScheduleCheckpoints(scheduleId);

View File

@@ -13,8 +13,11 @@ import {
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import { FleetManage, FleetView } from '../../common/booking-guards';
import type { AuthUserPayload } from '../../common/resolve-auth-user-id';
import { resolveAuthUserId } from '../../common/resolve-auth-user-id';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { AssignTrainWagonsDto } from './dto/assign-train-wagons.dto';
import { BuildTrainDto } from './dto/build-train.dto';
@@ -94,8 +97,12 @@ export class TrainBuilderController {
@Post(':id/wagons')
@FleetManage(FREIGHT_PERMS.trains.assignWagons)
@ApiOperation({ summary: "Append AVAILABLE wagons from the train's yard to the consist" })
assignWagons(@Param('id', ParseUUIDPipe) id: string, @Body() dto: AssignTrainWagonsDto) {
return this.trainBuilderService.assignWagons(id, dto);
assignWagons(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: AssignTrainWagonsDto,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.assignWagons(id, dto, resolveAuthUserId(user));
}
@Delete(':id/wagons/:wagonId')
@@ -104,8 +111,9 @@ export class TrainBuilderController {
removeWagon(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.removeWagon(id, wagonId);
return this.trainBuilderService.removeWagon(id, wagonId, resolveAuthUserId(user));
}
@Post(':id/wagons/:wagonId/maintenance')
@@ -114,8 +122,9 @@ export class TrainBuilderController {
sendWagonToMaintenance(
@Param('id', ParseUUIDPipe) id: string,
@Param('wagonId', ParseUUIDPipe) wagonId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId);
return this.trainBuilderService.sendWagonToMaintenance(id, wagonId, resolveAuthUserId(user));
}
@Post(':id/reorder-wagons')

View File

@@ -11,7 +11,11 @@ import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity
import { Locomotive } from '../locomotives/entities/locomotive.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
import { BookingBatchService } from '../train-scheduling/booking-batch.service';
import { combinedLocomotiveLimits } from '../train-scheduling/train-capacity.util';
import { ScheduleWagonAdjustmentLog } from '../train-schedules/entities/schedule-wagon-adjustment-log.entity';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { TrainSet } from '../train-sets/entities/train-set.entity';
import { WagonType } from '../wagon-types/entities/wagon-type.entity';
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
import { Wagon } from '../wagons/entities/wagon.entity';
@@ -58,7 +62,10 @@ export interface ActiveScheduleRef {
export class TrainBuilderService {
private readonly logger = new Logger(TrainBuilderService.name);
constructor(private readonly dataSource: DataSource) {}
constructor(
private readonly dataSource: DataSource,
private readonly bookingBatchService: BookingBatchService,
) {}
async buildTrain(dto: BuildTrainDto) {
const locomotiveIds = [...new Set(dto.locomotiveIds)];
@@ -467,19 +474,26 @@ export class TrainBuilderService {
}
/** Append AVAILABLE wagons from the train's own yard to the consist. */
async assignWagons(id: string, dto: AssignTrainWagonsDto) {
async assignWagons(id: string, dto: AssignTrainWagonsDto, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const currentCount = await manager
.getRepository(Wagon)
.count({ where: { trainId: train.id } });
await this.attachWagons(manager, train, dto.wagonIds, currentCount);
const attached = await this.attachWagons(manager, train, dto.wagonIds, currentCount);
await this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
attached.map((w) => ({ action: 'ADD' as const, wagonId: w.id, wagonNumber: w.wagonNumber })),
userId ?? null,
train.currentYardId ?? null,
);
});
return this.getComposition(id);
}
/** Detach one wagon and close the sequence gap it leaves. */
async removeWagon(id: string, wagonId: string) {
async removeWagon(id: string, wagonId: string, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
@@ -497,6 +511,13 @@ export class TrainBuilderService {
status: WagonStatus.Available,
});
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
wagon.currentYardId ?? train.currentYardId ?? null,
);
});
return this.getComposition(id);
}
@@ -506,7 +527,7 @@ export class TrainBuilderService {
* moves to MAINTENANCE status (not AVAILABLE), so it is not re-coupled until
* it clears maintenance. The freed sequence gap is closed.
*/
async sendWagonToMaintenance(id: string, wagonId: string) {
async sendWagonToMaintenance(id: string, wagonId: string, userId?: string | null) {
await this.dataSource.transaction(async (manager) => {
const train = await this.getEditableTrain(manager, id);
const wagon = await manager.getRepository(Wagon).findOne({ where: { id: wagonId } });
@@ -546,6 +567,13 @@ export class TrainBuilderService {
);
}
await this.resequenceWagons(manager, train.id);
await this.syncLiveScheduleAfterConsistChange(
manager,
train.id,
[{ action: 'REMOVE', wagonId: wagon.id, wagonNumber: wagon.wagonNumber }],
userId ?? null,
yardId,
);
});
return this.getComposition(id);
}
@@ -781,6 +809,81 @@ export class TrainBuilderService {
};
}
/**
* Train Builder edits a train's physical consist directly on `Wagon.trainId`
* — it never touches `TrainSchedule.maxWagons` / `TrainSet.wagonCount`, so a
* wagon added/removed here (while the train already has a live DRAFT/
* SCHEDULED schedule) used to leave the schedule's capacity, history, and
* booking-window status silently stale. This mirrors what
* TrainSchedulingService.adjustScheduleConsist does when the SAME edit is
* made from the schedule's own consist editor, so both entry points agree.
*/
private async syncLiveScheduleAfterConsistChange(
manager: EntityManager,
trainId: string,
changes: Array<{ action: 'ADD' | 'REMOVE'; wagonId: string; wagonNumber: string }>,
userId: string | null,
yardId: string | null,
): Promise<void> {
if (!changes.length) return;
const trainSet = await manager
.getRepository(TrainSet)
.findOne({ where: { trainId }, order: { createdAt: 'DESC' } });
const schedule = trainSet
? await manager.getRepository(TrainSchedule).findOne({
where: { trainSetId: trainSet.id, status: In(['DRAFT', 'SCHEDULED']) },
})
: null;
const consist = await manager.getRepository(Wagon).find({
where: { trainId },
relations: { wagonType: true },
});
const wagonCount = consist.length;
const totalWeightTons = round(
consist.reduce((sum, w) => sum + (w.wagonType?.tareWeightTons ? Number(w.wagonType.tareWeightTons) : 0), 0),
);
const totalLengthMeters = round(
consist.reduce((sum, w) => sum + (w.wagonType?.lengthMeters ? Number(w.wagonType.lengthMeters) : 0), 0),
);
if (trainSet) {
await manager
.getRepository(TrainSet)
.update(trainSet.id, { wagonCount, totalWeightTons, totalLengthMeters });
}
if (!schedule) return;
await manager.getRepository(TrainSchedule).update(schedule.id, { maxWagons: wagonCount });
const now = new Date();
await manager.getRepository(ScheduleWagonAdjustmentLog).save(
changes.map((c) =>
manager.getRepository(ScheduleWagonAdjustmentLog).create({
trainScheduleId: schedule.id,
trainId,
action: c.action,
wagonId: c.wagonId,
wagonNumber: c.wagonNumber,
adjustedByUserId: userId,
yardId,
occurredAt: now,
}),
),
);
// Same full/reopen rule as adjustScheduleConsist: freeing a slot on a FULL
// schedule reopens its booking window; filling the last one closes it.
const wasFull = schedule.bookingWindowStatus === 'FULL';
const usage = await this.bookingBatchService.scheduleWagonUsage(schedule.id);
if (!usage) return;
const nowFull = usage.remainingSlots <= 0;
if (wasFull && !nowFull) {
await this.bookingBatchService.refreshWindowStatus(schedule.id);
} else if (!wasFull && nowFull) {
await this.bookingBatchService.setWindow(schedule.id, 'FULL');
}
}
/** Load + freeze the train row for edit; block edits while it is out on a run. */
private async getEditableTrain(manager: EntityManager, id: string): Promise<Train> {
const train = await manager.getRepository(Train).findOne({
@@ -856,7 +959,7 @@ export class TrainBuilderService {
train: Train,
wagonIds: string[],
startCount: number,
): Promise<void> {
): Promise<Wagon[]> {
const uniqueIds = [...new Set(wagonIds)];
const wagonRepo = manager.getRepository(Wagon);
@@ -883,7 +986,7 @@ export class TrainBuilderService {
}
toAttach.push(wagon);
}
if (!toAttach.length) return;
if (!toAttach.length) return [];
await this.assertConsistLengthWithinLimit(manager, train, toAttach);
@@ -896,6 +999,7 @@ export class TrainBuilderService {
status: WagonStatus.Assigned,
});
}
return toAttach;
}
/**

View File

@@ -1,6 +1,7 @@
// apps/edr-freight-api/src/modules/trains/trains.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { TrainSchedulingModule } from '../train-scheduling/train-scheduling.module';
import { TrainLocomotive } from './entities/train-locomotive.entity';
import { Train } from './entities/train.entity';
import { TrainBuilderController } from './train-builder.controller';
@@ -9,7 +10,7 @@ import { TrainsController } from './trains.controller';
import { TrainsService } from './trains.service';
@Module({
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive])],
imports: [TypeOrmModule.forFeature([Train, TrainLocomotive]), TrainSchedulingModule],
controllers: [TrainsController, TrainBuilderController],
providers: [TrainsService, TrainBuilderService],
exports: [TrainsService, TrainBuilderService],

View File

@@ -389,10 +389,16 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
<Table.Tbody>
{e.bookings.map((b) => (
<Table.Tr key={b.bookingId}>
<Table.Td w="34%">
<Group gap={6}>
<Text size="sm" fw={500}>
<Table.Td w="50%">
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={500} style={{ whiteSpace: "nowrap" }}>
{b.reference}
{b.route ? (
<Text span size="xs" c="dimmed">
{" "}
({b.route})
</Text>
) : null}
</Text>
{b.unallocated ? (
<Badge size="xs" color="orange" variant="filled">
@@ -401,12 +407,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
) : null}
</Group>
</Table.Td>
<Table.Td w="26%">
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{b.route ?? "—"}
</Text>
</Table.Td>
<Table.Td w="20%">
<Table.Td w="25%">
<Group gap={4} wrap="nowrap">
<Train size={12} />
<Text size="xs" c="dimmed">
@@ -416,7 +417,7 @@ export function LegCapacityPanel({ schedule }: { schedule: TrainScheduleDetail }
</Text>
</Group>
</Table.Td>
<Table.Td w="20%">
<Table.Td w="25%">
<Group gap={4} wrap="nowrap">
<Weight size={12} />
<Text size="xs" c="dimmed">