mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 21:48:18 +00:00
Merge remote-tracking branch 'origin/dev' into dj-franc
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Roster of people assignable to a train (ITLMS Rolling Stock §1.2 crew
|
||||
* composition). Separate from freight.drivers, which registers road/last-mile
|
||||
* truck drivers and shares none of these fields.
|
||||
*
|
||||
* Role and nationality are stored as varchar rather than PG enums so adding a
|
||||
* crew role later is an application change, not a type migration. The partial
|
||||
* unique index keys on name + role — the roster has no employee number yet, so
|
||||
* that is the only identity available to block an accidental re-entry; it is
|
||||
* scoped to live rows so a soft-deleted member does not hold the name hostage.
|
||||
*/
|
||||
export class TrainCrewMembers3850000000000 implements MigrationInterface {
|
||||
name = 'TrainCrewMembers3850000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_crew_members (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
first_name varchar(100) NOT NULL,
|
||||
last_name varchar(100) NOT NULL,
|
||||
role varchar(32) NOT NULL,
|
||||
nationality varchar(16) NOT NULL,
|
||||
status varchar(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
is_active boolean NOT NULL DEFAULT true,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT chk_train_crew_role CHECK (role IN (
|
||||
'TRAIN_DRIVER','FEDERAL_POLICE','TECHNICIAN','REEFER_TECHNICIAN',
|
||||
'HAZMAT_ESCORT','LASHING_INSPECTOR','LIVESTOCK_HANDLER'
|
||||
)),
|
||||
CONSTRAINT chk_train_crew_nationality CHECK (nationality IN (
|
||||
'ETHIOPIAN','DJIBOUTIAN'
|
||||
)),
|
||||
CONSTRAINT chk_train_crew_status CHECK (status IN (
|
||||
'ACTIVE','INACTIVE','SUSPENDED','ON_LEAVE'
|
||||
))
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_crew_members_role
|
||||
ON freight.train_crew_members (role)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_crew_members_nationality
|
||||
ON freight.train_crew_members (nationality)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_crew_members_status
|
||||
ON freight.train_crew_members (status)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_train_crew_members_is_active
|
||||
ON freight.train_crew_members (is_active)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_train_crew_members_name_role
|
||||
ON freight.train_crew_members (lower(first_name), lower(last_name), role)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_members`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Crew assigned to a train schedule (ITLMS Rolling Stock §1.2, §2).
|
||||
*
|
||||
* `segment` and `duty_role` are per-assignment, not per-roster-member: Case 1
|
||||
* splits four drivers across the Dire Dawa boundary, and a driver who is
|
||||
* Primary on one run is Assistant on the next. `role` is snapshotted so a later
|
||||
* roster edit cannot rewrite the crew of a run that already departed.
|
||||
*
|
||||
* The partial unique index on (schedule, segment, duty_role) applies to drivers
|
||||
* only — one segment cannot have two Primaries, while four federal police on
|
||||
* the same run carry no segment or duty role and are unconstrained by it.
|
||||
*/
|
||||
export class TrainCrewAssignments3860000000000 implements MigrationInterface {
|
||||
name = 'TrainCrewAssignments3860000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.train_crew_assignments (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
train_schedule_id uuid NOT NULL,
|
||||
crew_member_id uuid NOT NULL
|
||||
REFERENCES freight.train_crew_members(id) ON DELETE RESTRICT,
|
||||
role varchar(32) NOT NULL,
|
||||
duty_role varchar(16),
|
||||
segment varchar(24),
|
||||
crewing_case varchar(8),
|
||||
layover_start_at timestamptz,
|
||||
layover_end_at timestamptz,
|
||||
duty_start_at timestamptz,
|
||||
duty_end_at timestamptz,
|
||||
status varchar(16) NOT NULL DEFAULT 'PLANNED',
|
||||
notes text,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz,
|
||||
CONSTRAINT chk_crew_assignment_duty_role CHECK (
|
||||
duty_role IS NULL OR duty_role IN ('PRIMARY','ASSISTANT','BENCH_RELIEF')
|
||||
),
|
||||
CONSTRAINT chk_crew_assignment_segment CHECK (
|
||||
segment IS NULL OR segment IN
|
||||
('INDODE_DIRE_DAWA','DIRE_DAWA_NAGAD','FULL_CORRIDOR')
|
||||
),
|
||||
CONSTRAINT chk_crew_assignment_case CHECK (
|
||||
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
|
||||
),
|
||||
CONSTRAINT chk_crew_assignment_status CHECK (
|
||||
status IN ('PLANNED','CONFIRMED','COMPLETED','REMOVED')
|
||||
)
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_crew_assignments_schedule
|
||||
ON freight.train_crew_assignments (train_schedule_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_crew_assignments_member
|
||||
ON freight.train_crew_assignments (crew_member_id)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_crew_assignments_status
|
||||
ON freight.train_crew_assignments (status)
|
||||
`);
|
||||
// Nobody holds two seats on one run.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_schedule_member
|
||||
ON freight.train_crew_assignments (train_schedule_id, crew_member_id)
|
||||
WHERE deleted_at IS NULL
|
||||
`);
|
||||
// One Primary (and one Assistant) per segment — drivers only.
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
|
||||
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
|
||||
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
|
||||
`);
|
||||
// Monthly overtime rollups scan a member's duty spans (§3.1).
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_crew_assignments_duty_window
|
||||
ON freight.train_crew_assignments (crew_member_id, duty_start_at)
|
||||
WHERE duty_start_at IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.train_crew_assignments`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Drop `crewing_case` from train crew assignments.
|
||||
*
|
||||
* The column encoded the two fixed driver pairing cases of ITLMS Rolling Stock
|
||||
* §2 (2+2 Ethiopian/Djiboutian, or 3 Ethiopian). Operations crew each run to
|
||||
* its own need instead — any number of drivers, each carrying their own segment
|
||||
* and duty role — so the case has nothing left to select and the column no
|
||||
* longer has a meaning. The §1.1 territorial boundary is unaffected: it is a
|
||||
* per-driver rule and still enforced.
|
||||
*/
|
||||
export class DropCrewingCase3870000000000 implements MigrationInterface {
|
||||
name = 'DropCrewingCase3870000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_crew_assignments
|
||||
DROP CONSTRAINT IF EXISTS chk_crew_assignment_case
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_crew_assignments
|
||||
DROP COLUMN IF EXISTS crewing_case
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_crew_assignments
|
||||
ADD COLUMN IF NOT EXISTS crewing_case varchar(8)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_crew_assignments
|
||||
ADD CONSTRAINT chk_crew_assignment_case CHECK (
|
||||
crewing_case IS NULL OR crewing_case IN ('CASE_1','CASE_2')
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Driver legs become yard-to-yard instead of three fixed corridor segments.
|
||||
*
|
||||
* The old `segment` enum could only express Indode–Dire Dawa, Dire Dawa–Nagad
|
||||
* or the full corridor. Operations hand over at other yards too (Feto, Meiso,
|
||||
* Sebet/Sibra — the very points ITLMS Rolling Stock §2 names as rotation
|
||||
* places), so a leg is now any two yards on the schedule's route.
|
||||
*
|
||||
* `segment` is kept, nullable, so rows written before this still read back; it
|
||||
* is never populated again. The §1.1 territorial boundary is unaffected — it
|
||||
* now derives from yard position rather than the segment name, so a Djibouti
|
||||
* driver is still confined to Dire Dawa and eastward.
|
||||
*
|
||||
* The driver-slot unique index moves with it: one Primary per leg, where a leg
|
||||
* is the (from, to) pair rather than a segment name.
|
||||
*/
|
||||
export class CrewLegYards3880000000000 implements MigrationInterface {
|
||||
name = 'CrewLegYards3880000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_crew_assignments
|
||||
ADD COLUMN IF NOT EXISTS from_yard_id uuid REFERENCES freight.yards(id),
|
||||
ADD COLUMN IF NOT EXISTS to_yard_id uuid REFERENCES freight.yards(id)
|
||||
`);
|
||||
|
||||
// Backfill the three legacy segments onto real yards so historic rows keep
|
||||
// a usable leg. Matched by code; a deployment missing one simply leaves
|
||||
// those rows with a null leg, which the validator reports as incomplete.
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_crew_assignments a
|
||||
SET from_yard_id = f.id, to_yard_id = t.id
|
||||
FROM freight.yards f, freight.yards t
|
||||
WHERE a.segment = 'INDODE_DIRE_DAWA'
|
||||
AND a.from_yard_id IS NULL
|
||||
AND f.code = 'KALITY' AND t.code = 'DIRE_DAWA'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_crew_assignments a
|
||||
SET from_yard_id = f.id, to_yard_id = t.id
|
||||
FROM freight.yards f, freight.yards t
|
||||
WHERE a.segment = 'DIRE_DAWA_NAGAD'
|
||||
AND a.from_yard_id IS NULL
|
||||
AND f.code = 'DIRE_DAWA' AND t.code = 'NAGAD'
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
UPDATE freight.train_crew_assignments a
|
||||
SET from_yard_id = f.id, to_yard_id = t.id
|
||||
FROM freight.yards f, freight.yards t
|
||||
WHERE a.segment = 'FULL_CORRIDOR'
|
||||
AND a.from_yard_id IS NULL
|
||||
AND f.code = 'KALITY' AND t.code = 'NAGAD'
|
||||
`);
|
||||
|
||||
// One Primary per leg replaces one Primary per segment.
|
||||
await queryRunner.query(`
|
||||
DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_slot
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_leg
|
||||
ON freight.train_crew_assignments
|
||||
(train_schedule_id, from_yard_id, to_yard_id, duty_role)
|
||||
WHERE deleted_at IS NULL
|
||||
AND from_yard_id IS NOT NULL
|
||||
AND to_yard_id IS NOT NULL
|
||||
AND duty_role IS NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_crew_assignments_leg
|
||||
ON freight.train_crew_assignments (from_yard_id, to_yard_id)
|
||||
WHERE from_yard_id IS NOT NULL
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_crew_assignments_leg`);
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.uq_crew_assignments_driver_leg`);
|
||||
await queryRunner.query(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_crew_assignments_driver_slot
|
||||
ON freight.train_crew_assignments (train_schedule_id, segment, duty_role)
|
||||
WHERE deleted_at IS NULL AND segment IS NOT NULL AND duty_role IS NOT NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.train_crew_assignments
|
||||
DROP COLUMN IF EXISTS from_yard_id,
|
||||
DROP COLUMN IF EXISTS to_yard_id
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Empty container import is base rail freight for equipment carrying no cargo,
|
||||
* so it is sold per lane exactly like laden container freight.
|
||||
*
|
||||
* CK_rates_yard_scope gains EMPTY_CONTAINER in its yard-carrying branch: an
|
||||
* empty rate prices a leg (Djibouti -> Modjo), so both yards stay required.
|
||||
* Drop-and-recreate is the established shape for this constraint — see
|
||||
* 3430000000000-FuelSurcharge and 3640000000000-EthiopianCustomsClearance.
|
||||
*/
|
||||
export class EmptyContainerRateScope3890000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerRateScope3890000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'EMPTY_CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.rates DROP CONSTRAINT IF EXISTS "CK_rates_yard_scope"`,
|
||||
);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.rates ADD CONSTRAINT "CK_rates_yard_scope" CHECK (
|
||||
deleted_at IS NOT NULL OR status = 'SUPERSEDED' OR
|
||||
CASE
|
||||
WHEN (trigger = 'ALWAYS' AND applies_to IN ('BULK', 'CONTAINER', 'INTERCITY'))
|
||||
OR trigger IN ('CUSTOMS_CLEARANCE', 'ETHIOPIAN_CUSTOMS_CLEARANCE', 'WITH_RETURN', 'FUEL')
|
||||
THEN origin_yard_id IS NOT NULL AND destination_yard_id IS NOT NULL
|
||||
ELSE origin_yard_id IS NULL AND destination_yard_id IS NULL
|
||||
END
|
||||
)
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Whether a booking moves cargo or bare equipment.
|
||||
*
|
||||
* EMPTY is container freight carrying nothing — the box itself is the shipment,
|
||||
* priced per size and lane off an EMPTY_CONTAINER_IMPORT rate. Deliberately a
|
||||
* separate column rather than a third `freight_type`: an empty booking is still
|
||||
* CONTAINER freight for wagon footprint, yard and warehouse allocation, train
|
||||
* scheduling, marshalling and gate passes, and `freight_type` is read in ~880
|
||||
* places whose else-arm means "container".
|
||||
*
|
||||
* Every existing row is LADEN, which the default supplies — no backfill needed.
|
||||
*/
|
||||
export class BookingCargoCondition3900000000000 implements MigrationInterface {
|
||||
name = 'BookingCargoCondition3900000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"
|
||||
`);
|
||||
// Bulk carries no equipment of its own, so EMPTY only ever rides CONTAINER
|
||||
// freight. Enforced here so no API path can file the combination.
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.bookings
|
||||
ADD CONSTRAINT "CK_bookings_cargo_condition" CHECK (
|
||||
cargo_condition IN ('LADEN', 'EMPTY')
|
||||
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
|
||||
)
|
||||
`);
|
||||
|
||||
// The booking queues filter empties out of (and into) the laden lists.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_cargo_condition
|
||||
ON freight.bookings (cargo_condition)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DROP INDEX IF EXISTS freight.idx_bookings_cargo_condition`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP CONSTRAINT IF EXISTS "CK_bookings_cargo_condition"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS cargo_condition`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
import { CONTRACT_TEMPLATE_DEFAULTS } from '../seed/data/contract-template-defaults';
|
||||
|
||||
/**
|
||||
* Contract paper for empty container import.
|
||||
*
|
||||
* - contracts.cargo_condition mirrors bookings.cargo_condition, so a general
|
||||
* contract can commit to moving bare equipment.
|
||||
* - Seeds IMPORT_EMPTY_CONTAINER, the system template the document renderer
|
||||
* resolves for those contracts. It carries no customs variant: an empty box
|
||||
* has no declaration to clear, the same reason intercity is unsuffixed.
|
||||
*/
|
||||
const SEEDED_CODES = ['IMPORT_EMPTY_CONTAINER'] as const;
|
||||
|
||||
export class EmptyContainerContractTemplate3910000000000 implements MigrationInterface {
|
||||
name = 'EmptyContainerContractTemplate3910000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS cargo_condition varchar(10) NOT NULL DEFAULT 'LADEN'
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD CONSTRAINT "CK_contracts_cargo_condition" CHECK (
|
||||
cargo_condition IN ('LADEN', 'EMPTY')
|
||||
AND (cargo_condition = 'LADEN' OR freight_type = 'CONTAINER')
|
||||
)
|
||||
`);
|
||||
|
||||
for (const code of SEEDED_CODES) {
|
||||
const seed = CONTRACT_TEMPLATE_DEFAULTS.find((t) => t.code === code);
|
||||
if (!seed) throw new Error(`Missing contract template default for ${code}`);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO freight.contract_templates
|
||||
(id, code, name, description, document_title, whereas_clauses, articles,
|
||||
is_active, is_system, created_at, updated_at)
|
||||
SELECT gen_random_uuid(), $1::varchar, $2, $3, $4, $5::jsonb, $6::jsonb,
|
||||
true, true, now(), now()
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM freight.contract_templates
|
||||
WHERE code = $1::varchar AND deleted_at IS NULL
|
||||
)`,
|
||||
[
|
||||
seed.code,
|
||||
seed.name,
|
||||
seed.description,
|
||||
seed.documentTitle,
|
||||
JSON.stringify(seed.whereasClauses),
|
||||
JSON.stringify(
|
||||
seed.articles.map((article, index) => ({ ...article, order: index + 1 })),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM freight.contract_templates WHERE code = ANY($1::varchar[]) AND is_system = true`,
|
||||
[[...SEEDED_CODES]],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP CONSTRAINT IF EXISTS "CK_contracts_cargo_condition"`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS cargo_condition`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Customer-requested validity extension of an EXPIRED contract.
|
||||
*
|
||||
* Flow: the customer asks from the portal (`extension_requested_at` is stamped,
|
||||
* the reason lands in contract_review_notes as EXTENSION_REQUESTED), then staff
|
||||
* add days on the backoffice detail page and the contract returns to the status
|
||||
* it held before it lapsed. Both expiry paths (nightly sweep + lazy flip on
|
||||
* read) now stash that status in `status_before_expiry`, mirroring
|
||||
* `status_before_suspension`; rows expired before this column existed fall
|
||||
* back to the kind's resting status on extension.
|
||||
*
|
||||
* Also seeds `edr_freight_app:contracts:extend`. `FreightPositionsSeeder`
|
||||
* resolves every registry key against `iam.permissions` at boot and throws on
|
||||
* a missing row, so the catalog row must exist wherever the registry ships.
|
||||
* The grant is copied from whoever already holds `contracts:suspend` — the
|
||||
* registry places both keys on the same desk (marketing), and the position
|
||||
* seeder only re-syncs presets when SEED_EDR_ORG is set.
|
||||
*/
|
||||
export class ContractExtensionRequest3920000000000 implements MigrationInterface {
|
||||
name = 'ContractExtensionRequest3920000000000';
|
||||
|
||||
private static readonly KEY = 'edr_freight_app:contracts:extend';
|
||||
private static readonly ID = 'a3000001-0001-4000-8000-00000000001d';
|
||||
private static readonly SIBLING_KEY = 'edr_freight_app:contracts:suspend';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS extension_requested_at timestamptz NULL
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
ALTER TABLE freight.contracts
|
||||
ADD COLUMN IF NOT EXISTS status_before_expiry varchar(40) NULL
|
||||
`);
|
||||
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.permissions (id, key, name, application_id)
|
||||
SELECT $2::uuid,
|
||||
$1::varchar,
|
||||
'{"am": "Extend an expired contract", "en": "Extend an expired contract"}'::jsonb,
|
||||
a.id
|
||||
FROM iam.application a
|
||||
WHERE a.key = 'edr_freight_app'
|
||||
AND NOT EXISTS (SELECT 1 FROM iam.permissions p WHERE p.key = $1::varchar)`,
|
||||
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.ID],
|
||||
);
|
||||
|
||||
// Grant wherever suspend is already granted (positions and roles alike).
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.position_permissions (position_id, permission_id)
|
||||
SELECT pp.position_id, np.id
|
||||
FROM iam.position_permissions pp
|
||||
JOIN iam.permissions sp ON sp.id = pp.permission_id AND sp.key = $2::varchar
|
||||
JOIN iam.permissions np ON np.key = $1::varchar
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM iam.position_permissions x
|
||||
WHERE x.position_id = pp.position_id AND x.permission_id = np.id
|
||||
)`,
|
||||
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`INSERT INTO iam.role_permissions (role_id, permission_id)
|
||||
SELECT rp.role_id, np.id
|
||||
FROM iam.role_permissions rp
|
||||
JOIN iam.permissions sp ON sp.id = rp.permission_id AND sp.key = $2::varchar
|
||||
JOIN iam.permissions np ON np.key = $1::varchar
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM iam.role_permissions x
|
||||
WHERE x.role_id = rp.role_id AND x.permission_id = np.id
|
||||
)`,
|
||||
[ContractExtensionRequest3920000000000.KEY, ContractExtensionRequest3920000000000.SIBLING_KEY],
|
||||
);
|
||||
}
|
||||
|
||||
/** Grants go first, or the delete trips the permission foreign keys. */
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.position_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[ContractExtensionRequest3920000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(
|
||||
`DELETE FROM iam.role_permissions
|
||||
WHERE permission_id IN (SELECT id FROM iam.permissions WHERE key = $1)`,
|
||||
[ContractExtensionRequest3920000000000.KEY],
|
||||
);
|
||||
await queryRunner.query(`DELETE FROM iam.permissions WHERE key = $1`, [
|
||||
ContractExtensionRequest3920000000000.KEY,
|
||||
]);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS status_before_expiry`,
|
||||
);
|
||||
await queryRunner.query(
|
||||
`ALTER TABLE freight.contracts DROP COLUMN IF EXISTS extension_requested_at`,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user