This commit is contained in:
Marshal
2026-07-07 12:28:47 +00:00
parent 1c18fdbd52
commit f300600bfa
63 changed files with 2587 additions and 428 deletions

View File

@@ -0,0 +1,59 @@
import { BaseEntity } from '@edr/api-common';
import { WagonMovementKind } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Yard } from '../../rule-engine/entities/yard.entity';
import { Wagon } from './wagon.entity';
/**
* Ledger of every physical wagon relocation between yards — one row per move.
* Written when a wagon carries a booking's leg (LOADED), rides a train empty to
* reposition (EMPTY_REPOSITION), or staff manually correct its yard (MANUAL).
* `wagons.current_yard_id` is the derived "where is it now"; this table is the
* auditable history of how it got there and by whom.
*/
@Entity({ schema: 'freight', name: 'wagon_movements' })
@Index(['wagonId', 'occurredAt'])
export class WagonMovement extends BaseEntity {
@Column({ name: 'wagon_id', type: 'uuid' })
wagonId!: string;
@ManyToOne(() => Wagon, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'wagon_id' })
wagon?: Wagon;
/** Null when the prior location is unknown (e.g. first manual registration). */
@Column({ name: 'from_yard_id', type: 'uuid', nullable: true })
fromYardId?: string | null;
@ManyToOne(() => Yard, { nullable: true })
@JoinColumn({ name: 'from_yard_id' })
fromYard?: Yard | null;
@Column({ name: 'to_yard_id', type: 'uuid' })
toYardId!: string;
@ManyToOne(() => Yard)
@JoinColumn({ name: 'to_yard_id' })
toYard?: Yard | null;
/** Set when the move happened by riding a scheduled train (LOADED / EMPTY_REPOSITION). */
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
trainScheduleId?: string | null;
/** Set when the move carried a specific booking's cargo (kind LOADED). */
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@Column({ name: 'kind', type: 'varchar', length: 30 })
kind!: WagonMovementKind;
@Column({ name: 'moved_by_user_id', type: 'uuid', nullable: true })
movedByUserId?: string | null;
@Column({ name: 'occurred_at', type: 'timestamptz' })
occurredAt!: Date;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
}

View File

@@ -43,6 +43,14 @@ export class WagonsController {
return this.wagonsService.findById(id);
}
@Get(':id/movements')
@ApiOperation({
summary: "Wagon movement ledger (loaded legs, empty repositions, manual moves), newest first",
})
listMovements(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.listMovements(id);
}
@Patch(':id')
@FleetManage()
@ApiOperation({ summary: 'Update a wagon' })

View File

@@ -1,4 +1,4 @@
import { WagonStatus } from '@edr/types';
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';
@@ -8,6 +8,7 @@ import { UpdateWagonDto } from './dto/update-wagon.dto';
import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.dto';
import { ReorderWagonsDto } from './dto/reorder-wagons.dto';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
@Injectable()
@@ -74,8 +75,9 @@ export class WagonsService {
return wagon;
}
async update(id: string, dto: UpdateWagonDto): Promise<Wagon> {
async update(id: string, dto: UpdateWagonDto, userId?: string | null): Promise<Wagon> {
const wagon = await this.findById(id);
const previousYardId = wagon.currentYardId ?? null;
Object.assign(wagon, dto);
// `findById` eager-loads `currentYard`; when the DTO changes the scalar FK
// TypeORM otherwise re-derives `current_yard_id` from the STALE relation
@@ -85,11 +87,40 @@ export class WagonsService {
wagon.currentYard = null;
}
await this.wagonRepo.save(wagon);
// Staff manually relocated the wagon — write the movement ledger row so the
// wagon's yard history stays auditable (who moved it, from where, when).
if (
dto.currentYardId !== undefined &&
dto.currentYardId !== null &&
dto.currentYardId !== previousYardId
) {
const movementRepo = this.dataSource.getRepository(WagonMovement);
await movementRepo.save(
movementRepo.create({
wagonId: id,
fromYardId: previousYardId,
toYardId: dto.currentYardId,
kind: WagonMovementKind.Manual,
movedByUserId: userId ?? null,
occurredAt: new Date(),
}),
);
}
// Re-read with the relation so the response reflects the new yard label
// instead of the stale relation object loaded before the assign.
return this.findById(id);
}
/** Movement ledger for one wagon, newest first (loaded legs, repositions, manual moves). */
async listMovements(wagonId: string): Promise<WagonMovement[]> {
await this.findById(wagonId); // 404 on unknown wagon
return this.dataSource.getRepository(WagonMovement).find({
where: { wagonId },
relations: { fromYard: true, toYard: true },
order: { occurredAt: 'DESC', createdAt: 'DESC' },
});
}
async remove(id: string): Promise<void> {
const wagon = await this.findById(id);
await this.wagonRepo.remove(wagon);