feat(wagons): audited maintenance/availability toggle

This commit is contained in:
Marshal
2026-08-07 12:10:43 +00:00
parent 756325c814
commit 70215a9f37
12 changed files with 419 additions and 31 deletions

View File

@@ -0,0 +1,32 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Audit trail for wagon status flips (Available ⇄ Maintenance and any other
* bulk-status change): who moved which wagon from what to what, when, and why.
* Written inside the same transaction as the status update itself.
*/
export class WagonStatusLogs3310000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.wagon_status_logs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
wagon_id uuid NOT NULL REFERENCES freight.wagons(id),
from_status varchar(30) NOT NULL,
to_status varchar(30) NOT NULL,
changed_by_user_id uuid,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_wagon_status_logs_wagon
ON freight.wagon_status_logs (wagon_id, created_at DESC)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.wagon_status_logs`);
}
}

View File

@@ -1,5 +1,13 @@
import { WagonStatus } from '@edr/types';
import { ArrayNotEmpty, IsArray, IsEnum, IsUUID } from 'class-validator';
import {
ArrayNotEmpty,
IsArray,
IsEnum,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
export class BulkSetWagonStatusDto {
@IsArray()
@@ -9,4 +17,10 @@ export class BulkSetWagonStatusDto {
@IsEnum(WagonStatus)
status!: WagonStatus;
/** Reason shown in the wagon's status history (maintenance/availability flips). */
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}

View File

@@ -0,0 +1,32 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Wagon } from './wagon.entity';
/**
* One wagon status flip (Available ⇄ Maintenance, Detained, …) — the audit
* trail behind the maintenance/availability buttons on the wagons desk.
* Written in the same transaction as the status change.
*/
@Entity({ schema: 'freight', name: 'wagon_status_logs' })
@Index(['wagonId', 'createdAt'])
export class WagonStatusLog extends BaseEntity {
@Column({ name: 'wagon_id', type: 'uuid' })
wagonId!: string;
@ManyToOne(() => Wagon)
@JoinColumn({ name: 'wagon_id' })
wagon?: Wagon;
@Column({ name: 'from_status', type: 'varchar', length: 30 })
fromStatus!: string;
@Column({ name: 'to_status', type: 'varchar', length: 30 })
toStatus!: string;
@Column({ name: 'changed_by_user_id', type: 'uuid', nullable: true })
changedByUserId?: string | null;
@Column({ name: 'note', type: 'text', nullable: true })
note?: string | null;
}

View File

@@ -119,9 +119,22 @@ export class WagonsController {
}
@Post('bulk-status')
@FleetManage(FREIGHT_PERMS.wagons.update)
@ApiOperation({ summary: 'Set the status of multiple wagons' })
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto) {
return this.wagonsService.bulkSetStatus(dto);
// One-of: the dedicated maintenance⇄availability key, full wagon edit, or
// the legacy coarse fleet:manage — operations/OCC hold statusToggle only.
@BookingStaff([
FREIGHT_PERMS.wagons.statusToggle,
FREIGHT_PERMS.wagons.update,
FREIGHT_PERMS.fleet.manage,
])
@ApiOperation({ summary: 'Set the status of multiple wagons (audited in wagon_status_logs)' })
bulkSetStatus(@Body() dto: BulkSetWagonStatusDto, @CurrentUser() user: TCurrentUser) {
return this.wagonsService.bulkSetStatus(dto, user?.id);
}
@Get(':id/status-history')
@FleetView(FREIGHT_PERMS.wagons.view)
@ApiOperation({ summary: 'Status-flip history of a wagon (maintenance ⇄ availability audit)' })
statusHistory(@Param('id', ParseUUIDPipe) id: string) {
return this.wagonsService.statusHistory(id);
}
}

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Wagon } from './entities/wagon.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { WagonStatusLog } from './entities/wagon-status-log.entity';
import { WagonTransferRequest } from './entities/wagon-transfer-request.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -16,6 +17,7 @@ import { WagonTransferRequestsService } from './wagon-transfer-requests.service'
TypeOrmModule.forFeature([
Wagon,
WagonMovement,
WagonStatusLog,
WagonTransferRequest,
Train,
Yard,

View File

@@ -15,6 +15,7 @@ import { AssignWagonToTrainDto } from './dto/assign-wagon-to-train.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 { WagonStatusLog } from './entities/wagon-status-log.entity';
import { WagonMovement } from './entities/wagon-movement.entity';
import { Train } from '../trains/entities/train.entity';
import { Yard } from '../rule-engine/entities/yard.entity';
@@ -440,7 +441,10 @@ export class WagonsService {
* 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 }> {
async bulkSetStatus(
dto: BulkSetWagonStatusDto,
changedByUserId?: string,
): Promise<{ updated: number }> {
const { wagonIds, status } = dto;
if (!wagonIds.length) return { updated: 0 };
@@ -466,10 +470,24 @@ export class WagonsService {
);
}
// Audit trail rides the same transaction — a status flip without its
// history row can't happen. No-change wagons write no log row.
const logs = wagons
.filter((w) => w.status !== status)
.map((w) =>
queryRunner.manager.create(WagonStatusLog, {
wagonId: w.id,
fromStatus: w.status,
toStatus: status,
changedByUserId: changedByUserId ?? null,
note: dto.note ?? null,
}),
);
for (const wagon of wagons) {
wagon.status = status;
}
await queryRunner.manager.save(Wagon, wagons);
if (logs.length) await queryRunner.manager.save(WagonStatusLog, logs);
await queryRunner.commitTransaction();
return { updated: wagons.length };
@@ -481,4 +499,13 @@ export class WagonsService {
}
}
/** Status-flip history of one wagon, newest first (maintenance/availability audit). */
async statusHistory(wagonId: string): Promise<WagonStatusLog[]> {
return this.dataSource.getRepository(WagonStatusLog).find({
where: { wagonId },
order: { createdAt: 'DESC' },
take: 100,
});
}
}

View File

@@ -644,6 +644,13 @@ export const FLEET_RAIL_PERMISSIONS: FreightPermissionSeed[] = [
"edr_freight_app:wagons:hard_delete",
"Permanently delete wagon",
),
// Maintenance ⇄ availability flip on the wagons desk — its own key so
// operations/OCC can flip readiness without holding full wagon edit.
perm(
"e1b00001-0001-4000-8000-00000000000c",
"edr_freight_app:wagons:status_toggle",
"Flip wagon between maintenance and available",
),
perm(
"e1c00001-0001-4000-8000-000000000001",
"edr_freight_app:trains:view",
@@ -1511,6 +1518,8 @@ export const FREIGHT_PERMS = {
transferView: "edr_freight_app:wagons:transfer_view",
/** Withdraw a request that has not moved any wagon yet. */
transferCancel: "edr_freight_app:wagons:transfer_cancel",
/** Maintenance ⇄ availability flip on the wagons desk (audited). */
statusToggle: "edr_freight_app:wagons:status_toggle",
/** End a request short — anyone who can fulfil may also do this. */
transferCloseShort: "edr_freight_app:wagons:transfer_close_short",
// Admin: read every staffer's transfer history. Without it, a user only sees
@@ -1777,6 +1786,7 @@ const FLEET_GRANULAR_KEYS: string[] = [
FREIGHT_PERMS.wagons.create,
FREIGHT_PERMS.wagons.update,
FREIGHT_PERMS.wagons.delete,
FREIGHT_PERMS.wagons.statusToggle,
FREIGHT_PERMS.trains.view,
FREIGHT_PERMS.trains.create,
FREIGHT_PERMS.trains.update,