feat: add wagon usage computation and maintenance logging features

- Implemented  utility to calculate wagon usage metrics for train schedules.
- Created  for sending wagons to maintenance with optional notes.
- Added unit tests for train builder maintenance functionalities, including formatting train run labels and building maintenance notes.
- Developed  component for merging train schedules with detailed previews and reasons for merging.
- Introduced  component for selecting wagons with search functionality and selection limits.
- Created  for displaying and filtering audit logs, including detailed views of individual log entries.
- Added  for handling API interactions related to audit logs, including fetching logs and entity types.
This commit is contained in:
marshalyordanos
2026-08-12 09:36:50 +03:00
parent 35e5404b41
commit 5da36eb128
77 changed files with 6275 additions and 296 deletions

View File

@@ -0,0 +1,81 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Backoffice audit trail for every state-changing freight endpoint.
*
* No `updated_at` / `deleted_at` columns, unlike every other table here: audit
* rows are insert-only evidence. A soft-delete column would let an actor erase
* their own trail and TypeORM would then hide those rows from default queries
* silently — see the entity comment.
*
* `user_id` intentionally carries NO foreign key to the `iam` schema.
* Cross-schema FKs are forbidden platform-wide, and one here would let user
* deletion cascade away the record of what that user did.
*
* DDL is idempotent (`IF NOT EXISTS`) because watch-mode API instances race
* `migrationsRun` against each other on the shared dev database.
*/
export class CreateAuditLogs3390000000000 implements MigrationInterface {
name = "CreateAuditLogs3390000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.audit_logs (
id uuid NOT NULL DEFAULT gen_random_uuid(),
title varchar(255) NOT NULL,
method varchar(10) NOT NULL,
url text NOT NULL,
route_path varchar(255),
type varchar(50) NOT NULL,
is_success boolean NOT NULL,
user_id uuid,
resource_id varchar(64),
request jsonb,
status_code smallint,
error_message text,
user_name varchar(150),
user_role varchar(100),
ip_address inet,
user_agent text,
request_id varchar(64),
duration_ms integer,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT "PK_audit_logs" PRIMARY KEY (id)
)
`);
// Every audit query is time-bounded, so created_at leads each index.
// DESC matches the "newest first" read path the controller exposes.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_created_at"
ON freight.audit_logs (created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_user_id_created_at"
ON freight.audit_logs (user_id, created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_created_at"
ON freight.audit_logs (type, created_at DESC)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_type_resource_id"
ON freight.audit_logs (type, resource_id)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_route_path_created_at"
ON freight.audit_logs (route_path, created_at DESC)
`);
// Failures are a small slice of the table but carry the security signal
// (403s especially), so they get their own partial index.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "IDX_audit_logs_failures"
ON freight.audit_logs (created_at DESC)
WHERE is_success = false
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS freight.audit_logs`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Wagons the requester specifically asked for. A transfer request stays
* count-driven (`quantity` is what must be delivered), but a requester who
* picked wagons off the yard desk now records WHICH ones — OCC sees the numbers
* on the queue and the fulfil picker pre-selects them.
*
* Stored as a uuid[] column rather than a join table: the list is read and
* written whole, never queried by wagon, and a preference carries no lifecycle
* of its own (no FK — a purged wagon simply drops out of the display).
*/
export class TransferRequestPreferredWagons3400000000000
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
ADD COLUMN IF NOT EXISTS preferred_wagon_ids uuid[]
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.wagon_transfer_requests
DROP COLUMN IF EXISTS preferred_wagon_ids
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Voyage number for one departure.
*
* `train_number` already exists on a schedule (the run number), but operations
* also quote a VOYAGE number — the sailing/run identifier yards and customs use
* for a specific departure. It belongs on the schedule, not the built train: one
* train serves many departures and each carries its own voyage.
*
* Nullable and un-indexed: it is display/reference data typed by staff, not a
* lookup key, and older schedules simply have none.
*/
export class ScheduleVoyageNumber3410000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
ADD COLUMN IF NOT EXISTS voyage_number varchar(20)
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.train_schedules
DROP COLUMN IF EXISTS voyage_number
`);
}
}