fix issue

This commit is contained in:
Marshal
2026-08-20 18:10:29 +00:00
183 changed files with 14241 additions and 1265 deletions

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Columns for `POST /v1/bulkRegister` — see `EimsBulkRegistrationService`.
*
* `eims_system_state.in_flight_conversation_id` is the bulk equivalent of `in_flight_invoice_id`:
* a whole batch, not one invoice, is what's outstanding while MoR processes it asynchronously.
* `invoices.eims_bulk_conversation_id` tags which batch an invoice was submitted in, so a stuck
* batch (webhook never arrived) can be found and reconciled by conversation id.
*/
export class EimsBulkRegistration3580000000000 implements MigrationInterface {
name = "EimsBulkRegistration3580000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
ADD COLUMN IF NOT EXISTS in_flight_conversation_id text
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS eims_bulk_conversation_id text
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.eims_system_state
DROP COLUMN IF EXISTS in_flight_conversation_id
`);
await queryRunner.query(`
ALTER TABLE freight.invoices
DROP COLUMN IF EXISTS eims_bulk_conversation_id
`);
}
}

View File

@@ -0,0 +1,114 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Reference data for the operations reporting suite (turnaround, delay,
* trainset, TEU, cargo volume).
*
* Two new tables and two new columns:
*
* - `operations_standards` — single-row settings table, same shape as
* `logo_settings` / `exchange_settings`. Holds the railway's standard times
* and charged-tonnage factors. Editable in the backoffice because the
* business calls the corridor standard "flexible".
* - `operations_targets` — the planned side of every "Plan / Operated /
* Implement Rate" table in the spec. One row per period × metric ×
* dimension value.
* - `yard_distances.standard_hours` — the per-corridor standard transit time
* (Negad→GMP 21h, →Adama 20h, →Modjo 20.5h, →Sebeta 22h). Null falls back to
* `operations_standards.default_leg_standard_hours`.
* - `cargo_types.full_trainset_wagons` — wagons in a full trainset of this
* cargo (37 for vehicles, 22 for sand). Null falls back to
* `operations_standards.default_full_trainset_wagons`.
*
* The seed row is inserted only when the table is empty, so re-running this
* never overwrites values an operator has since edited.
*/
export class OperationsReporting3580000000000 implements MigrationInterface {
name = "OperationsReporting3580000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.operations_standards (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
station_standard_hours_ethiopia numeric(6,2) NOT NULL DEFAULT 10,
station_standard_hours_djibouti numeric(6,2) NOT NULL DEFAULT 13,
cycle_standard_hours_container numeric(6,2) NOT NULL DEFAULT 65,
cycle_standard_hours_bulk_dmp numeric(6,2) NOT NULL DEFAULT 88,
cycle_standard_hours_bulk_nagad numeric(6,2) NOT NULL DEFAULT 96,
cycle_standard_hours_bulk_bcc numeric(6,2) NOT NULL DEFAULT 96,
default_leg_standard_hours numeric(6,2) NOT NULL DEFAULT 21,
delay_tolerance_minutes integer NOT NULL DEFAULT 30,
charged_tons_full_20ft numeric(8,2) NOT NULL DEFAULT 20,
charged_tons_full_40ft numeric(8,2) NOT NULL DEFAULT 40,
charged_tons_empty_20ft numeric(8,2) NOT NULL DEFAULT 2.24,
charged_tons_empty_40ft numeric(8,2) NOT NULL DEFAULT 3.88,
charged_tons_per_wagon_general numeric(8,2) NOT NULL DEFAULT 70,
charged_tons_per_wagon_perishable numeric(8,2) NOT NULL DEFAULT 38,
default_full_trainset_wagons integer NOT NULL DEFAULT 50,
updated_by_id uuid,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
// Column defaults carry every value — the seed only needs the row to exist.
await queryRunner.query(`
INSERT INTO freight.operations_standards (id)
SELECT gen_random_uuid()
WHERE NOT EXISTS (SELECT 1 FROM freight.operations_standards WHERE deleted_at IS NULL);
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.operations_targets (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
period_type varchar(10) NOT NULL,
period_start date NOT NULL,
metric varchar(20) NOT NULL,
dimension varchar(20) NOT NULL,
dimension_key varchar(60) NOT NULL,
planned_value numeric(14,3) NOT NULL,
note text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
);
`);
// Partial unique index rather than a table constraint, so a soft-deleted
// target can be re-created — same choice as yard_distances.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
WHERE deleted_at IS NULL;
`);
// The reports look targets up by period and metric, never by id.
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS idx_operations_targets_lookup
ON freight.operations_targets (metric, period_type, period_start)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.yard_distances
ADD COLUMN IF NOT EXISTS standard_hours numeric(6,2);
`);
await queryRunner.query(`
ALTER TABLE freight.cargo_types
ADD COLUMN IF NOT EXISTS full_trainset_wagons integer;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.cargo_types DROP COLUMN IF EXISTS full_trainset_wagons;`,
);
await queryRunner.query(
`ALTER TABLE freight.yard_distances DROP COLUMN IF EXISTS standard_hours;`,
);
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_targets;`);
await queryRunner.query(`DROP TABLE IF EXISTS freight.operations_standards;`);
}
}

View File

@@ -0,0 +1,51 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* A station's plan is per station AND per cargo type, not per station.
*
* The OCC monthly report plans "NagadMojo multimodal container 122,010 t" and
* "NagadMojo fertilizer 18,000 t" as separate lines against the same station,
* which the single `dimension_key` column cannot express: a station-keyed target
* would apply the whole station's plan to each of its cargo types.
*
* `cargo_category` is nullable, so `cargo_category` and `container_class`
* targets are unaffected — they leave it null and stay keyed on
* `dimension_key` alone. The uniqueness index moves to include it, since
* (station, category) is now the slot.
*/
export class OperationsTargetCargoCategory3590000000000 implements MigrationInterface {
name = "OperationsTargetCargoCategory3590000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.operations_targets
ADD COLUMN IF NOT EXISTS cargo_category varchar(60);
`);
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
// COALESCE rather than a plain column list: a partial unique index treats
// NULLs as distinct, which would let the same category target be entered
// twice over.
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (
period_type, period_start, metric, dimension, dimension_key,
COALESCE(cargo_category, '')
)
WHERE deleted_at IS NULL;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP INDEX IF EXISTS freight.ux_operations_targets_slot;`);
await queryRunner.query(`
CREATE UNIQUE INDEX IF NOT EXISTS ux_operations_targets_slot
ON freight.operations_targets (period_type, period_start, metric, dimension, dimension_key)
WHERE deleted_at IS NULL;
`);
await queryRunner.query(`
ALTER TABLE freight.operations_targets DROP COLUMN IF EXISTS cargo_category;
`);
}
}

View File

@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/** Ad-hoc customer charges finance raises against a booking — Additional Payments tab. */
export class AdditionalCharge3620000000000 implements MigrationInterface {
name = 'AdditionalCharge3620000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS "freight"."additional_charge" (
"id" uuid NOT NULL DEFAULT uuid_generate_v4(),
"created_at" timestamptz NOT NULL DEFAULT now(),
"updated_at" timestamptz NOT NULL DEFAULT now(),
"deleted_at" timestamptz,
"booking_id" uuid NOT NULL,
"reason" text NOT NULL,
"status" character varying(20) NOT NULL DEFAULT 'DRAFT',
"amount" numeric(14,2) NOT NULL,
"currency" character varying(8) NOT NULL,
"file_record_id" uuid,
"invoice_id" uuid,
"payment_reference" character varying(64),
"created_by_staff_id" uuid,
"sent_by_staff_id" uuid,
"sent_at" timestamptz,
"paid_at" timestamptz,
"cancelled_by_staff_id" uuid,
"cancelled_at" timestamptz,
"cancel_reason" text,
CONSTRAINT "pk_additional_charge" PRIMARY KEY ("id"),
CONSTRAINT "fk_additional_charge_booking" FOREIGN KEY ("booking_id")
REFERENCES "freight"."bookings"("id") ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE INDEX IF NOT EXISTS "idx_additional_charge_booking"
ON "freight"."additional_charge" ("booking_id")
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE IF EXISTS "freight"."additional_charge"`);
}
}