Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight_feature/usermanagement

This commit is contained in:
Marshal
2026-07-30 11:31:02 +00:00
215 changed files with 15300 additions and 1450 deletions

View File

@@ -0,0 +1,39 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Double handling becomes an explicit per-booking decision instead of an
* implicit "every import" charge. Warehouse staff record Yes/No after
* unloading (whether the goods actually had to be re-handled); the
* DOUBLE_HANDLING_FEE rule only bills when the answer is Yes.
*
* NULL = not decided yet → no charge, and the UI shows "not set" so the
* operator is prompted. Existing rows stay NULL deliberately: back-billing a
* fee nobody confirmed would be wrong.
*/
export class AddBookingDoubleHandling2850000000000 implements MigrationInterface {
name = 'AddBookingDoubleHandling2850000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling boolean;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_at timestamptz;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings ADD COLUMN IF NOT EXISTS double_handling_set_by varchar(160);`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_by;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling_set_at;`,
);
await queryRunner.query(
`ALTER TABLE freight.bookings DROP COLUMN IF EXISTS double_handling;`,
);
}
}

View File

@@ -0,0 +1,35 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Per-truck detention clocks. Detention was timed once per last-mile leg
* (last_mile.arrived_at / delivered_at), so every truck on a multi-truck
* delivery shared one window and was billed identical days — wrong the moment
* two trucks arrive or return at different times.
*
* Deliberately NEW columns rather than reusing the existing per-truck
* arrived_at / departed_at on this table: those are WAREHOUSE gate-in/gate-out
* events stamped by release(), whereas detention runs from arrival at the
* DESTINATION until the truck is released/returned.
*
* Both nullable — a truck without its own window falls back to the leg-level
* timestamps, so legacy legs keep billing exactly as before.
*/
export class AddPerTruckDetentionWindow2860000000000 implements MigrationInterface {
name = 'AddPerTruckDetentionWindow2860000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
ADD COLUMN IF NOT EXISTS destination_arrived_at timestamptz,
ADD COLUMN IF NOT EXISTS returned_at timestamptz;
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.last_mile_vehicle_assignments
DROP COLUMN IF EXISTS returned_at,
DROP COLUMN IF EXISTS destination_arrived_at;
`);
}
}

View File

@@ -0,0 +1,28 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Livestock is billed and counted per head, not per ton — line it up with the
* other break-bulk cargo types (Machinery, Truck, Automobile) so bulk
* storage/demurrage fees charge per item instead of per ton for it.
*/
export class LivestockPerItem2900000000000 implements MigrationInterface {
name = "LivestockPerItem2900000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.cargo_types
SET unit_of_measure = 'PER_ITEM'
WHERE code = 'LIVESTOCK'
AND unit_of_measure IS DISTINCT FROM 'PER_ITEM'
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
UPDATE freight.cargo_types
SET unit_of_measure = 'PER_TON'
WHERE code = 'LIVESTOCK'
AND unit_of_measure IS DISTINCT FROM 'PER_TON'
`);
}
}

View File

@@ -0,0 +1,131 @@
import { MigrationInterface, QueryRunner } from "typeorm";
/**
* Indode's real 11-yard layout, plus the plumbing to auto-route a booking to
* the right yard by cargo type (and, for container yards, trade direction):
*
* - `warehouse_yards.direction` — IMPORT | EXPORT | BOTH | null. Only
* meaningful for CONTAINER_YARD, where import and export stacks are
* physically separate (Yard 5 vs Yard 6). Everything else takes cargo
* either way. A CONTAINER_YARD left at null/BOTH is a signal too: it means
* "not a customer cargo yard" — Yards 10/11 (service/equipment) are
* CONTAINER_YARD structurally but must never be offered for ordinary
* import/export cargo, so the frontend match requires an EXACT IMPORT/
* EXPORT direction hit for container freight rather than treating BOTH as
* a wildcard.
* - `warehouse_yard_cargo_types` — which cargo types a yard accepts (mirrors
* the existing `cargo_type_wagon_types` join table). Empty = open to any
* cargo type of the yard's structural type (additive, never restrictive
* by default), so this cannot break a yard nobody has configured yet.
*
* Three cargo types didn't exist yet (Fertilizer, Coffee, Tea) — added here
* so Yards 1 and 9 have a real mapping ready for when they reopen.
*/
export class IndodeYardsAndCargoRouting2990000000000 implements MigrationInterface {
name = "IndodeYardsAndCargoRouting2990000000000";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_yards
ADD COLUMN IF NOT EXISTS direction varchar(10)
`);
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.warehouse_yard_cargo_types (
yard_id uuid NOT NULL REFERENCES freight.warehouse_yards (id) ON DELETE CASCADE,
cargo_type_id uuid NOT NULL REFERENCES freight.cargo_types (id) ON DELETE CASCADE,
PRIMARY KEY (yard_id, cargo_type_id)
)
`);
// New cargo types Indode's yard list names but the catalog didn't have yet.
await queryRunner.query(`
INSERT INTO freight.cargo_types (code, cargo_type_name, unit_of_measure, is_active)
VALUES
('FERTILIZER', 'Fertilizer', 'PER_TON', true),
('COFFEE', 'Coffee', 'PER_TON', true),
('TEA', 'Tea', 'PER_TON', true)
ON CONFLICT (code) DO NOTHING
`);
// The 11 real yards at Indode Open Warehouse (code 'IOW').
await queryRunner.query(`
INSERT INTO freight.warehouse_yards
(warehouse_id, name, code, type, direction, status, is_active)
SELECT w.id, y.name, y.code, y.type, y.direction, y.status, y.status = 'ACTIVE'
FROM freight.warehouses w
CROSS JOIN (VALUES
('Y1', 'Bagged Cargo Discharge - Fertilizer', 'BULK_YARD', NULL, 'INACTIVE'),
('Y2', 'Break Bulk', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
('Y3', 'Ro-Ro / Pac', 'GENERAL_CARGO_YARD', NULL, 'ACTIVE'),
('Y4', 'Dry Bulk', 'BULK_YARD', NULL, 'INACTIVE'),
('Y5', 'Container Terminal - Import (Stack Area)', 'CONTAINER_YARD', 'IMPORT', 'ACTIVE'),
('Y6', 'Container Terminal - Export', 'CONTAINER_YARD', 'EXPORT', 'ACTIVE'),
('Y7', 'Cold Chain', 'COLD_STORAGE_YARD', NULL, 'INACTIVE'),
('Y8', 'Chemical', 'HAZARDOUS_YARD', NULL, 'INACTIVE'),
('Y9', 'Coffee and Tea', 'GENERAL_CARGO_YARD', NULL, 'INACTIVE'),
('Y10', 'Container Service Yard - Maintenance', 'CONTAINER_YARD', 'BOTH', 'ACTIVE'),
('Y11', 'Equipment (Empty Container)', 'CONTAINER_YARD', 'BOTH', 'ACTIVE')
) AS y(code, name, type, direction, status)
WHERE w.code = 'IOW'
ON CONFLICT (warehouse_id, code) DO NOTHING
`);
// One default zone per new yard, matching its yard's type — every existing
// yard (CY-1, CY-A) already follows this one-zone-per-yard shape.
await queryRunner.query(`
INSERT INTO freight.warehouse_zones (yard_id, name, code, type, status, is_active)
SELECT y.id, y.name || ' Zone 1', 'Z1',
CASE y.type
WHEN 'CONTAINER_YARD' THEN 'CONTAINER_ZONE'
WHEN 'COLD_STORAGE_YARD' THEN 'COLD_STORAGE_ZONE'
WHEN 'HAZARDOUS_YARD' THEN 'HAZARDOUS_ZONE'
WHEN 'BULK_YARD' THEN 'BULK_ZONE'
ELSE 'GENERAL_CARGO_ZONE'
END,
y.status, y.status = 'ACTIVE'
FROM freight.warehouse_yards y
JOIN freight.warehouses w ON w.id = y.warehouse_id
WHERE w.code = 'IOW' AND y.code LIKE 'Y%'
ON CONFLICT (yard_id, code) DO NOTHING
`);
// Cargo-type routing. Yards 5/6/10/11 (CONTAINER_YARD) are intentionally
// left with no rows — direction alone decides those, per the entity comment.
await queryRunner.query(`
INSERT INTO freight.warehouse_yard_cargo_types (yard_id, cargo_type_id)
SELECT y.id, ct.id
FROM freight.warehouses w
JOIN freight.warehouse_yards y ON y.warehouse_id = w.id
JOIN (VALUES
('Y1', 'FERTILIZER'),
('Y2', 'STEEL_BILLET'), ('Y2', 'PLASTIC_BARREL'), ('Y2', 'MACHINERY'), ('Y2', 'LIVESTOCK'),
('Y3', 'AUTOMOBILE'), ('Y3', 'TRUCK'),
('Y4', 'BARLY'), ('Y4', 'BEANS'), ('Y4', 'BULK'), ('Y4', 'CEREAL'),
('Y4', 'EDIBLE_OIL'), ('Y4', 'RICE'), ('Y4', 'SUGAR'), ('Y4', 'WHEAT'),
('Y7', 'PERISHABLE'),
('Y9', 'COFFEE'), ('Y9', 'TEA')
) AS m(yard_code, cargo_code) ON m.yard_code = y.code
JOIN freight.cargo_types ct ON ct.code = m.cargo_code
WHERE w.code = 'IOW'
ON CONFLICT (yard_id, cargo_type_id) DO NOTHING
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
DELETE FROM freight.warehouse_zones z
USING freight.warehouse_yards y, freight.warehouses w
WHERE z.yard_id = y.id AND y.warehouse_id = w.id
AND w.code = 'IOW' AND y.code LIKE 'Y%'
`);
await queryRunner.query(`
DELETE FROM freight.warehouse_yards y
USING freight.warehouses w
WHERE y.warehouse_id = w.id AND w.code = 'IOW' AND y.code LIKE 'Y%'
`);
// Cargo types and the join table are left in place — other data may have
// started referencing them since; dropping columns/tables is not reversible
// once real rows exist, and leaving them is harmless.
}
}