mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'freight/develop' into freight/style/ui-sync
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -29,3 +29,6 @@ coverage/
|
||||
*~
|
||||
\#*\#
|
||||
.\#*
|
||||
branch_structure.json
|
||||
temp_auto_push.bat
|
||||
temp_interactive_push.bat
|
||||
|
||||
@@ -35,8 +35,8 @@
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^11.4.2",
|
||||
"@nestjs/typeorm": "^11.0.1",
|
||||
"@tria-plc/api-common": "^1.4.3",
|
||||
"@tria-plc/iamapi-common": "^0.6.6",
|
||||
"@tria-plc/api-common": "file:../../local-packages/tria-plc-api-common-1.4.3.tgz",
|
||||
"@tria-plc/iamapi-common": "file:../../local-packages/tria-plc-iamapi-common-0.7.3.tgz",
|
||||
"amqp-connection-manager": "^5.0.0",
|
||||
"amqplib": "^2.0.1",
|
||||
"axios": "^1.16.1",
|
||||
|
||||
@@ -47,6 +47,10 @@ import { PricingDataSeeder } from "./seed/pricing-data.seeder";
|
||||
import { FileUploadSettingsSeeder } from "./seed/file-upload-settings.seeder";
|
||||
import { IndodeFacilitySeeder } from "./seed/indode-facility.seeder";
|
||||
import { Batch14TestDataSeeder } from "./seed/batch1-4-test-data.seeder";
|
||||
import { Batch5TestDataSeeder } from "./seed/batch5-test-data.seeder";
|
||||
import { Batch7TestDataSeeder } from "./seed/batch7-test-data.seeder";
|
||||
import { Batch8TestDataSeeder } from "./seed/batch8-test-data.seeder";
|
||||
import { WarehouseDemoSeeder } from "./seed/warehouse-demo.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
@@ -129,6 +133,10 @@ import { VehiclesModule } from './modules/vehicles/vehicles.module';
|
||||
DemoFreightDataSeeder,
|
||||
IndodeFacilitySeeder,
|
||||
Batch14TestDataSeeder,
|
||||
Batch5TestDataSeeder,
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -137,6 +145,14 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly edrOrgSeeder: EdrOrgSeeder,
|
||||
private readonly demoUsersSeeder: DemoUsersSeeder,
|
||||
private readonly freightStaffUsersSeeder: FreightStaffUsersSeeder,
|
||||
private readonly pricingDataSeeder: PricingDataSeeder,
|
||||
private readonly fileUploadSettingsSeeder: FileUploadSettingsSeeder,
|
||||
private readonly indodeFacilitySeeder: IndodeFacilitySeeder,
|
||||
private readonly batch14TestDataSeeder: Batch14TestDataSeeder,
|
||||
private readonly batch5TestDataSeeder: Batch5TestDataSeeder,
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -147,6 +163,16 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.edrOrgSeeder.run();
|
||||
await this.demoUsersSeeder.run();
|
||||
await this.freightStaffUsersSeeder.run();
|
||||
await this.pricingDataSeeder.run();
|
||||
await this.fileUploadSettingsSeeder.run();
|
||||
await this.indodeFacilitySeeder.run();
|
||||
await this.batch14TestDataSeeder.run();
|
||||
await this.batch5TestDataSeeder.run();
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
// FileUploadSettingsSeeder) are intentionally disabled — they stay
|
||||
// registered as providers but are not run. Re-inject + call .run() to enable.
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Full wagon re-seed — runs in this order:
|
||||
*
|
||||
* 1. DELETE all existing wagons (hard delete, not soft).
|
||||
* 2. UPSERT all 10 standard wagon types so they are guaranteed to exist.
|
||||
* 3. INSERT 50 wagons per wagon type (500 total), distributed evenly across
|
||||
* the 5 main operational yards (10 wagons per yard per type):
|
||||
*
|
||||
* KALITY — Kality Rail Terminal
|
||||
* MOJO — Mojo Dry Port
|
||||
* DIRE_DAWA — Dire Dawa Yard
|
||||
* DJIB_PORT — Djibouti Port Terminal
|
||||
* NAGAD — Nagad Terminal, Djibouti
|
||||
*
|
||||
* Wagon numbers follow the pattern <TYPE_CODE>-NNNN (e.g. NW5-0001 … NW5-0050).
|
||||
* Yard IDs are fetched live from freight.yards so the migration is safe across
|
||||
* all environments regardless of UUID values.
|
||||
*/
|
||||
export class SeedWagonsWithYardAssignment1784000000001
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'SeedWagonsWithYardAssignment1784000000001';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
// ── STEP 1: Remove all wagons ──────────────────────────────────────────
|
||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||
|
||||
// ── STEP 2: Ensure all 10 wagon types exist ────────────────────────────
|
||||
await queryRunner.query(`
|
||||
INSERT INTO freight.wagon_types (
|
||||
code,
|
||||
name,
|
||||
capacity_tons,
|
||||
length_meters,
|
||||
max_wagons_per_train,
|
||||
supported_load_types,
|
||||
is_active,
|
||||
tare_weight_tons
|
||||
)
|
||||
VALUES
|
||||
('NW7', 'Double deck sedan wagon', 22, 26.066, NULL, ARRAY['vehicles', 'sedan'], true, 18.0),
|
||||
('NW5', 'Flat wagon (container)', 70, 14.000, 53, ARRAY['container', 'steel', 'machinery'], true, 22.0),
|
||||
('PW2', 'Box wagon', 70, 17.066, 18, ARRAY['general cargo', 'break bulk'], true, 20.0),
|
||||
('GW2', 'Tank wagon', 70, 12.228, 37, ARRAY['liquid', 'fuel'], true, 25.0),
|
||||
('CW4', 'Gondola covered wagon', 70, 13.976, 37, ARRAY['covered bulk cargo'], true, 22.0),
|
||||
('CW3', 'Gondola open wagon', 70, 13.976, NULL, ARRAY['open bulk cargo'], true, 20.0),
|
||||
('KW2', 'Hopper covered wagon', 69, 16.466, NULL, ARRAY['bulk grains'], true, 22.0),
|
||||
('KW3', 'Hopper open wagon', 70, 14.400, NULL, ARRAY['coal', 'bulk cargo'], true, 20.0),
|
||||
('NW6', 'Flat wagon (long cargo)', 70, 18.560, NULL, ARRAY['long cargo'], true, 22.0),
|
||||
('BW1', 'Refrigerated wagon', 38, 21.996, NULL, ARRAY['refrigerated cargo'], true, 24.0)
|
||||
ON CONFLICT (code) DO UPDATE SET
|
||||
name = EXCLUDED.name,
|
||||
capacity_tons = EXCLUDED.capacity_tons,
|
||||
length_meters = EXCLUDED.length_meters,
|
||||
max_wagons_per_train = EXCLUDED.max_wagons_per_train,
|
||||
supported_load_types = EXCLUDED.supported_load_types,
|
||||
is_active = true,
|
||||
tare_weight_tons = EXCLUDED.tare_weight_tons,
|
||||
deleted_at = NULL,
|
||||
updated_at = now();
|
||||
`);
|
||||
|
||||
// ── STEP 3: Seed 50 wagons per type across 5 yards ────────────────────
|
||||
await queryRunner.query(`
|
||||
DO $$
|
||||
DECLARE
|
||||
wt RECORD;
|
||||
yard_kality UUID;
|
||||
yard_mojo UUID;
|
||||
yard_dire_dawa UUID;
|
||||
yard_djib_port UUID;
|
||||
yard_nagad UUID;
|
||||
yards UUID[];
|
||||
i INT;
|
||||
yard_id UUID;
|
||||
wagon_num TEXT;
|
||||
v_tare NUMERIC;
|
||||
v_payload NUMERIC;
|
||||
BEGIN
|
||||
-- Fetch yard IDs by code (safe across envs — UUIDs differ per DB)
|
||||
SELECT id INTO yard_kality FROM freight.yards WHERE code = 'KALITY' LIMIT 1;
|
||||
SELECT id INTO yard_mojo FROM freight.yards WHERE code = 'MOJO' LIMIT 1;
|
||||
SELECT id INTO yard_dire_dawa FROM freight.yards WHERE code = 'DIRE_DAWA' LIMIT 1;
|
||||
SELECT id INTO yard_djib_port FROM freight.yards WHERE code = 'DJIB_PORT' LIMIT 1;
|
||||
SELECT id INTO yard_nagad FROM freight.yards WHERE code = 'NAGAD' LIMIT 1;
|
||||
|
||||
IF yard_kality IS NULL OR yard_mojo IS NULL OR yard_dire_dawa IS NULL
|
||||
OR yard_djib_port IS NULL OR yard_nagad IS NULL
|
||||
THEN
|
||||
RAISE EXCEPTION 'One or more operational yards not found. Run the yards seed first.';
|
||||
END IF;
|
||||
|
||||
yards := ARRAY[
|
||||
yard_kality,
|
||||
yard_mojo,
|
||||
yard_dire_dawa,
|
||||
yard_djib_port,
|
||||
yard_nagad
|
||||
];
|
||||
|
||||
FOR wt IN
|
||||
SELECT id, code, capacity_tons, tare_weight_tons
|
||||
FROM freight.wagon_types
|
||||
WHERE is_active = true
|
||||
ORDER BY code
|
||||
LOOP
|
||||
v_tare := COALESCE(wt.tare_weight_tons, 20.0);
|
||||
v_payload := COALESCE(wt.capacity_tons, 60.0);
|
||||
|
||||
FOR i IN 1 .. 50 LOOP
|
||||
wagon_num := wt.code || '-' || LPAD(i::TEXT, 4, '0');
|
||||
yard_id := yards[ ((i - 1) % 5) + 1 ]; -- round-robin: 1→K, 2→M, 3→D, 4→J, 5→N, 6→K …
|
||||
|
||||
INSERT INTO freight.wagons (
|
||||
id,
|
||||
wagon_number,
|
||||
wagon_type_id,
|
||||
tare_weight,
|
||||
max_payload_weight,
|
||||
status,
|
||||
current_yard_id,
|
||||
train_id,
|
||||
sequence_number,
|
||||
notes,
|
||||
train_set_wagon_id,
|
||||
current_train_schedule_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (
|
||||
uuid_generate_v4(),
|
||||
wagon_num,
|
||||
wt.id,
|
||||
v_tare,
|
||||
v_payload,
|
||||
'Available',
|
||||
yard_id,
|
||||
NULL, NULL, NULL, NULL, NULL,
|
||||
now(), now()
|
||||
)
|
||||
ON CONFLICT (wagon_number) DO NOTHING;
|
||||
END LOOP;
|
||||
|
||||
RAISE NOTICE 'Seeded 50 wagons for type %.', wt.code;
|
||||
END LOOP;
|
||||
END $$;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
// Remove all seeded wagons (full wipe — mirrors what up() did)
|
||||
await queryRunner.query(`DELETE FROM freight.wagons;`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Day-level booking pool: customers select a DAY (route + day), not a specific
|
||||
* train. The batch engine's pool query filters bookings on
|
||||
* (origin_yard_id, destination_yard_id, scheduled_date, status); this partial
|
||||
* index backs that scan.
|
||||
*/
|
||||
export class AddBookingRouteDayIndex1784100000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_bookings_route_day
|
||||
ON freight.bookings (origin_yard_id, destination_yard_id, scheduled_date, status)
|
||||
WHERE deleted_at IS NULL;
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS freight.idx_bookings_route_day;`);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ import { MigrationInterface, QueryRunner, Table, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 5 — warehouse allocation rules, storage/demurrage fee rules,
|
||||
* and demurrage lifecycle timestamps on inventory.
|
||||
* and demurrage lifecycle timestamps on inventory. Idempotent.
|
||||
*/
|
||||
export class AddWarehouseAllocationAndFeeRules1790000000000 implements MigrationInterface {
|
||||
export class AddWarehouseAllocationAndFeeRules1791000000000 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
|
||||
|
||||
/** Batch 6 — warehouse fee invoices + invoice items. */
|
||||
export class AddWarehouseFeeInvoices1790000000001 implements MigrationInterface {
|
||||
/** Batch 6 — warehouse fee invoices + invoice items. Idempotent (createTable ifNotExists). */
|
||||
export class AddWarehouseFeeInvoices1791000000001 implements MigrationInterface {
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Import pickup branch on warehouse_inventory:
|
||||
* - release_order_reference: DO / release order number sent to the customer
|
||||
* - delivered_at: when the goods were handed over (proof of delivery)
|
||||
*
|
||||
* Idempotent: the shared dev DB may already carry some of these columns
|
||||
* (added by another checkout), so only add what is missing.
|
||||
*/
|
||||
export class AddImportPickupDeliveryColumns1791000000002 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'release_order_reference'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'release_order_reference', type: 'varchar', length: '100', isNullable: true }),
|
||||
);
|
||||
}
|
||||
if (!(await queryRunner.hasColumn(this.table, 'delivered_at'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'delivered_at', type: 'timestamptz', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn(this.table, 'release_order_reference')) {
|
||||
await queryRunner.dropColumn(this.table, 'release_order_reference');
|
||||
}
|
||||
if (await queryRunner.hasColumn(this.table, 'delivered_at')) {
|
||||
await queryRunner.dropColumn(this.table, 'delivered_at');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Batch 8 — train-arrival unload landing state on warehouse_inventory:
|
||||
* - unloaded_at: when the goods were unloaded off the arrived train (before storage/inspection)
|
||||
*
|
||||
* The `status` column is a free varchar, so the new 'UNLOADED' value needs no schema change.
|
||||
* Idempotent: the shared dev DB may already carry this column (added by another checkout).
|
||||
*/
|
||||
export class AddInventoryUnloadedAt1791000000003 implements MigrationInterface {
|
||||
private readonly table = 'freight.warehouse_inventory';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn(this.table, 'unloaded_at'))) {
|
||||
await queryRunner.addColumn(
|
||||
this.table,
|
||||
new TableColumn({ name: 'unloaded_at', type: 'timestamptz', isNullable: true }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn(this.table, 'unloaded_at')) {
|
||||
await queryRunner.dropColumn(this.table, 'unloaded_at');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -689,6 +689,11 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
destinationStationId?: string;
|
||||
schedulingStatus?: string;
|
||||
trainScheduleId?: string;
|
||||
/**
|
||||
* EAT calendar day (yyyy-MM-dd). With day-level pooling the staff wizard sees
|
||||
* the whole (route, day) pool rather than bookings pre-targeted to one train.
|
||||
*/
|
||||
day?: string;
|
||||
}): Promise<Booking[]> {
|
||||
const qb = this.repository
|
||||
.createQueryBuilder('booking')
|
||||
@@ -706,9 +711,16 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.where('booking.status = :paidStatus', { paidStatus: 'PAID' })
|
||||
.andWhere('scheduleBooking.id IS NULL');
|
||||
|
||||
// Mirror the automatic batch pool: a schedule only ever considers bookings that
|
||||
// targeted THAT schedule (same as findBatchPool's train_schedule_id filter).
|
||||
if (options.trainScheduleId) {
|
||||
// Day-level pooling: customers no longer set train_schedule_id, so the wizard
|
||||
// surfaces the whole (route, EAT day) pool. Fall back to the legacy
|
||||
// single-schedule filter only when no day is supplied (e.g. a staff-pinned
|
||||
// booking that still carries train_schedule_id).
|
||||
if (options.day) {
|
||||
qb.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day: options.day },
|
||||
);
|
||||
} else if (options.trainScheduleId) {
|
||||
qb.andWhere('booking.train_schedule_id = :trainScheduleId', {
|
||||
trainScheduleId: options.trainScheduleId,
|
||||
});
|
||||
@@ -765,6 +777,43 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level batch pool: ready, not-yet-allocated bookings on a route for one
|
||||
* EAT calendar day, regardless of which train they end up on. Same status
|
||||
* rules and ordering as {@link findBatchPool}, but keyed on
|
||||
* (origin, destination, day) instead of train_schedule_id — the engine then
|
||||
* distributes these across all trains departing that day.
|
||||
*/
|
||||
findBatchPoolByRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<Booking[]> {
|
||||
return this.repository
|
||||
.createQueryBuilder('booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('booking.bookingContainers', 'bookingContainer')
|
||||
.leftJoin(TrainScheduleBooking, 'sb', 'sb.booking_id = booking.id')
|
||||
.where('booking.origin_yard_id = :originYardId', { originYardId })
|
||||
.andWhere('booking.destination_yard_id = :destinationYardId', {
|
||||
destinationYardId,
|
||||
})
|
||||
.andWhere(
|
||||
`DATE(booking.scheduled_date AT TIME ZONE 'Africa/Addis_Ababa') = :day`,
|
||||
{ day },
|
||||
)
|
||||
.andWhere('sb.id IS NULL')
|
||||
.andWhere(
|
||||
`((booking.is_government = false AND booking.status = 'FULLY_EXECUTED')
|
||||
OR (booking.is_government = true AND booking.status IN ('APPROVED','PAID')))`,
|
||||
)
|
||||
.orderBy('booking.is_government', 'DESC')
|
||||
.addOrderBy('booking.priority_score', 'DESC')
|
||||
.addOrderBy('booking.fully_executed_at', 'ASC')
|
||||
.addOrderBy('booking.created_at', 'ASC')
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/** Every booking that targeted a schedule (any status) — for the batch monitoring board. */
|
||||
findAllBySchedule(scheduleId: string): Promise<Booking[]> {
|
||||
return this.repository
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Freight, SchedulingStatus } from '@edr/types';
|
||||
// import { CustomersService } from '../customers/customers.service';
|
||||
import { CompaniesService } from '../companies/companies.service';
|
||||
import { TrainSchedulingService } from '../train-scheduling/train-scheduling.service';
|
||||
import { eatDay } from '../train-scheduling/batch-window.util';
|
||||
import { FilesService } from '../files/files.service';
|
||||
import { MinioService } from '../minio/minio.service';
|
||||
import { ContainerTypesService } from '../rule-engine/services/container-types.service';
|
||||
@@ -273,8 +274,8 @@ export class BookingsService {
|
||||
companyId = company.id;
|
||||
}
|
||||
|
||||
// Schedule targeting: when provided, the schedule must be OPEN and on the same route.
|
||||
if (dto.trainScheduleId) {
|
||||
// Staff manual pin: the schedule must be OPEN and on the same route.
|
||||
const schedule = await this.dataSource
|
||||
.getRepository(TrainSchedule)
|
||||
.findOne({ where: { id: dto.trainScheduleId } });
|
||||
@@ -290,6 +291,22 @@ export class BookingsService {
|
||||
) {
|
||||
throw new BadRequestException('Selected schedule is not on the booking route');
|
||||
}
|
||||
} else {
|
||||
// Day-level pool: the customer picked a DAY — require that the route has at
|
||||
// least one OPEN departure on that EAT day. The batch engine assigns the
|
||||
// train later.
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const hasDeparture =
|
||||
await this.trainSchedulingService.existsOpenScheduleOnRouteDay(
|
||||
dto.originYardId,
|
||||
dto.destinationYardId,
|
||||
day,
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reference = dto.reference || (await this.generateReference());
|
||||
|
||||
@@ -91,12 +91,21 @@ export class CreateBookingDto {
|
||||
@IsUUID()
|
||||
trainId?: string;
|
||||
|
||||
/** Target schedule this booking is created against (required by the backoffice create form). */
|
||||
@ApiPropertyOptional({ format: 'uuid', description: 'Target train schedule (pool membership)' })
|
||||
/**
|
||||
* Staff-only manual pin to a specific train. Customers omit this — they pick a
|
||||
* DAY via {@link scheduledDate} and the batch engine assigns a train within
|
||||
* that (route, day) pool. When provided, the schedule must be OPEN and on the
|
||||
* booking route.
|
||||
*/
|
||||
@ApiPropertyOptional({
|
||||
format: 'uuid',
|
||||
description: 'Staff only: pin to a specific train schedule. Customers omit this.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
trainScheduleId?: string;
|
||||
|
||||
/** The day the customer wants to ship (the pool day key). */
|
||||
@ApiProperty({ example: '2026-06-15T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
|
||||
@@ -280,7 +280,15 @@ export class Booking extends BaseEntity {
|
||||
@Column({ name: 'scheduled_at', type: 'timestamptz', nullable: true })
|
||||
scheduledAt?: Date | null;
|
||||
|
||||
/** The schedule this booking targets (pool membership), set at creation. FK to train_schedules. */
|
||||
/**
|
||||
* The train this booking is assigned to. FK to train_schedules.
|
||||
*
|
||||
* Day-level pooling: customers no longer pick a train — they pick a DAY, and
|
||||
* this stays null at creation. The batch engine sets it when it assigns the
|
||||
* booking to a specific train within its (route, day) pool; staff may also
|
||||
* pin it manually. The day-level pool is keyed on
|
||||
* (origin_yard_id, destination_yard_id, day of scheduled_date), not this column.
|
||||
*/
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid', nullable: true })
|
||||
trainScheduleId?: string | null;
|
||||
|
||||
|
||||
@@ -55,6 +55,16 @@ function eatParts(date: Date): EatDateParts {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The EAT calendar day a timestamp falls on, as `yyyy-MM-dd`. This is the day
|
||||
* key for day-level booking pools — it must match the day the portal calendar
|
||||
* renders, so always derive day keys through this (never `toISOString().slice`).
|
||||
*/
|
||||
export function eatDay(date: Date): string {
|
||||
const { year, month, day } = eatParts(date);
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Build a UTC Date for a given EAT local wall-clock time on a calendar day. */
|
||||
function eatToUtc(
|
||||
year: number,
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
let bookingsRepository: {
|
||||
findPaidUnlinkedForSchedule: jest.Mock;
|
||||
findBatchPool: jest.Mock;
|
||||
findBatchPoolByRouteDay: jest.Mock;
|
||||
findReservedForSchedule: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
@@ -33,16 +34,24 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
};
|
||||
let trainSchedulingService: {
|
||||
tryAutoWagonAllocation: jest.Mock;
|
||||
getBookableSchedules: jest.Mock;
|
||||
};
|
||||
let dataSource: {
|
||||
getRepository: jest.Mock;
|
||||
transaction: jest.Mock;
|
||||
};
|
||||
let notifier: {
|
||||
payNow: jest.Mock;
|
||||
secured: jest.Mock;
|
||||
expired: jest.Mock;
|
||||
unplaced: jest.Mock;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
bookingsRepository = {
|
||||
findPaidUnlinkedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
findBatchPool: jest.fn().mockResolvedValue([]),
|
||||
findBatchPoolByRouteDay: jest.fn().mockResolvedValue([]),
|
||||
findReservedForSchedule: jest.fn().mockResolvedValue([]),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
@@ -67,11 +76,14 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
issues: [],
|
||||
violations: [],
|
||||
}),
|
||||
getBookableSchedules: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const bookingRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(paidBooking),
|
||||
update: jest.fn().mockResolvedValue(undefined),
|
||||
// WagonType.find() / global-rules find() fall back to defaults when empty.
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
dataSource = {
|
||||
getRepository: jest.fn().mockReturnValue(bookingRepo),
|
||||
@@ -83,12 +95,19 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
}),
|
||||
};
|
||||
|
||||
notifier = {
|
||||
payNow: jest.fn(),
|
||||
secured: jest.fn(),
|
||||
expired: jest.fn(),
|
||||
unplaced: jest.fn(),
|
||||
};
|
||||
|
||||
service = new BookingBatchService(
|
||||
dataSource as never,
|
||||
bookingsRepository as never,
|
||||
trainSchedulesRepository as never,
|
||||
trainScheduleBookingsRepository as never,
|
||||
{ payNow: jest.fn(), secured: jest.fn(), expired: jest.fn() } as never,
|
||||
notifier as never,
|
||||
{ addTimeout: jest.fn(), deleteTimeout: jest.fn(), doesExist: jest.fn() } as never,
|
||||
trainSchedulingService as never,
|
||||
);
|
||||
@@ -141,4 +160,96 @@ describe('BookingBatchService — PAID reconcile', () => {
|
||||
expect(fillOrder).toBeLessThan(reconcileOrder);
|
||||
expect(reconcileOrder).toBeLessThan(wagonOrder);
|
||||
});
|
||||
|
||||
describe('fillRouteDay — day-level distribution', () => {
|
||||
const originYardId = 'yard-origin';
|
||||
const destinationYardId = 'yard-dest';
|
||||
const day = '2026-06-20';
|
||||
// 06:00Z and 09:00Z on 2026-06-20 both land on the same EAT day.
|
||||
const trainA = 'train-a';
|
||||
const trainB = 'train-b';
|
||||
|
||||
// A tiny locomotive: default wagon = 14m / 70t → exactly 1 wagon slot fits.
|
||||
const smallLoco = { maxPullWeightTons: 70, maxTrainLengthMeters: 14 };
|
||||
|
||||
const commercial = (id: string, priority: number): Booking =>
|
||||
({
|
||||
id,
|
||||
reference: id,
|
||||
isGovernment: false,
|
||||
priorityScore: priority,
|
||||
status: 'FULLY_EXECUTED',
|
||||
wagonsRequired: 1,
|
||||
cargoTotalWeightVgm: 10,
|
||||
freightType: 'CONTAINER',
|
||||
bookingContainers: [],
|
||||
}) as unknown as Booking;
|
||||
|
||||
beforeEach(() => {
|
||||
// Two OPEN trains on the same route + day, train A earlier than train B.
|
||||
trainSchedulingService.getBookableSchedules.mockResolvedValue([
|
||||
{
|
||||
id: trainA,
|
||||
scheduleDate: '2026-06-20T06:00:00.000Z',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
{
|
||||
id: trainB,
|
||||
scheduleDate: '2026-06-20T09:00:00.000Z',
|
||||
bookingWindowStatus: 'OPEN',
|
||||
},
|
||||
]);
|
||||
trainSchedulesRepository.findByIdWithFullGraph.mockImplementation((id: string) =>
|
||||
Promise.resolve({
|
||||
id,
|
||||
maxWagons: 1,
|
||||
bookingWindowStatus: 'OPEN',
|
||||
trainSetId: `set-${id}`,
|
||||
trainSet: { locomotive: smallLoco },
|
||||
scheduleBookings: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('spills overflow to the next train by priority, then reports unplaced', async () => {
|
||||
// 3 commercial bookings, descending priority; only 1 fits per train (2 total).
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([
|
||||
commercial('hi', 30),
|
||||
commercial('mid', 20),
|
||||
commercial('lo', 10),
|
||||
]);
|
||||
|
||||
const touched = await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
expect(bookingsRepository.findBatchPoolByRouteDay).toHaveBeenCalledWith(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
// Both trains were processed.
|
||||
expect(touched).toEqual([trainA, trainB]);
|
||||
// Highest priority reserved on train A, next on train B (commercial → reserve).
|
||||
const reservedOn = notifier.payNow.mock.calls.map((c) => (c[0] as Booking).id);
|
||||
expect(reservedOn).toEqual(['hi', 'mid']);
|
||||
// The third booking fits no train and is reported unplaced (and only it).
|
||||
expect(notifier.unplaced).toHaveBeenCalledTimes(1);
|
||||
expect((notifier.unplaced.mock.calls[0][0] as Booking).id).toBe('lo');
|
||||
expect(notifier.unplaced.mock.calls[0][1]).toBe(day);
|
||||
});
|
||||
|
||||
it('reserves the chosen train id on each commercial booking', async () => {
|
||||
bookingsRepository.findBatchPoolByRouteDay.mockResolvedValue([commercial('hi', 30)]);
|
||||
|
||||
await service.fillRouteDay(originYardId, destinationYardId, day);
|
||||
|
||||
// reserve() persists trainScheduleId so the settle lifecycle can find the train.
|
||||
expect(bookingsRepository.update).toHaveBeenCalledWith(
|
||||
'hi',
|
||||
expect.objectContaining({
|
||||
trainScheduleId: trainA,
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ import { TrainScheduleBookingsRepository } from '../train-schedules/train-schedu
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { BookingNotifierService } from './booking-notifier.service';
|
||||
import { TrainSchedulingService } from './train-scheduling.service';
|
||||
import { groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import { eatDay, groupBookingsIntoBoardWindows } from './batch-window.util';
|
||||
import {
|
||||
BATCH_CRON,
|
||||
BATCH_TIMEZONE,
|
||||
@@ -42,6 +42,14 @@ interface Capacity {
|
||||
lengthMeters: number;
|
||||
}
|
||||
|
||||
/** A day-level pool key: all trains on this route departing on this EAT day. */
|
||||
interface RouteDayGroup {
|
||||
originYardId: string;
|
||||
destinationYardId: string;
|
||||
/** EAT calendar day, `yyyy-MM-dd`. */
|
||||
day: string;
|
||||
}
|
||||
|
||||
type WagonLengths = { container: number; bulk: number };
|
||||
|
||||
export type BatchBoardBookingState =
|
||||
@@ -173,16 +181,16 @@ export class BookingBatchService implements OnModuleInit {
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
|
||||
/** On boot, reconcile OPEN schedules and re-arm settle timers. */
|
||||
/** On boot, reconcile OPEN route-days and re-arm settle timers. */
|
||||
async onModuleInit(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
for (const s of open) {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
await this.processRouteDay(group);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Boot reconcile failed for ${s.id}: ${(err as Error).message}`);
|
||||
this.logger.warn(
|
||||
`Boot reconcile failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const reserved = await this.dataSource
|
||||
@@ -195,13 +203,48 @@ export class BookingBatchService implements OnModuleInit {
|
||||
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
|
||||
}
|
||||
|
||||
/** Fire-and-forget batch pipeline for a schedule (contract sign, cron, payment). */
|
||||
/**
|
||||
* Fire-and-forget batch pipeline for the (route, day) a schedule belongs to
|
||||
* (contract sign, payment). Day-level pooling distributes across all of that
|
||||
* day's trains, so a single schedule id maps to its whole route-day group.
|
||||
*/
|
||||
enqueueScheduleProcessing(scheduleId: string): void {
|
||||
void this.processSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(`processSchedule ${scheduleId} failed: ${(err as Error).message}`),
|
||||
void this.processRouteDayForSchedule(scheduleId).catch((err) =>
|
||||
this.logger.error(
|
||||
`processRouteDay for schedule ${scheduleId} failed: ${(err as Error).message}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve a schedule's (route, day) group and run the day-level pipeline. */
|
||||
private async processRouteDayForSchedule(scheduleId: string): Promise<void> {
|
||||
const schedule = await this.trainSchedulesRepository.findById(scheduleId);
|
||||
if (!schedule?.scheduledDepartureDate) return;
|
||||
await this.processRouteDay({
|
||||
originYardId: schedule.originStationId,
|
||||
destinationYardId: schedule.destinationStationId,
|
||||
day: eatDay(schedule.scheduledDepartureDate),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level pipeline: distribute the (route, day) pool across all its trains,
|
||||
* then settle / reconcile / assign wagons per schedule (those steps stay
|
||||
* schedule-scoped — only the fill is day-level).
|
||||
*/
|
||||
async processRouteDay(group: RouteDayGroup): Promise<void> {
|
||||
const scheduleIds = await this.fillRouteDay(
|
||||
group.originYardId,
|
||||
group.destinationYardId,
|
||||
group.day,
|
||||
);
|
||||
for (const scheduleId of scheduleIds) {
|
||||
await this.settleDueReservations(scheduleId);
|
||||
await this.reconcilePaidUnlinked(scheduleId);
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Fill pool, settle due reservations, link orphaned PAID, then assign wagons. */
|
||||
async processSchedule(scheduleId: string): Promise<void> {
|
||||
await this.fillSchedule(scheduleId);
|
||||
@@ -210,6 +253,31 @@ export class BookingBatchService implements OnModuleInit {
|
||||
await this.trainSchedulingService.tryAutoWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Distinct (origin, destination, EAT day) groups across all OPEN schedules. */
|
||||
private async openRouteDayGroups(): Promise<RouteDayGroup[]> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
const groups = new Map<string, RouteDayGroup>();
|
||||
for (const s of open) {
|
||||
if (!s.scheduledDepartureDate) continue;
|
||||
const day = eatDay(s.scheduledDepartureDate);
|
||||
const key = `${s.originStationId}|${s.destinationStationId}|${day}`;
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
originYardId: s.originStationId,
|
||||
destinationYardId: s.destinationStationId,
|
||||
day,
|
||||
});
|
||||
}
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
private groupLabel(group: RouteDayGroup): string {
|
||||
return `${group.originYardId}→${group.destinationYardId} on ${group.day}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Idempotent: link a paid batch booking to its schedule and assign wagons.
|
||||
* Handles SELECTED_FOR_BATCH, PAID-without-link, and PAID-already-linked cases.
|
||||
@@ -289,15 +357,15 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
@Cron(BATCH_CRON, { name: 'booking-batch-fill', timeZone: BATCH_TIMEZONE })
|
||||
async runBatchFill(): Promise<void> {
|
||||
const open = await this.trainSchedulesRepository.findAll({
|
||||
where: { bookingWindowStatus: 'OPEN' },
|
||||
});
|
||||
this.logger.log(`Batch fill: ${open.length} OPEN schedule(s).`);
|
||||
for (const s of open) {
|
||||
const groups = await this.openRouteDayGroups();
|
||||
this.logger.log(`Batch fill: ${groups.length} OPEN route-day group(s).`);
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.processSchedule(s.id);
|
||||
await this.processRouteDay(group);
|
||||
} catch (err) {
|
||||
this.logger.error(`Batch fill failed for ${s.id}: ${(err as Error).message}`);
|
||||
this.logger.error(
|
||||
`Batch fill failed for ${this.groupLabel(group)}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,7 +679,7 @@ export class BookingBatchService implements OnModuleInit {
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(scheduleId, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking);
|
||||
await this.reserve(booking, scheduleId);
|
||||
armed = true;
|
||||
}
|
||||
budget = this.subtract(budget, need);
|
||||
@@ -623,6 +691,106 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Distribute one (route, day) pool across ALL of that day's OPEN trains, by
|
||||
* priority, filling each train (earliest departure first) until it's full and
|
||||
* spilling overflow to the next. Government bookings that fit no train preempt
|
||||
* lower-priority commercial; bookings that fit no train at all stay pending and
|
||||
* trigger a staff `unplaced` warning. Returns the schedule ids that were touched
|
||||
* (or that had remaining pool work) so the caller can settle them per-schedule.
|
||||
*/
|
||||
async fillRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<string[]> {
|
||||
// The day's OPEN bookable schedules on this exact corridor, earliest first.
|
||||
const bookable = await this.trainSchedulingService.getBookableSchedules(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const scheduleIds = bookable
|
||||
.filter(
|
||||
(s) =>
|
||||
s.bookingWindowStatus === 'OPEN' &&
|
||||
s.scheduleDate != null &&
|
||||
eatDay(new Date(s.scheduleDate)) === day,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(a.scheduleDate).getTime() - new Date(b.scheduleDate).getTime(),
|
||||
)
|
||||
.map((s) => s.id);
|
||||
|
||||
if (scheduleIds.length === 0) return [];
|
||||
|
||||
const rules = await this.loadGlobalRules();
|
||||
const wagonLengths = await this.loadWagonLengths();
|
||||
|
||||
// Live per-schedule budget + arm flag, in departure order.
|
||||
const trains: Array<{ id: string; budget: Capacity; armed: boolean }> = [];
|
||||
for (const id of scheduleIds) {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(id);
|
||||
const locomotive = schedule?.trainSet?.locomotive;
|
||||
if (!schedule || !schedule.trainSetId || !locomotive) {
|
||||
this.logger.warn(`Schedule ${id} has no locomotive/train set — skipped.`);
|
||||
continue;
|
||||
}
|
||||
const limits = await this.capacityLimits(locomotive, rules);
|
||||
await this.syncScheduleMaxWagons(schedule, locomotive, rules);
|
||||
const budget = await this.remainingCapacity(schedule, limits, wagonLengths);
|
||||
trains.push({ id, budget, armed: false });
|
||||
}
|
||||
if (trains.length === 0) return [];
|
||||
|
||||
const pool = await this.bookingsRepository.findBatchPoolByRouteDay(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
);
|
||||
|
||||
for (const booking of pool) {
|
||||
const need = this.needFor(booking, wagonLengths);
|
||||
|
||||
// First train (earliest departure) that fits this booking as-is.
|
||||
let target = trains.find((t) => this.fits(need, t.budget));
|
||||
|
||||
if (!target && booking.isGovernment) {
|
||||
// Government booking fits nowhere on its own — try to preempt commercial
|
||||
// on each train (earliest first) until one frees enough room.
|
||||
for (const t of trains) {
|
||||
t.budget = await this.preemptForGovernment(t.id, need, t.budget, wagonLengths);
|
||||
if (this.fits(need, t.budget)) {
|
||||
target = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
// Fits no train this day — stays in the pool, retried next batch.
|
||||
this.notifier.unplaced(booking, day);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (booking.isGovernment) {
|
||||
await this.allocate(target.id, booking, 'gov');
|
||||
} else {
|
||||
await this.reserve(booking, target.id);
|
||||
target.armed = true;
|
||||
}
|
||||
target.budget = this.subtract(target.budget, need);
|
||||
}
|
||||
|
||||
for (const t of trains) {
|
||||
if (t.budget.wagons <= 0) await this.setWindow(t.id, 'FULL');
|
||||
if (t.armed) this.armSettle(t.id);
|
||||
void this.triggerWagonAllocation(t.id);
|
||||
}
|
||||
|
||||
return trains.map((t) => t.id);
|
||||
}
|
||||
|
||||
/** Durable settle: allocate paid / expire overdue reservations, then top up. */
|
||||
async settleDueReservations(scheduleId: string): Promise<void> {
|
||||
const reserved = await this.bookingsRepository.findReservedForSchedule(scheduleId);
|
||||
@@ -766,15 +934,24 @@ export class BookingBatchService implements OnModuleInit {
|
||||
|
||||
// ---- mutations ------------------------------------------------------------
|
||||
|
||||
/** Reserve capacity for a commercial booking and open its pay window. */
|
||||
private async reserve(booking: Booking): Promise<void> {
|
||||
/**
|
||||
* Reserve capacity for a commercial booking on a specific train and open its
|
||||
* pay window. `scheduleId` is persisted so the settle/allocate lifecycle
|
||||
* (settleDueReservations, settleBatch, ensurePaidBookingAllocated, markPaid),
|
||||
* which is all keyed off `booking.trainScheduleId`, can find the train — with
|
||||
* day-level pooling the booking arrives here with `trainScheduleId` still null,
|
||||
* so the engine sets it as it picks the train.
|
||||
*/
|
||||
private async reserve(booking: Booking, scheduleId: string): Promise<void> {
|
||||
const now = new Date();
|
||||
const deadline = new Date(now.getTime() + PAYMENT_WINDOW_MS);
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: scheduleId,
|
||||
status: 'SELECTED_FOR_BATCH',
|
||||
selectedForBatchAt: now,
|
||||
paymentDeadline: deadline,
|
||||
} as never);
|
||||
booking.trainScheduleId = scheduleId;
|
||||
await this.notifier.payNow(booking, deadline);
|
||||
}
|
||||
|
||||
@@ -807,14 +984,20 @@ export class BookingBatchService implements OnModuleInit {
|
||||
void this.triggerWagonAllocation(scheduleId);
|
||||
}
|
||||
|
||||
/** Expire an unpaid reservation and free its capacity. */
|
||||
/**
|
||||
* Expire an unpaid reservation and free its capacity. With day-level pooling we
|
||||
* also clear `trainScheduleId` so the booking is no longer pinned to the train
|
||||
* it failed to pay for — it's back in the day pool for staff to act on.
|
||||
*/
|
||||
private async expire(booking: Booking): Promise<void> {
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
trainScheduleId: null,
|
||||
status: 'EXPIRED',
|
||||
schedulingStatus: 'ELIGIBLE',
|
||||
paymentDeadline: null,
|
||||
selectedForBatchAt: null,
|
||||
} as never);
|
||||
booking.trainScheduleId = null;
|
||||
this.notifier.expired(booking);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,17 @@ export class BookingNotifierService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Staff-facing warning when a pooled booking fits no train on its chosen day.
|
||||
* It stays pending and is retried next batch; staff can add capacity or pin it
|
||||
* to a train manually. Mirrors {@link scheduleFull} — no customer notification.
|
||||
*/
|
||||
unplaced(b: Booking, day: string): void {
|
||||
this.logger.warn(
|
||||
`UNPLACED — ${this.ref(b)} could not be placed on any train for ${day}; add capacity or assign it manually.`,
|
||||
);
|
||||
}
|
||||
|
||||
displaced(b: Booking): void {
|
||||
const msg = `Booking ${b.reference ?? b.id} was displaced by a government booking. Move to another schedule or cancel.`;
|
||||
void this.notifyContact(b, msg, 'DISPLACED');
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsUUID } from 'class-validator';
|
||||
|
||||
export class AvailableDaysQueryDto {
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
originYardId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
destinationYardId?: string;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||
import { AvailableLocomotivesQueryDto } from "./dto/available-locomotives-query.dto";
|
||||
import { BookableSchedulesQueryDto } from "./dto/bookable-schedules-query.dto";
|
||||
import { AvailableDaysQueryDto } from "./dto/available-days-query.dto";
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from "./dto/update-train-scheduling-global-rules.dto";
|
||||
import { TrainSchedulingService } from "./train-scheduling.service";
|
||||
import { BookingBatchService } from "./booking-batch.service";
|
||||
@@ -108,6 +109,20 @@ export class TrainSchedulingController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get("available-days")
|
||||
// No staff guard: customers hit this while creating a booking to find which
|
||||
// DAYS have a departure on their route. Day-level pooling — no capacity is
|
||||
// returned, only the list of bookable days.
|
||||
@ApiOperation({
|
||||
summary: "Distinct days with an OPEN same-route departure (day-level pool)",
|
||||
})
|
||||
getAvailableDays(@Query() query: AvailableDaysQueryDto) {
|
||||
return this.trainSchedulingService.getAvailableDays(
|
||||
query.originYardId,
|
||||
query.destinationYardId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get("container/eligible-bookings")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "List eligible container bookings" })
|
||||
|
||||
@@ -87,6 +87,7 @@ import {
|
||||
DEFAULT_BULK_WAGON_LENGTH_METERS,
|
||||
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
|
||||
} from './booking-batch.constants';
|
||||
import { eatDay } from './batch-window.util';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
@@ -167,12 +168,28 @@ export class TrainSchedulingService {
|
||||
) {}
|
||||
|
||||
async getEligibleBookings(query: GetEligibleBookingsDto) {
|
||||
// Day-level pooling: when the wizard targets a schedule, surface the whole
|
||||
// (route, EAT day) pool — not just bookings pre-pinned to that train — by
|
||||
// resolving the schedule's route + day and filtering on the day instead.
|
||||
let day: string | undefined;
|
||||
let originStationId = query.originStationId;
|
||||
let destinationStationId = query.destinationStationId;
|
||||
if (query.trainScheduleId) {
|
||||
const schedule = await this.trainSchedulesRepository.findById(query.trainScheduleId);
|
||||
if (schedule?.scheduledDepartureDate) {
|
||||
day = eatDay(schedule.scheduledDepartureDate);
|
||||
originStationId = originStationId ?? schedule.originStationId;
|
||||
destinationStationId = destinationStationId ?? schedule.destinationStationId;
|
||||
}
|
||||
}
|
||||
|
||||
const bookings = await this.bookingsRepository.findEligibleForScheduling({
|
||||
freightType: query.freightType,
|
||||
originStationId: query.originStationId,
|
||||
destinationStationId: query.destinationStationId,
|
||||
originStationId,
|
||||
destinationStationId,
|
||||
schedulingStatus: query.schedulingStatus,
|
||||
trainScheduleId: query.trainScheduleId,
|
||||
day,
|
||||
});
|
||||
return { count: bookings.length, items: bookings.map((b) => this.mapEligibleBooking(b)) };
|
||||
}
|
||||
@@ -1989,6 +2006,33 @@ export class TrainSchedulingService {
|
||||
return filteredSchedules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Day-level pool: the distinct EAT calendar days that have ≥1 OPEN bookable
|
||||
* departure on the route. Customers pick a DAY (not a train) — so this returns
|
||||
* only the day strings, no capacity, counts or train info.
|
||||
*/
|
||||
async getAvailableDays(
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): Promise<{ days: string[] }> {
|
||||
const schedules = await this.getBookableSchedules(originYardId, destinationYardId);
|
||||
const days = new Set<string>();
|
||||
for (const s of schedules) {
|
||||
if (s.scheduleDate) days.add(eatDay(new Date(s.scheduleDate)));
|
||||
}
|
||||
return { days: [...days].sort() };
|
||||
}
|
||||
|
||||
/** Whether a route has ≥1 OPEN bookable departure on a given EAT day. */
|
||||
async existsOpenScheduleOnRouteDay(
|
||||
originYardId: string,
|
||||
destinationYardId: string,
|
||||
day: string,
|
||||
): Promise<boolean> {
|
||||
const { days } = await this.getAvailableDays(originYardId, destinationYardId);
|
||||
return days.includes(day);
|
||||
}
|
||||
|
||||
private async mapScheduleDetail(
|
||||
schedule: import('../train-schedules/entities/train-schedule.entity').TrainSchedule,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, IsArray, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/** Bulk-mark received inventory items as inspection PASSED. */
|
||||
export class BulkInspectDto {
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('all', { each: true })
|
||||
inventoryIds!: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Inspection type label (e.g. ORIGIN_INSPECTION).' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inspectionType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remarks?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
inspectedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
|
||||
export class BulkReceiveDto {
|
||||
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
|
||||
@IsIn(['IMPORT', 'EXPORT'])
|
||||
direction!: 'IMPORT' | 'EXPORT';
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
warehouseId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
yardId!: string;
|
||||
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
zoneId!: string;
|
||||
|
||||
@ApiProperty({ type: [String], format: 'uuid' })
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('all', { each: true })
|
||||
bookingIds!: string[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/** Proof of delivery captured when import goods are handed over to the customer. */
|
||||
export class DeliverInventoryDto {
|
||||
@ApiProperty({ description: 'Name of the person who received the goods' })
|
||||
@IsString()
|
||||
receiverName!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'When the goods were delivered (defaults to now)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
deliveredAt?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Delivery remarks / notes' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
remarks?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
/** Records a DO / release order being sent to the customer for import pickup. */
|
||||
export class ReleaseOrderDto {
|
||||
@ApiPropertyOptional({ description: 'DO / release order reference number' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Release date (defaults to now)' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
releaseDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
@@ -3,12 +3,16 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const WAREHOUSE_ACTIVITY_TYPES = [
|
||||
'INVENTORY_RECEIVED',
|
||||
'INVENTORY_UNLOADED',
|
||||
'INVENTORY_STORED',
|
||||
'INVENTORY_MOVED',
|
||||
'INVENTORY_RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'INVENTORY_LOADED',
|
||||
'INVENTORY_DISPATCHED',
|
||||
'READY_FOR_PICKUP',
|
||||
'INVENTORY_RELEASED',
|
||||
'INVENTORY_DELIVERED',
|
||||
] as const;
|
||||
export type WarehouseActivityType = (typeof WAREHOUSE_ACTIVITY_TYPES)[number];
|
||||
|
||||
|
||||
@@ -8,26 +8,40 @@ import { Warehouse } from './warehouse.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
import { WarehouseZone } from './warehouse-zone.entity';
|
||||
|
||||
// Batch 2 lifecycle. Supersedes the Batch 1 set
|
||||
// Lifecycle. Supersedes the Batch 1 set
|
||||
// (ARRIVED_AT_WAREHOUSE / UNDER_INSPECTION / READY_FOR_LOADING) — migrated in place.
|
||||
// After RECEIVED + inspection (PASSED), the flow branches by booking trade direction:
|
||||
// EXPORT/DOMESTIC: STORED → RESERVED → READY_FOR_LOADING → LOADED → DISPATCHED
|
||||
// IMPORT: READY_FOR_PICKUP → DELIVERED (release order + proof of delivery)
|
||||
export const WAREHOUSE_INVENTORY_STATUSES = [
|
||||
'UNLOADED',
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
'READY_FOR_PICKUP',
|
||||
'DELIVERED',
|
||||
] as const;
|
||||
export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number];
|
||||
|
||||
/** Allowed forward transitions for the inventory lifecycle. */
|
||||
export const WAREHOUSE_INVENTORY_TRANSITIONS: Record<WarehouseInventoryStatus, WarehouseInventoryStatus[]> = {
|
||||
RECEIVED: ['STORED'],
|
||||
// UNLOADED = train-arrival landing state (Batch 8). Not yet stored/inspected.
|
||||
// Mirrors RECEIVED so the import flow can store or go straight to pickup after inspection.
|
||||
UNLOADED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
RECEIVED: ['STORED', 'READY_FOR_PICKUP'],
|
||||
STORED: ['RESERVED'],
|
||||
RESERVED: ['READY_FOR_LOADING'],
|
||||
READY_FOR_LOADING: ['LOADED'],
|
||||
LOADED: ['DISPATCHED'],
|
||||
DISPATCHED: [],
|
||||
// Batch 10: an inspected import item can leave by customer pickup (DELIVERED) or be dispatched
|
||||
// out by EDR (DISPATCHED) — kept separate — or be put into storage (STORED) if no one collects
|
||||
// it / customs or inspection hold / operator chooses to store.
|
||||
READY_FOR_PICKUP: ['DELIVERED', 'STORED', 'DISPATCHED'],
|
||||
DELIVERED: [],
|
||||
};
|
||||
|
||||
@Entity({ schema: 'freight', name: 'warehouse_inventory' })
|
||||
@@ -104,6 +118,10 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'arrived_at', type: 'timestamptz', nullable: true })
|
||||
arrivedAt?: Date | null;
|
||||
|
||||
// Batch 8: when the goods were unloaded off the arrived train (before storage/inspection).
|
||||
@Column({ name: 'unloaded_at', type: 'timestamptz', nullable: true })
|
||||
unloadedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'stored_at', type: 'timestamptz', nullable: true })
|
||||
storedAt?: Date | null;
|
||||
|
||||
@@ -135,6 +153,14 @@ export class WarehouseInventory extends BaseEntity {
|
||||
@Column({ name: 'release_date', type: 'timestamptz', nullable: true })
|
||||
releaseDate?: Date | null;
|
||||
|
||||
// Import branch: reference of the DO / release order sent to the customer.
|
||||
@Column({ name: 'release_order_reference', type: 'varchar', length: 100, nullable: true })
|
||||
releaseOrderReference?: string | null;
|
||||
|
||||
// Import branch: when the goods were handed over to the customer (proof of delivery).
|
||||
@Column({ name: 'delivered_at', type: 'timestamptz', nullable: true })
|
||||
deliveredAt?: Date | null;
|
||||
|
||||
@Column({ name: 'gate_cleared_at', type: 'timestamptz', nullable: true })
|
||||
gateClearedAt?: Date | null;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, ManyToOne, OneToMany } from 'typeorm';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm';
|
||||
|
||||
import { Facility } from '../../facilities/entities/facility.entity';
|
||||
import { WarehouseYard } from './warehouse-yard.entity';
|
||||
@@ -63,6 +63,7 @@ export class Warehouse extends BaseEntity {
|
||||
facilityId?: string | null;
|
||||
|
||||
@ManyToOne(() => Facility, (facility) => facility.warehouses, { nullable: true })
|
||||
@JoinColumn({ name: 'facility_id' })
|
||||
facility?: Facility | null;
|
||||
|
||||
@OneToMany(() => WarehouseYard, (yard) => yard.warehouse)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
|
||||
/**
|
||||
* READ-ONLY view into the train-scheduling / wagons domain for the warehouse module.
|
||||
*
|
||||
@@ -9,6 +11,32 @@ import { DataSource } from 'typeorm';
|
||||
* It is intentionally decoupled (raw SQL) so it does not import the scheduling
|
||||
* services/entities and cannot accidentally write to them.
|
||||
*/
|
||||
export interface ImportTrainRow {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
arrivalTime: string | null;
|
||||
totalBookings: number;
|
||||
totalContainers: number;
|
||||
totalCargoes: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ImportTrainItemRow {
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
lastMileRequested: boolean;
|
||||
pickupOption: string;
|
||||
}
|
||||
export interface WagonView {
|
||||
id: string;
|
||||
wagonNumber: string;
|
||||
@@ -115,4 +143,81 @@ export class SchedulingReadFacade {
|
||||
departureStatus: schedule?.status ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* ARRIVED train schedules whose route is IMPORT (origin country = Djibouti), with per-train
|
||||
* booking/container/cargo counts. Direction is derived from the origin/destination station
|
||||
* countries (route-based), so EXPORT/DOMESTIC trains never appear. Read-only.
|
||||
*/
|
||||
async importArriveQueue(): Promise<ImportTrainRow[]> {
|
||||
const rows: Array<
|
||||
ImportTrainRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS "scheduleId",
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
ts.status,
|
||||
(SELECT count(*) FROM freight.train_schedule_bookings tsb
|
||||
WHERE tsb.train_schedule_id = ts.id AND tsb.deleted_at IS NULL) AS "totalBookings",
|
||||
(SELECT count(*) FROM freight.containers c
|
||||
JOIN freight.train_schedule_bookings tsbc ON tsbc.booking_id = c.booking_id AND tsbc.deleted_at IS NULL
|
||||
WHERE tsbc.train_schedule_id = ts.id AND c.deleted_at IS NULL) AS "totalContainers",
|
||||
(SELECT count(*) FROM freight.cargoes cg
|
||||
JOIN freight.train_schedule_bookings tsbg ON tsbg.booking_id = cg.booking_id AND tsbg.deleted_at IS NULL
|
||||
WHERE tsbg.train_schedule_id = ts.id AND cg.deleted_at IS NULL) AS "totalCargoes"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status = 'ARRIVED'
|
||||
ORDER BY COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) DESC NULLS LAST`,
|
||||
);
|
||||
|
||||
return rows
|
||||
.filter(
|
||||
(r) =>
|
||||
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
|
||||
)
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => ({
|
||||
...rest,
|
||||
totalBookings: Number(rest.totalBookings) || 0,
|
||||
totalContainers: Number(rest.totalContainers) || 0,
|
||||
totalCargoes: Number(rest.totalCargoes) || 0,
|
||||
route: rest.origin || rest.destination ? `${rest.origin ?? '?'} → ${rest.destination ?? '?'}` : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train (one row per booking). Read-only. */
|
||||
async importTrainDetail(scheduleId: string): Promise<ImportTrainItemRow[]> {
|
||||
const rows: ImportTrainItemRow[] = await this.dataSource.query(
|
||||
`SELECT b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customerName",
|
||||
(SELECT c.container_number FROM freight.containers c
|
||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
COALESCE(ts.actual_arrival_at, ts.scheduled_arrival_date) AS "arrivalTime",
|
||||
COALESCE(inv.status, b.status) AS "currentStatus",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
||||
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL
|
||||
ORDER BY b.reference ASC NULLS LAST`,
|
||||
[scheduleId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
|
||||
import { Warehouse } from './entities/warehouse.entity';
|
||||
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
|
||||
@@ -8,11 +8,18 @@ export interface WarehouseDashboard {
|
||||
totalWarehouses: number;
|
||||
totalInventory: number;
|
||||
receivedToday: number;
|
||||
// Inspection gate
|
||||
awaitingInspection: number;
|
||||
inspected: number;
|
||||
// Export branch
|
||||
stored: number;
|
||||
reserved: number;
|
||||
readyForLoading: number;
|
||||
loaded: number;
|
||||
dispatched: number;
|
||||
// Import branch
|
||||
readyForPickup: number;
|
||||
delivered: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -26,30 +33,50 @@ export class WarehouseDashboardService {
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalWarehouses, totalInventory, stored, reserved, readyForLoading, loaded, dispatched, receivedToday] =
|
||||
await Promise.all([
|
||||
warehouseRepo.count(),
|
||||
inventoryRepo.count(),
|
||||
inventoryRepo.count({ where: { status: 'STORED' } }),
|
||||
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
||||
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
||||
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
||||
inventoryRepo
|
||||
.createQueryBuilder('inv')
|
||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return {
|
||||
const [
|
||||
totalWarehouses,
|
||||
totalInventory,
|
||||
receivedToday,
|
||||
awaitingInspection,
|
||||
inspected,
|
||||
stored,
|
||||
reserved,
|
||||
readyForLoading,
|
||||
loaded,
|
||||
dispatched,
|
||||
readyForPickup,
|
||||
delivered,
|
||||
receivedToday,
|
||||
] = await Promise.all([
|
||||
warehouseRepo.count(),
|
||||
inventoryRepo.count(),
|
||||
inventoryRepo.count({ where: { status: 'RECEIVED', inspectionStatus: IsNull() } }),
|
||||
inventoryRepo.count({ where: { inspectionStatus: 'PASSED' } }),
|
||||
inventoryRepo.count({ where: { status: 'STORED' } }),
|
||||
inventoryRepo.count({ where: { status: 'RESERVED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_LOADING' } }),
|
||||
inventoryRepo.count({ where: { status: 'LOADED' } }),
|
||||
inventoryRepo.count({ where: { status: 'DISPATCHED' } }),
|
||||
inventoryRepo.count({ where: { status: 'READY_FOR_PICKUP' } }),
|
||||
inventoryRepo.count({ where: { status: 'DELIVERED' } }),
|
||||
inventoryRepo
|
||||
.createQueryBuilder('inv')
|
||||
.where('inv.arrived_at >= :start', { start: startOfToday })
|
||||
.getCount(),
|
||||
]);
|
||||
|
||||
return {
|
||||
totalWarehouses,
|
||||
totalInventory,
|
||||
receivedToday,
|
||||
awaitingInspection,
|
||||
inspected,
|
||||
stored,
|
||||
reserved,
|
||||
readyForLoading,
|
||||
loaded,
|
||||
dispatched,
|
||||
readyForPickup,
|
||||
delivered,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
import { SchedulingReadFacade } from './scheduling-read.facade';
|
||||
@@ -56,6 +60,49 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.autoLoadReady();
|
||||
}
|
||||
|
||||
@Get('eligible-bookings')
|
||||
@ApiOperation({ summary: 'PAID bookings not yet received, classified IMPORT/EXPORT by route; omit direction for all' })
|
||||
eligibleBookings(@Query('direction') direction?: string) {
|
||||
const dir = direction === 'IMPORT' || direction === 'EXPORT' ? direction : undefined;
|
||||
return this.inventoryService.eligibleBookings(dir);
|
||||
}
|
||||
|
||||
@Post('receive-bulk')
|
||||
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
|
||||
receiveBulk(@Body() dto: BulkReceiveDto) {
|
||||
return this.inventoryService.bulkReceive(dto);
|
||||
}
|
||||
|
||||
@Post('load-passed-export')
|
||||
@ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' })
|
||||
loadPassedExport(@Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.loadPassedExport(performedBy);
|
||||
}
|
||||
|
||||
@Get('ready-to-load-export')
|
||||
@ApiOperation({ summary: 'EXPORT inventory that passed inspection and is READY_FOR_LOADING' })
|
||||
readyToLoadExport() {
|
||||
return this.inventoryService.readyToLoadExport();
|
||||
}
|
||||
|
||||
@Get('loaded-export')
|
||||
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
||||
loadedExport() {
|
||||
return this.inventoryService.loadedExport();
|
||||
}
|
||||
|
||||
@Post('bulk-dispatch-export')
|
||||
@ApiOperation({ summary: 'Bulk-dispatch loaded EXPORT inventory (LOADED → DISPATCHED)' })
|
||||
bulkDispatchExport(@Body() dto: { inventoryIds: string[]; performedBy?: string }) {
|
||||
return this.inventoryService.bulkDispatchExport(dto.inventoryIds ?? [], dto.performedBy);
|
||||
}
|
||||
|
||||
@Post('bulk-mark-inspected')
|
||||
@ApiOperation({ summary: 'Bulk mark received inventory inspection PASSED (EXPORT → READY_FOR_LOADING)' })
|
||||
bulkMarkInspected(@Body() dto: BulkInspectDto) {
|
||||
return this.inventoryService.bulkMarkInspected(dto);
|
||||
}
|
||||
|
||||
@Post('bookings/:bookingId/unload')
|
||||
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
|
||||
unloadBooking(
|
||||
@@ -71,6 +118,36 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.gateClearance(id, performedBy);
|
||||
}
|
||||
|
||||
@Get('import/arrive-queue')
|
||||
@ApiOperation({ summary: 'Arrived IMPORT train schedules (route-derived), read-only from scheduling' })
|
||||
importArriveQueue() {
|
||||
return this.scheduling.importArriveQueue();
|
||||
}
|
||||
|
||||
@Get('import/trains/:scheduleId/items')
|
||||
@ApiOperation({ summary: 'Assigned bookings/items for an arrived import train (read-only)' })
|
||||
importTrainDetail(@Param('scheduleId', ParseUUIDPipe) scheduleId: string) {
|
||||
return this.scheduling.importTrainDetail(scheduleId);
|
||||
}
|
||||
|
||||
@Post('import/auto-unload-arrived-bookings')
|
||||
@ApiOperation({ summary: 'Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED)' })
|
||||
autoUnloadArrivedBookings(@Body() dto: { scheduleId: string; performedBy?: string }) {
|
||||
return this.inventoryService.autoUnloadArrivedBookings(dto.scheduleId, dto.performedBy);
|
||||
}
|
||||
|
||||
@Get('import/unloaded-queue')
|
||||
@ApiOperation({ summary: 'IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection)' })
|
||||
importUnloadedQueue() {
|
||||
return this.inventoryService.importUnloadedQueue();
|
||||
}
|
||||
|
||||
@Get('import/pickup-ready-queue')
|
||||
@ApiOperation({ summary: 'IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch' })
|
||||
importPickupReadyQueue() {
|
||||
return this.inventoryService.importPickupReadyQueue();
|
||||
}
|
||||
|
||||
@Get('loadable-wagons')
|
||||
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
|
||||
loadableWagons() {
|
||||
@@ -137,6 +214,24 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.load(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ready-for-pickup')
|
||||
@ApiOperation({ summary: 'Mark inspected IMPORT inventory READY_FOR_PICKUP' })
|
||||
readyForPickup(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
return this.inventoryService.readyForPickup(id, performedBy);
|
||||
}
|
||||
|
||||
@Post(':id/release')
|
||||
@ApiOperation({ summary: 'Issue a DO / release order for ready-for-pickup inventory' })
|
||||
release(@Param('id', ParseUUIDPipe) id: string, @Body() dto: ReleaseOrderDto) {
|
||||
return this.inventoryService.release(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/deliver')
|
||||
@ApiOperation({ summary: 'Deliver import goods to the customer + capture proof of delivery' })
|
||||
deliver(@Param('id', ParseUUIDPipe) id: string, @Body() dto: DeliverInventoryDto) {
|
||||
return this.inventoryService.deliver(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/dispatch')
|
||||
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
|
||||
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto } from './dto/bulk-receive.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
|
||||
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
|
||||
import { LoadInventoryDto } from './dto/load-inventory.dto';
|
||||
import { MoveInventoryDto } from './dto/move-inventory.dto';
|
||||
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
|
||||
import { ReleaseOrderDto } from './dto/release-order.dto';
|
||||
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
|
||||
import { UnloadBookingDto } from './dto/unload-booking.dto';
|
||||
import { WarehouseAllocationService } from './warehouse-allocation.service';
|
||||
import { WarehouseInspectionService } from './warehouse-inspection.service';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
|
||||
@@ -113,6 +120,85 @@ export interface AutoLoadResult {
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
// ── Receive (Import/Export bulk) shapes ──────────────────────────────────────
|
||||
export interface EligibleBookingRow {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerId: string | null;
|
||||
customer: string | null;
|
||||
direction: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
freightType: string | null;
|
||||
cargo: string | null;
|
||||
weight: string | null;
|
||||
paymentStatus: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
receivedCount: number;
|
||||
skippedCount: number;
|
||||
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface LoadPassedExportResult {
|
||||
loadedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface BulkInspectResult {
|
||||
inspectedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface ReadyToLoadRow {
|
||||
id: string;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
inspectionStatus: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BulkDispatchResult {
|
||||
dispatchedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface AutoUnloadArrivedResult {
|
||||
unloadedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface ImportUnloadedRow {
|
||||
id: string;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
arrivalTime: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
trainSchedule: string | null;
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
currentStatus: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryService {
|
||||
constructor(
|
||||
@@ -123,6 +209,7 @@ export class WarehouseInventoryService {
|
||||
private readonly scheduling: SchedulingReadFacade,
|
||||
private readonly allocation: WarehouseAllocationService,
|
||||
private readonly invoices: WarehouseInvoiceService,
|
||||
private readonly inspectionService: WarehouseInspectionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -403,6 +490,542 @@ export class WarehouseInventoryService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Eligible PAID bookings that have NOT been received yet, classified IMPORT/EXPORT by route
|
||||
* (origin/destination yard countries). Pass a direction to filter to one; omit it to return
|
||||
* all import + export bookings in a single call (DOMESTIC routes are excluded either way).
|
||||
*/
|
||||
async eligibleBookings(direction?: 'IMPORT' | 'EXPORT'): Promise<EligibleBookingRow[]> {
|
||||
const rows: Array<
|
||||
EligibleBookingRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT b.id,
|
||||
b.reference AS "reference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customer",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
b.freight_type AS "freightType",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.status AS "status"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.payment_status = 'PAID'
|
||||
AND inv.id IS NULL
|
||||
ORDER BY b.scheduled_date DESC NULLS LAST`,
|
||||
);
|
||||
|
||||
// Direction is derived from the route (origin/destination yard countries), reusing deriveTradeDirection.
|
||||
return rows
|
||||
.map((r) => ({
|
||||
...r,
|
||||
direction: deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }),
|
||||
}))
|
||||
.filter((r) =>
|
||||
direction ? r.direction === direction : r.direction === 'IMPORT' || r.direction === 'EXPORT',
|
||||
);
|
||||
}
|
||||
|
||||
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
|
||||
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
|
||||
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateLocation(manager, {
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
});
|
||||
|
||||
for (const bookingId of dto.bookingIds) {
|
||||
const skip = (reason: string) => {
|
||||
result.skippedCount += 1;
|
||||
result.results.push({ bookingId, status: 'SKIPPED', reason });
|
||||
};
|
||||
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!booking) { skip('Booking not found'); continue; }
|
||||
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
|
||||
// Direction is derived from the route (yard countries), not the stored field.
|
||||
const bookingDirection = deriveTradeDirection(
|
||||
{ country: booking.originCountry },
|
||||
{ country: booking.destinationCountry },
|
||||
);
|
||||
if (bookingDirection !== dto.direction) {
|
||||
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
|
||||
if (existing) { skip('Already received'); continue; }
|
||||
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
warehouseId: dto.warehouseId,
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId,
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: new Date(),
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `Bulk received ${dto.direction} booking`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id });
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */
|
||||
async loadPassedExport(performedBy?: string): Promise<LoadPassedExportResult> {
|
||||
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
|
||||
const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] };
|
||||
|
||||
for (const item of ready) {
|
||||
const skip = (reason: string) => {
|
||||
result.skippedCount += 1;
|
||||
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason });
|
||||
};
|
||||
|
||||
if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; }
|
||||
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
|
||||
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
|
||||
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
|
||||
if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; }
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(item.id, {
|
||||
status: 'LOADED',
|
||||
loadedAt: new Date(),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_LOADED',
|
||||
inventoryId: item.id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: 'Bulk loaded (passed export)',
|
||||
performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
result.loadedCount += 1;
|
||||
result.results.push({ inventoryId: item.id, status: 'LOADED' });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** EXPORT inventory rows at a given status (route-derived direction), with booking detail. */
|
||||
private async exportInventoryByStatus(
|
||||
status: WarehouseInventoryStatus,
|
||||
requireInspectionPassed = false,
|
||||
): Promise<ReadyToLoadRow[]> {
|
||||
const rows: Array<
|
||||
ReadyToLoadRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customerName",
|
||||
ct.container_number AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
inv.weight AS "weight",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
inv.status
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.containers ct ON ct.id = inv.container_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.status = $1
|
||||
${requireInspectionPassed ? `AND inv.inspection_status = 'PASSED'` : ''}
|
||||
ORDER BY inv.created_at DESC`,
|
||||
[status],
|
||||
);
|
||||
|
||||
return rows
|
||||
.filter((r) => {
|
||||
const dir = deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry });
|
||||
return dir === 'EXPORT';
|
||||
})
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
|
||||
}
|
||||
|
||||
/** EXPORT inventory that passed inspection and is waiting to be loaded (READY_FOR_LOADING). */
|
||||
async readyToLoadExport(): Promise<ReadyToLoadRow[]> {
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
||||
}
|
||||
|
||||
/** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
|
||||
async loadedExport(): Promise<ReadyToLoadRow[]> {
|
||||
return this.exportInventoryByStatus('LOADED');
|
||||
}
|
||||
|
||||
/** Shared query for the import queues — IMPORT inventory at the given statuses, inspection columns. */
|
||||
private async importQueueByStatuses(statuses: string[]): Promise<ImportUnloadedRow[]> {
|
||||
const rows: Array<
|
||||
ImportUnloadedRow & { originCountry: string | null; destinationCountry: string | null }
|
||||
> = await this.dataSource.query(
|
||||
`SELECT inv.id,
|
||||
inv.booking_id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customerName",
|
||||
COALESCE(inv.unloaded_at, inv.arrived_at) AS "arrivalTime",
|
||||
(SELECT c.container_number FROM freight.containers c
|
||||
WHERE c.booking_id = b.id AND c.deleted_at IS NULL
|
||||
ORDER BY c.container_number LIMIT 1) AS "containerNumber",
|
||||
COALESCE(cgt.cargo_type_name, b.cargo_free_text) AS "cargoType",
|
||||
inv.weight AS "weight",
|
||||
ts.train_number AS "trainSchedule",
|
||||
inv.inspection_status AS "inspectionStatus",
|
||||
CASE WHEN b.last_mile_delivery_address IS NOT NULL
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
|
||||
inv.status AS "currentStatus",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry"
|
||||
FROM freight.warehouse_inventory inv
|
||||
LEFT JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
LEFT JOIN freight.train_schedule_bookings tsb ON tsb.booking_id = b.id AND tsb.deleted_at IS NULL
|
||||
LEFT JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
|
||||
WHERE inv.deleted_at IS NULL
|
||||
AND inv.status = ANY($1)
|
||||
ORDER BY inv.created_at DESC`,
|
||||
[statuses],
|
||||
);
|
||||
|
||||
return rows
|
||||
.filter(
|
||||
(r) =>
|
||||
deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }) === 'IMPORT',
|
||||
)
|
||||
.map(({ originCountry: _oc, destinationCountry: _dc, ...rest }) => rest);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 9 — IMPORT inventory sitting in the Unloaded Queue (UNLOADED / destination-inspection
|
||||
* states), with the columns the inspection screen needs. Read-only.
|
||||
*/
|
||||
importUnloadedQueue(): Promise<ImportUnloadedRow[]> {
|
||||
return this.importQueueByStatuses(['UNLOADED', 'DESTINATION_INSPECTION', 'UNDER_INSPECTION']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch 10 — IMPORT inventory that passed inspection and is PICKUP_READY (READY_FOR_PICKUP),
|
||||
* awaiting customer pickup / last mile / store / dispatch. Read-only.
|
||||
*/
|
||||
importPickupReadyQueue(): Promise<ImportUnloadedRow[]> {
|
||||
return this.importQueueByStatuses(['READY_FOR_PICKUP']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-dispatch loaded EXPORT inventory. Reuses the single-item dispatch transition
|
||||
* (status LOADED → DISPATCHED, capacity freed, movement/activity logged). Items not
|
||||
* LOADED or not EXPORT are skipped. The train/schedule flow later moves DISPATCHED → IN_TRANSIT.
|
||||
*/
|
||||
async bulkDispatchExport(inventoryIds: string[], performedBy?: string): Promise<BulkDispatchResult> {
|
||||
const result: BulkDispatchResult = { dispatchedCount: 0, skippedCount: 0, results: [] };
|
||||
|
||||
for (const inventoryId of inventoryIds) {
|
||||
const skip = (reason: string) => {
|
||||
result.skippedCount += 1;
|
||||
result.results.push({ inventoryId, status: 'SKIPPED', reason });
|
||||
};
|
||||
|
||||
const item = await this.inventoryRepository.findById(inventoryId);
|
||||
if (!item) { skip('Inventory not found'); continue; }
|
||||
if (item.status !== 'LOADED') { skip(`Status is ${item.status}, not LOADED`); continue; }
|
||||
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
|
||||
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
|
||||
|
||||
try {
|
||||
await this.dispatch(inventoryId, performedBy);
|
||||
result.dispatchedCount += 1;
|
||||
result.results.push({ inventoryId, status: 'DISPATCHED' });
|
||||
} catch (error) {
|
||||
skip(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Booking statuses eligible to be unloaded off an arrived import train (Batch 8). */
|
||||
private readonly IMPORT_UNLOAD_ELIGIBLE_STATUSES = [
|
||||
'IN_TRANSIT',
|
||||
'ARRIVED_AT_INDODE',
|
||||
'ARRIVED_AT_DESTINATION',
|
||||
'ARRIVED_AT_FACILITY',
|
||||
];
|
||||
|
||||
/**
|
||||
* Batch 8 — unload all eligible assigned bookings of an ARRIVED import train into UNLOADED state.
|
||||
* Reuses the allocation + inventory + activity-log plumbing. Does NOT store and does NOT inspect —
|
||||
* items land in UNLOADED so the operator drives store/inspect/reserve/dispatch from the queue.
|
||||
*/
|
||||
async autoUnloadArrivedBookings(
|
||||
scheduleId: string,
|
||||
performedBy?: string,
|
||||
): Promise<AutoUnloadArrivedResult> {
|
||||
const result: AutoUnloadArrivedResult = { unloadedCount: 0, skippedCount: 0, failedCount: 0, results: [] };
|
||||
|
||||
// 1. Schedule must exist, be ARRIVED, and be an IMPORT route (derived from station countries).
|
||||
const [schedule] = await this.dataSource.query(
|
||||
`SELECT ts.id, ts.status, oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.id = $1 AND ts.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[scheduleId],
|
||||
);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (schedule.status !== 'ARRIVED') {
|
||||
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
|
||||
}
|
||||
const direction = deriveTradeDirection(
|
||||
{ country: schedule.originCountry },
|
||||
{ country: schedule.destinationCountry },
|
||||
);
|
||||
if (direction !== 'IMPORT') {
|
||||
throw new BadRequestException(`Train schedule route is ${direction}, not IMPORT`);
|
||||
}
|
||||
|
||||
// 2. Assigned bookings on this train.
|
||||
const bookings: {
|
||||
id: string;
|
||||
status: string;
|
||||
weight: string | null;
|
||||
freightType: string | null;
|
||||
tradeDirection: string | null;
|
||||
cargoTypeCode: string | null;
|
||||
}[] = await this.dataSource.query(
|
||||
`SELECT b.id, b.status, b.cargo_total_weight_vgm AS weight,
|
||||
b.freight_type AS "freightType", b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode"
|
||||
FROM freight.train_schedule_bookings tsb
|
||||
JOIN freight.bookings b ON b.id = tsb.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cgt ON cgt.id = b.cargo_type_id
|
||||
WHERE tsb.train_schedule_id = $1 AND tsb.deleted_at IS NULL`,
|
||||
[scheduleId],
|
||||
);
|
||||
|
||||
const fallback = await this.pickDefaultLocation();
|
||||
const now = new Date();
|
||||
|
||||
for (const booking of bookings) {
|
||||
const skip = (reason: string) => {
|
||||
result.skippedCount += 1;
|
||||
result.results.push({ bookingId: booking.id, status: 'SKIPPED', reason });
|
||||
};
|
||||
const fail = (reason: string) => {
|
||||
result.failedCount += 1;
|
||||
result.results.push({ bookingId: booking.id, status: 'FAILED', reason });
|
||||
};
|
||||
|
||||
if (!this.IMPORT_UNLOAD_ELIGIBLE_STATUSES.includes(booking.status)) {
|
||||
skip(`Booking status ${booking.status} is not unload-eligible`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const existing = (await this.inventoryRepository.findAll({ where: { bookingId: booking.id } }))[0];
|
||||
|
||||
// Already unloaded or further along — leave it (do not regress the lifecycle).
|
||||
if (existing && existing.status !== 'RECEIVED') {
|
||||
skip(`Inventory already ${existing.status}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await this.inventoryRepository.update(existing.id, {
|
||||
status: 'UNLOADED',
|
||||
unloadedAt: now,
|
||||
arrivedAt: existing.arrivedAt ?? now,
|
||||
});
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
inventoryId: existing.id,
|
||||
warehouseId: existing.warehouseId,
|
||||
description: 'Unloaded from arrived import train',
|
||||
performedBy,
|
||||
});
|
||||
result.unloadedCount += 1;
|
||||
result.results.push({ bookingId: booking.id, inventoryId: existing.id, status: 'UNLOADED' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// No inventory yet — create it at the allocated (or default) location, in UNLOADED state.
|
||||
const allocated = await this.allocation.resolveLocation({
|
||||
freightType: booking.freightType,
|
||||
tradeDirection: booking.tradeDirection,
|
||||
cargoTypeCode: booking.cargoTypeCode,
|
||||
});
|
||||
const location = allocated ?? fallback;
|
||||
if (!location) {
|
||||
fail('No warehouse/yard/zone configured');
|
||||
continue;
|
||||
}
|
||||
|
||||
const saved = await this.inventoryRepository.create({
|
||||
warehouseId: location.warehouseId,
|
||||
yardId: location.yardId,
|
||||
zoneId: location.zoneId,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'UNLOADED',
|
||||
arrivedAt: now,
|
||||
unloadedAt: now,
|
||||
notes: allocated?.rule ? `Unloaded → ${allocated.path}` : 'Unloaded from arrived import train',
|
||||
});
|
||||
await this.activityLog.record({
|
||||
activityType: 'INVENTORY_UNLOADED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: saved.warehouseId,
|
||||
description: 'Unloaded from arrived import train',
|
||||
performedBy,
|
||||
});
|
||||
result.unloadedCount += 1;
|
||||
result.results.push({ bookingId: booking.id, inventoryId: saved.id, status: 'UNLOADED' });
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-mark received items inspection PASSED (reusing the inspection service to create a minimal
|
||||
* report + sync inspectionStatus/inspectedAt). EXPORT items advance straight to READY_FOR_LOADING.
|
||||
* For damage / weight-loss / images, use the per-item Inspect / Report action instead.
|
||||
*/
|
||||
async bulkMarkInspected(dto: BulkInspectDto): Promise<BulkInspectResult> {
|
||||
const result: BulkInspectResult = { inspectedCount: 0, skippedCount: 0, results: [] };
|
||||
// UNLOADED added for Batch 9 import destination inspection (arrived-train unload landing state).
|
||||
const eligible = ['UNLOADED', 'RECEIVED', 'STORED', 'RESERVED'];
|
||||
|
||||
for (const inventoryId of dto.inventoryIds) {
|
||||
const skip = (reason: string) => {
|
||||
result.skippedCount += 1;
|
||||
result.results.push({ inventoryId, status: 'SKIPPED', reason });
|
||||
};
|
||||
|
||||
const item = await this.inventoryRepository.findById(inventoryId);
|
||||
if (!item) { skip('Inventory not found'); continue; }
|
||||
if (item.inspectionStatus === 'PASSED') { skip('Already inspected'); continue; }
|
||||
if (!eligible.includes(item.status)) { skip(`Status ${item.status} not eligible for inspection`); continue; }
|
||||
|
||||
// Reuse the existing inspection service: creates a minimal PASSED report + sets inspectionStatus/inspectedAt.
|
||||
await this.inspectionService.create(inventoryId, {
|
||||
reportType: 'INSPECTION',
|
||||
inspectionStatus: 'PASSED',
|
||||
remarks: dto.remarks ?? 'Bulk marked inspected (PASSED).',
|
||||
inspectedById: dto.inspectedBy,
|
||||
});
|
||||
|
||||
// A passed item advances by trade direction:
|
||||
// EXPORT → Ready To Load (READY_FOR_LOADING)
|
||||
// IMPORT → Pickup Ready (READY_FOR_PICKUP) — NOT ready-for-loading.
|
||||
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
|
||||
if (direction === 'EXPORT') {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(inventoryId, {
|
||||
status: 'READY_FOR_LOADING',
|
||||
readyForLoadingAt: new Date(),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'READY_FOR_LOADING',
|
||||
inventoryId,
|
||||
warehouseId: item.warehouseId,
|
||||
description: 'Inspection passed → ready for loading',
|
||||
performedBy: dto.inspectedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
result.results.push({ inventoryId, status: 'READY_FOR_LOADING' });
|
||||
} else if (direction === 'IMPORT') {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(inventoryId, {
|
||||
status: 'READY_FOR_PICKUP',
|
||||
readyForPickupAt: new Date(),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'READY_FOR_PICKUP',
|
||||
inventoryId,
|
||||
warehouseId: item.warehouseId,
|
||||
description: 'Destination inspection passed → pickup ready',
|
||||
performedBy: dto.inspectedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
result.results.push({ inventoryId, status: 'READY_FOR_PICKUP' });
|
||||
} else {
|
||||
result.results.push({ inventoryId, status: 'INSPECTED' });
|
||||
}
|
||||
result.inspectedCount += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Receive ──────────────────────────────────────────────────────────────
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
@@ -511,6 +1134,9 @@ export class WarehouseInventoryService {
|
||||
if (!item.bookingId || !item.warehouseId || !item.yardId || !item.zoneId) {
|
||||
throw new BadRequestException('Inventory must have booking, warehouse, yard and zone before loading prep');
|
||||
}
|
||||
if (item.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for loading');
|
||||
}
|
||||
return this.transition(id, 'READY_FOR_LOADING', {
|
||||
timestampField: 'readyForLoadingAt',
|
||||
activityType: 'READY_FOR_LOADING',
|
||||
@@ -520,6 +1146,112 @@ export class WarehouseInventoryService {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────
|
||||
|
||||
/** Mark inspected IMPORT inventory ready for customer pickup (RECEIVED → READY_FOR_PICKUP). */
|
||||
async readyForPickup(id: string, performedBy?: string): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
|
||||
if (item.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Inventory must pass inspection before it can be marked ready for pickup');
|
||||
}
|
||||
|
||||
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
|
||||
if (direction !== 'IMPORT') {
|
||||
throw new BadRequestException('Only IMPORT inventory can be marked ready for pickup');
|
||||
}
|
||||
|
||||
return this.transition(id, 'READY_FOR_PICKUP', {
|
||||
timestampField: 'readyForPickupAt',
|
||||
activityType: 'READY_FOR_PICKUP',
|
||||
description: 'Inventory ready for customer pickup',
|
||||
performedBy,
|
||||
preloaded: item,
|
||||
});
|
||||
}
|
||||
|
||||
/** Record a DO / release order sent to the customer. Item stays READY_FOR_PICKUP. */
|
||||
async release(id: string, dto: ReleaseOrderDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
if (item.status !== 'READY_FOR_PICKUP') {
|
||||
throw new BadRequestException(
|
||||
`Inventory must be READY_FOR_PICKUP to issue a release order (current: ${item.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
||||
const reference = dto.reference?.trim() || null;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
releaseOrderReference: reference,
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: reference
|
||||
? `Release order ${reference} sent to customer`
|
||||
: 'Release order sent to customer',
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Hand import goods to the customer + capture proof of delivery (READY_FOR_PICKUP → DELIVERED). */
|
||||
async deliver(id: string, dto: DeliverInventoryDto): Promise<WarehouseInventory> {
|
||||
const item = await this.findById(id);
|
||||
this.assertTransition(item.status, 'DELIVERED');
|
||||
|
||||
if (!item.releaseDate) {
|
||||
throw new BadRequestException('A release order must be issued before the goods can be delivered');
|
||||
}
|
||||
|
||||
const receiverName = dto.receiverName.trim();
|
||||
const deliveredAt = dto.deliveredAt ? new Date(dto.deliveredAt) : new Date();
|
||||
const weight = Number(item.weight) || 0;
|
||||
const volume = Number(item.volume) || 0;
|
||||
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
status: 'DELIVERED',
|
||||
deliveredAt,
|
||||
});
|
||||
|
||||
// Goods physically leave the warehouse on pickup — free up capacity.
|
||||
await this.applyCapacityDelta(manager, item.warehouseId, item.yardId, item.zoneId, weight, volume, containerCount, -1);
|
||||
|
||||
// Proof of delivery is captured on the linked cargo.
|
||||
if (item.cargoId) {
|
||||
await manager.getRepository(Cargo).update(item.cargoId, {
|
||||
receiverName,
|
||||
deliveredAt,
|
||||
deliveryRemarks: dto.remarks?.trim() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_DELIVERED',
|
||||
inventoryId: id,
|
||||
warehouseId: item.warehouseId,
|
||||
description: `Delivered to ${receiverName}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
});
|
||||
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record.
|
||||
* Reads wagon/schedule data read-only — never modifies scheduling.
|
||||
@@ -886,6 +1618,23 @@ export class WarehouseInventoryService {
|
||||
return rows?.[0]?.status ?? null;
|
||||
}
|
||||
|
||||
/** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */
|
||||
private async getBookingDirection(bookingId: string): Promise<string | null> {
|
||||
const rows = await this.dataSource.query(
|
||||
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
if (!rows?.[0]) return null;
|
||||
return deriveTradeDirection(
|
||||
{ country: rows[0].originCountry },
|
||||
{ country: rows[0].destinationCountry },
|
||||
);
|
||||
}
|
||||
|
||||
private assertCapacity(
|
||||
label: string,
|
||||
node: LocationNode,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindManyOptions, ILike } from 'typeorm';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { FindManyOptions, ILike, QueryFailedError } from 'typeorm';
|
||||
|
||||
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
|
||||
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
|
||||
@@ -49,23 +49,27 @@ export class WarehousesService {
|
||||
async create(dto: CreateWarehouseDto): Promise<Warehouse> {
|
||||
await this.assertCodeUnique(dto.code.trim());
|
||||
|
||||
return this.warehousesRepository.create({
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
stationId: dto.stationId ?? null,
|
||||
facilityId: dto.facilityId ?? null,
|
||||
locationName: dto.locationName?.trim() ?? null,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
maxVolume: dto.maxVolume ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
try {
|
||||
return await this.warehousesRepository.create({
|
||||
name: dto.name.trim(),
|
||||
code: dto.code.trim(),
|
||||
type: dto.type,
|
||||
stationId: dto.stationId ?? null,
|
||||
facilityId: dto.facilityId ?? null,
|
||||
locationName: dto.locationName?.trim() ?? null,
|
||||
capacityWeight: dto.capacityWeight ?? null,
|
||||
capacityContainers: dto.capacityContainers ?? null,
|
||||
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
|
||||
maxVolume: dto.maxVolume ?? null,
|
||||
currentWeight: 0,
|
||||
currentContainers: 0,
|
||||
currentVolume: 0,
|
||||
status: 'ACTIVE',
|
||||
isActive: true,
|
||||
});
|
||||
} catch (error) {
|
||||
this.mapDbError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateWarehouseDto): Promise<Warehouse> {
|
||||
@@ -77,20 +81,25 @@ export class WarehousesService {
|
||||
|
||||
const status = dto.status ?? existing.status;
|
||||
|
||||
const updated = await this.warehousesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
stationId: dto.stationId ?? existing.stationId,
|
||||
facilityId: dto.facilityId ?? existing.facilityId,
|
||||
locationName: dto.locationName?.trim() ?? existing.locationName,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
let updated;
|
||||
try {
|
||||
updated = await this.warehousesRepository.update(id, {
|
||||
name: dto.name?.trim() ?? existing.name,
|
||||
code: dto.code?.trim() ?? existing.code,
|
||||
type: dto.type ?? existing.type,
|
||||
stationId: dto.stationId ?? existing.stationId,
|
||||
facilityId: dto.facilityId ?? existing.facilityId,
|
||||
locationName: dto.locationName?.trim() ?? existing.locationName,
|
||||
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
|
||||
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
|
||||
maxWeight: dto.maxWeight ?? existing.maxWeight,
|
||||
maxVolume: dto.maxVolume ?? existing.maxVolume,
|
||||
status,
|
||||
isActive: status === 'ACTIVE',
|
||||
});
|
||||
} catch (error) {
|
||||
this.mapDbError(error);
|
||||
}
|
||||
|
||||
if (!updated) {
|
||||
throw new NotFoundException(`Warehouse ${id} not found`);
|
||||
@@ -99,6 +108,21 @@ export class WarehousesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
|
||||
private mapDbError(error: unknown): never {
|
||||
if (error instanceof QueryFailedError) {
|
||||
const driver = (error as QueryFailedError & { driverError?: { code?: string; detail?: string } }).driverError;
|
||||
if (driver?.code === '23503') {
|
||||
throw new BadRequestException('Selected facility does not exist.');
|
||||
}
|
||||
if (driver?.code === '22001') {
|
||||
throw new BadRequestException('A field is too long (code max 40, name max 160 characters).');
|
||||
}
|
||||
throw new BadRequestException(driver?.detail ?? error.message ?? 'Invalid warehouse data.');
|
||||
}
|
||||
throw error as Error;
|
||||
}
|
||||
|
||||
private async assertCodeUnique(code: string, ignoreId?: string): Promise<void> {
|
||||
const [existing] = await this.warehousesRepository.findAll({ where: { code } });
|
||||
|
||||
|
||||
139
apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts
Normal file
139
apps/edr-freight-api/src/seed/batch5-test-data.seeder.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
|
||||
const SEED_REFS = ['SEED-B5-EXP-001', 'SEED-B5-EXP-002', 'SEED-B5-EXP-003'];
|
||||
|
||||
const SEEDS = [
|
||||
{ ref: 'SEED-B5-EXP-001', weight: 5000, notes: 'Electronics export cargo' },
|
||||
{ ref: 'SEED-B5-EXP-002', weight: 8500, notes: 'Textile export cargo' },
|
||||
{ ref: 'SEED-B5-EXP-003', weight: 3200, notes: 'Coffee export cargo' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Seeds 3 EXPORT+PAID bookings with READY_FOR_LOADING + inspection PASSED inventory
|
||||
* so the Batch 5 "Ready To Load" tab has visible rows to test against.
|
||||
*
|
||||
* Origin: any Ethiopian yard (route-based direction = EXPORT when dest = Djibouti)
|
||||
* Destination: any Djiboutian yard
|
||||
* Uses the INDODE_OPEN warehouse created by IndodeFacilitySeeder.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Batch5TestDataSeeder {
|
||||
private readonly logger = new Logger(Batch5TestDataSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
|
||||
const existing = await bookingRepo.findOne({ where: { reference: SEED_REFS[0] } });
|
||||
if (existing) {
|
||||
this.logger.log('Batch 5 test data already seeded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const yardRepo = this.dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
|
||||
// Find Ethiopian origin yard and Djiboutian destination yard.
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'ADDIS_ABABA' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
const destYard =
|
||||
(await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
|
||||
if (!originYard || !destYard) {
|
||||
this.logger.warn(
|
||||
`Required yards not found (origin=${originYard?.code ?? 'none'}, dest=${destYard?.code ?? 'none'}); skipping Batch 5 seed`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find any active service type (bookings require one).
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
if (!serviceType) {
|
||||
this.logger.warn('No service type found; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Find INDODE warehouse.
|
||||
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
|
||||
if (!warehouse) {
|
||||
this.logger.warn('INDODE_OPEN warehouse not found; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouseYard = await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } });
|
||||
if (!warehouseYard) {
|
||||
this.logger.warn('No warehouse yard found for INDODE_OPEN; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouseZone = await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } });
|
||||
if (!warehouseZone) {
|
||||
this.logger.warn('No warehouse zone found; skipping Batch 5 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
for (const seed of SEEDS) {
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference: seed.ref,
|
||||
originYardId: originYard.id,
|
||||
destinationYardId: destYard.id,
|
||||
serviceTypeId: serviceType.id,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: 'BULK',
|
||||
cargoTotalWeightVgm: seed.weight,
|
||||
cargoFreeText: seed.notes,
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
bookingId: booking.id,
|
||||
warehouseId: warehouse.id,
|
||||
yardId: warehouseYard.id,
|
||||
zoneId: warehouseZone.id,
|
||||
status: 'READY_FOR_LOADING',
|
||||
inspectionStatus: 'PASSED',
|
||||
inspectedAt: new Date(now.getTime() - 3600 * 1000),
|
||||
quantity: 1,
|
||||
weight: seed.weight,
|
||||
arrivedAt: new Date(now.getTime() - 7200 * 1000),
|
||||
readyForLoadingAt: new Date(now.getTime() - 1800 * 1000),
|
||||
notes: `[SEED-B5] ${seed.notes}`,
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Seeded ${seed.ref} → READY_FOR_LOADING + PASSED`);
|
||||
}
|
||||
|
||||
this.logger.log('✅ Batch 5 Ready-To-Load test data seeded successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Batch5TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts
Normal file
103
apps/edr-freight-api/src/seed/batch7-test-data.seeder.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
|
||||
/**
|
||||
* Seeds two ARRIVED train schedules so the Import Arrive Queue (Batch 7) is demonstrable:
|
||||
* - SEED-IMP-TRAIN-01: DJIB_PORT → MOJO (IMPORT) linked to booking SEED-IMP-001 → SHOWS
|
||||
* - SEED-EXP-TRAIN-01: MOJO → DJIB_PORT (EXPORT) linked to booking SEED-EXP-001 → must NOT show
|
||||
*
|
||||
* Read-only train-schedule SERVICE logic is untouched; this only inserts fixture rows.
|
||||
* Idempotent: guards on the import train number.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Batch7TestDataSeeder {
|
||||
private readonly logger = new Logger(Batch7TestDataSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
|
||||
const existing = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } });
|
||||
if (existing) {
|
||||
this.logger.log('Batch 7 test data already seeded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
const locoRepo = this.dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = this.dataSource.getRepository(TrainSet);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const importBooking = await bookingRepo.findOne({ where: { reference: 'SEED-IMP-001' } });
|
||||
const exportBooking = await bookingRepo.findOne({ where: { reference: 'SEED-EXP-001' } });
|
||||
if (!importBooking) {
|
||||
this.logger.warn('SEED-IMP-001 booking not found; skipping Batch 7 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
// One shared locomotive is fine — train_set.locomotive_id is not unique.
|
||||
const loco =
|
||||
(await locoRepo.findOne({ where: { code: 'SEED-LOCO-01' } })) ??
|
||||
(await locoRepo.save(
|
||||
locoRepo.create({ code: 'SEED-LOCO-01', name: 'Seed Locomotive', maxPullWeightTons: 4000 }),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const arrival = new Date(now.getTime() - 3600 * 1000);
|
||||
const departure = new Date(now.getTime() - 6 * 3600 * 1000);
|
||||
|
||||
const makeArrivedTrain = async (
|
||||
trainNumber: string,
|
||||
booking: Booking,
|
||||
): Promise<void> => {
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: loco.id,
|
||||
totalWeightTons: 500,
|
||||
totalLengthMeters: 300,
|
||||
wagonCount: 10,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: booking.originYardId,
|
||||
destinationStationId: booking.destinationYardId,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber,
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: booking.id }),
|
||||
);
|
||||
|
||||
this.logger.log(`Seeded arrived train ${trainNumber} → booking ${booking.reference}`);
|
||||
};
|
||||
|
||||
await makeArrivedTrain('SEED-IMP-TRAIN-01', importBooking);
|
||||
if (exportBooking) {
|
||||
await makeArrivedTrain('SEED-EXP-TRAIN-01', exportBooking);
|
||||
}
|
||||
|
||||
this.logger.log('✅ Batch 7 arrive-queue test data seeded successfully');
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Batch7TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
51
apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts
Normal file
51
apps/edr-freight-api/src/seed/batch8-test-data.seeder.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
|
||||
/**
|
||||
* Makes the Batch 7 seed import train demonstrable for Batch 8: a booking riding an ARRIVED
|
||||
* train is IN_TRANSIT until unloaded, so flip the seed import train's assigned bookings to
|
||||
* IN_TRANSIT (an unload-eligible status). Idempotent — re-applying IN_TRANSIT is a no-op.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Batch8TestDataSeeder {
|
||||
private readonly logger = new Logger(Batch8TestDataSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
try {
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
|
||||
const train = await scheduleRepo.findOne({ where: { trainNumber: 'SEED-IMP-TRAIN-01' } });
|
||||
if (!train) {
|
||||
this.logger.log('SEED-IMP-TRAIN-01 not found; skipping Batch 8 seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const links = await scheduleBookingRepo.find({ where: { trainScheduleId: train.id } });
|
||||
let updated = 0;
|
||||
for (const link of links) {
|
||||
const booking = await bookingRepo.findOne({ where: { id: link.bookingId } });
|
||||
if (!booking || booking.status === 'IN_TRANSIT') continue;
|
||||
await bookingRepo.update(booking.id, { status: 'IN_TRANSIT' });
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
if (updated > 0) {
|
||||
this.logger.log(`✅ Batch 8: set ${updated} import train booking(s) to IN_TRANSIT (unload-eligible)`);
|
||||
} else {
|
||||
this.logger.log('Batch 8: import train bookings already IN_TRANSIT, skipping');
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Batch8TestDataSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
258
apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
Normal file
258
apps/edr-freight-api/src/seed/warehouse-demo.seeder.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainScheduleBooking } from '../modules/train-schedules/entities/train-schedule-booking.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
import { WarehouseInventory } from '../modules/warehouses/entities/warehouse-inventory.entity';
|
||||
import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.entity';
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
|
||||
/**
|
||||
* One coherent warehouse dataset so EVERY queue/tab shows representative data:
|
||||
* Export → Receive Queue : PAID export bookings, not yet received
|
||||
* Export → Ready To Load : EXPORT inventory READY_FOR_LOADING + inspection PASSED
|
||||
* Export → Loaded/Dispatch : EXPORT inventory LOADED
|
||||
* Import → Arrive Queue : an ARRIVED import train with IN_TRANSIT bookings (no inventory)
|
||||
* Import → Unloaded Queue : UNLOADED import inventory
|
||||
* Import → Dispatch Queue : READY_FOR_PICKUP import inventory (PASSED)
|
||||
*
|
||||
* Idempotent: guarded on a sentinel booking reference. Uses dedicated WH-DEMO-* references so it
|
||||
* never collides with other seeders. To repopulate after items are walked through their lifecycle,
|
||||
* delete the WH-DEMO-* bookings (cascades) and reboot.
|
||||
*/
|
||||
@Injectable()
|
||||
export class WarehouseDemoSeeder {
|
||||
private readonly logger = new Logger(WarehouseDemoSeeder.name);
|
||||
private readonly SENTINEL = 'WH-DEMO-RCV-1';
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
if (await bookingRepo.findOne({ where: { reference: this.SENTINEL } })) {
|
||||
this.logger.log('Warehouse demo data already seeded, skipping');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const yardRepo = this.dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = this.dataSource.getRepository(ServiceType);
|
||||
const cargoTypeRepo = this.dataSource.getRepository(CargoType);
|
||||
const warehouseRepo = this.dataSource.getRepository(Warehouse);
|
||||
const whYardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||
const whZoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||
const invRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
|
||||
const djibYard =
|
||||
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const ethYard =
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
|
||||
if (!djibYard || !ethYard || !serviceType) {
|
||||
this.logger.warn(
|
||||
`Missing yards/service type (djib=${djibYard?.code}, eth=${ethYard?.code}, svc=${serviceType?.code}); skipping`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
|
||||
const whYard = warehouse ? await whYardRepo.findOne({ where: { warehouseId: warehouse.id } }) : null;
|
||||
const whZone = whYard ? await whZoneRepo.findOne({ where: { yardId: whYard.id } }) : null;
|
||||
if (!warehouse || !whYard || !whZone) {
|
||||
this.logger.warn('INDODE_OPEN warehouse/yard/zone missing; skipping warehouse demo seed');
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const ago = (mins: number) => new Date(now - mins * 60_000);
|
||||
|
||||
// EXPORT booking = Ethiopia → Djibouti; IMPORT booking = Djibouti → Ethiopia.
|
||||
const makeBooking = async (
|
||||
reference: string,
|
||||
direction: 'EXPORT' | 'IMPORT',
|
||||
status: string,
|
||||
weight: number,
|
||||
idx: number,
|
||||
): Promise<Booking> =>
|
||||
bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: direction === 'EXPORT' ? ethYard.id : djibYard.id,
|
||||
destinationYardId: direction === 'EXPORT' ? djibYard.id : ethYard.id,
|
||||
serviceTypeId: serviceType.id,
|
||||
status,
|
||||
paymentStatus: 'PAID',
|
||||
tradeDirection: direction,
|
||||
freightType: idx % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType ? null : `${direction} demo cargo ${idx}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
}),
|
||||
);
|
||||
|
||||
const makeInventory = async (
|
||||
booking: Booking,
|
||||
status: string,
|
||||
weight: number,
|
||||
extra: Partial<WarehouseInventory>,
|
||||
): Promise<void> => {
|
||||
await invRepo.save(
|
||||
invRepo.create({
|
||||
warehouseId: warehouse.id,
|
||||
yardId: whYard.id,
|
||||
zoneId: whZone.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: status as WarehouseInventory['status'],
|
||||
notes: '[WH-DEMO]',
|
||||
...extra,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
let created = 0;
|
||||
|
||||
// 1) Export Receive Queue — 3 PAID export bookings, NO inventory.
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await makeBooking(`WH-DEMO-RCV-${i}`, 'EXPORT', 'PAID', 4000 + i * 500, i);
|
||||
created++;
|
||||
}
|
||||
|
||||
// 2) Export Ready To Load — EXPORT inventory READY_FOR_LOADING + PASSED.
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-RTL-${i}`, 'EXPORT', 'PAID', 6000 + i * 500, i);
|
||||
await makeInventory(b, 'READY_FOR_LOADING', 6000 + i * 500, {
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: ago(180),
|
||||
inspectedAt: ago(120),
|
||||
readyForLoadingAt: ago(60),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// 3) Export Loaded / Dispatch Queue — EXPORT inventory LOADED.
|
||||
for (let i = 1; i <= 2; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-LOAD-${i}`, 'EXPORT', 'PAID', 7000 + i * 500, i);
|
||||
await makeInventory(b, 'LOADED', 7000 + i * 500, {
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: ago(240),
|
||||
inspectedAt: ago(180),
|
||||
readyForLoadingAt: ago(120),
|
||||
loadedAt: ago(30),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// 4) Import Unloaded Queue — UNLOADED import inventory (not inspected, not stored).
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-UNL-${i}`, 'IMPORT', 'IN_TRANSIT', 5000 + i * 500, i);
|
||||
await makeInventory(b, 'UNLOADED', 5000 + i * 500, {
|
||||
arrivedAt: ago(90),
|
||||
unloadedAt: ago(45),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// 5) Import Dispatch Queue — READY_FOR_PICKUP import inventory (inspection PASSED).
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await makeBooking(`WH-DEMO-PKR-${i}`, 'IMPORT', 'IN_TRANSIT', 5500 + i * 500, i);
|
||||
await makeInventory(b, 'READY_FOR_PICKUP', 5500 + i * 500, {
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: ago(200),
|
||||
unloadedAt: ago(160),
|
||||
inspectedAt: ago(120),
|
||||
readyForPickupAt: ago(60),
|
||||
});
|
||||
created++;
|
||||
}
|
||||
|
||||
// 6) Import Arrive Queue — an ARRIVED import train with IN_TRANSIT bookings, no inventory yet.
|
||||
await this.seedArrivedImportTrain(djibYard, ethYard, serviceType, cargoType, ago(60), ago(360));
|
||||
created += 1;
|
||||
|
||||
this.logger.log(`✅ Warehouse demo seeded: ${created} buckets populated across every queue`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`WarehouseDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** An ARRIVED Djibouti→Ethiopia train with 3 IN_TRANSIT bookings (no inventory) for the Arrive Queue. */
|
||||
private async seedArrivedImportTrain(
|
||||
djibYard: Yard,
|
||||
ethYard: Yard,
|
||||
serviceType: ServiceType,
|
||||
cargoType: CargoType | null,
|
||||
arrival: Date,
|
||||
departure: Date,
|
||||
): Promise<void> {
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
const locoRepo = this.dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = this.dataSource.getRepository(TrainSet);
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const loco =
|
||||
(await locoRepo.findOne({ where: { code: 'WH-DEMO-LOCO' } })) ??
|
||||
(await locoRepo.save(locoRepo.create({ code: 'WH-DEMO-LOCO', name: 'Demo Locomotive', maxPullWeightTons: 4000 })));
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: loco.id,
|
||||
totalWeightTons: 500,
|
||||
totalLengthMeters: 300,
|
||||
wagonCount: 10,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: djibYard.id,
|
||||
destinationStationId: ethYard.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: 'WH-DEMO-IMP-TRAIN',
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const b = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference: `WH-DEMO-ARR-${i}`,
|
||||
originYardId: djibYard.id,
|
||||
destinationYardId: ethYard.id,
|
||||
serviceTypeId: serviceType.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: i % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType ? null : `IMPORT arrive demo cargo ${i}`,
|
||||
cargoTotalWeightVgm: 5000 + i * 400,
|
||||
}),
|
||||
);
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({ trainScheduleId: schedule.id, bookingId: b.id }),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,7 @@
|
||||
"preview": "vite preview --port 5183",
|
||||
"lint": "eslint src",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit",
|
||||
"build:user-management": "cd user-management-config && npm run build",
|
||||
"backoffice": "npm run build:user-management && nx serve @fhc-platform/backoffice",
|
||||
"backoffice:no-build": "nx serve @fhc-platform/backoffice"
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@edr/types": "workspace:*",
|
||||
@@ -22,7 +19,7 @@
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tabler/icons-react": "^3.44.0",
|
||||
"@tanstack/react-query": "^5.100.11",
|
||||
"@tria-plc/iamui-common": "1.1.2",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -49,6 +49,8 @@ import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"
|
||||
import RuleEngineLegacyRedirect from "./pages/ruleEngine/RuleEngineLegacyRedirect";
|
||||
import RuleEngineResourcePage from "./pages/ruleEngine/RuleEngineResourcePage";
|
||||
import TrainDetailPage from "./pages/trains/TrainDetailPage";
|
||||
import CargoTypesPage from "./pages/ruleEngine/CargoTypesPage";
|
||||
import TrainScheduleV2ListPage from "./pages/trainScheduling/TrainScheduleV2ListPage";
|
||||
import BatchBoardPage from "./pages/trainScheduling/BatchBoardPage";
|
||||
import BatchScheduleDetailPage from "./pages/trainScheduling/BatchScheduleDetailPage";
|
||||
import TrainScheduleTrackPage from "./pages/trainScheduling/TrainScheduleTrackPage";
|
||||
@@ -213,34 +215,6 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [
|
||||
{
|
||||
title: "Administration",
|
||||
items: [
|
||||
{
|
||||
label: "User management",
|
||||
href: "/dashboard/user-management",
|
||||
icon: <Network />,
|
||||
permission: FREIGHT_PERMS.admin,
|
||||
children: [
|
||||
{
|
||||
label: "Users",
|
||||
href: "/dashboard/user-management/users",
|
||||
},
|
||||
{
|
||||
label: "Employees",
|
||||
href: "/dashboard/user-management/employees",
|
||||
},
|
||||
{
|
||||
label: "Position Types",
|
||||
href: "/dashboard/user-management/position-types",
|
||||
},
|
||||
{
|
||||
label: "Permissions",
|
||||
href: "/dashboard/user-management/permissions",
|
||||
},
|
||||
{
|
||||
label: "Roles",
|
||||
href: "/dashboard/user-management/roles",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "File settings",
|
||||
href: "/dashboard/file-settings",
|
||||
@@ -341,216 +315,210 @@ const App = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/auth" element={<LoginPage />} />
|
||||
<Route path='/um/*' element={<UserManagementHostPage />} />
|
||||
<Route path="*" element={<Navigate to="/um" replace />} />
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="*" element={<Navigate to="/auth" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
<Route path="/um/*" element={<UserManagementHostPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardShell />}>
|
||||
<Route path="overview" element={<OverviewPage />} />
|
||||
<Route path="profile" element={<MyProfilePage />} />
|
||||
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
<Route
|
||||
path="payments"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||
<PaymentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||
<Route
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
<Route path="booking-requests" element={<BookingRequestsPage />} />
|
||||
<Route
|
||||
path="payments"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.bookings.view}>
|
||||
<PaymentsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="booking-requests/new" element={<NewBookingPage />} />
|
||||
<Route path="booking-requests/:id" element={<BookingRequestDetailPage />} />
|
||||
<Route
|
||||
path="booking-requests/:id/contract"
|
||||
element={<BookingContractPage />}
|
||||
/>
|
||||
<Route path="warehouses" element={<WarehouseListPage />} />
|
||||
<Route path="warehouses/:id" element={<WarehouseDetailPage />} />
|
||||
<Route path="warehouse-inventory" element={<WarehouseInventoryPage />} />
|
||||
<Route path="arrival-queue" element={<ArrivalQueuePage />} />
|
||||
<Route path="loading-queue" element={<LoadingQueuePage />} />
|
||||
<Route path="loaded-inventory" element={<LoadedInventoryPage />} />
|
||||
<Route path="dispatch-queue" element={<DispatchQueuePage />} />
|
||||
<Route path="inventory-inquiry" element={<InventoryInquiryPage />} />
|
||||
<Route path="warehouse-rules" element={<WarehouseRulesPage />} />
|
||||
<Route path="warehouse-fee-invoices" element={<WarehouseInvoicesPage />} />
|
||||
<Route path="warehouse-dashboard" element={<WarehouseDashboardPage />} />
|
||||
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchBoardPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchScheduleDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2ListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2DetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleTrackPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling"
|
||||
element={<Navigate to="/dashboard/operations/train-scheduling-v2" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchBoardPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/batch-board/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<BatchScheduleDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2ListPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleV2DetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="operations/train-scheduling-v2/:scheduleId/track"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainScheduleTrackPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="routes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<RoutesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="locomotives"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="trains/:id"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<TrainDetailPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="wagons"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="containers"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="cargoes"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.fleet.view}>
|
||||
<FleetResourcePage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* iframe-based user management module */}
|
||||
<Route path="um/*" element={<UserManagementHostPage />} />
|
||||
{/* Legacy embedded user management routes */}
|
||||
<Route path="user-management" element={<UserManagementPage />} />
|
||||
<Route path="user-management/users" element={<UsersPage />} />
|
||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||
<Route path="user-management/roles" element={<RolesPage />} />
|
||||
|
||||
{/* Legacy embedded user management routes */}
|
||||
<Route path="user-management" element={<UserManagementPage />} />
|
||||
<Route path="user-management/users" element={<UsersPage />} />
|
||||
<Route path="user-management/position-types" element={<PositionTypesPage />} />
|
||||
{/* <Route path="user-management/employees" element={<EmployeesPage />} /> */}
|
||||
<Route path="user-management/permissions" element={<PermissionsPage />} />
|
||||
<Route path="user-management/roles" element={<RolesPage />} />
|
||||
<Route
|
||||
path="file-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<FileUploadSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="dropdown-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<DropdownSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="file-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<FileUploadSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="dropdown-settings"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.admin}>
|
||||
<DropdownSettingsPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="configuration"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/train-scheduling-rules"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainSchedulingGlobalRulesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="configuration/cargo-types" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/cargo-types/:id" element={<CargoTypesPage />} />
|
||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
<Route
|
||||
path="configuration"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="configuration/train-scheduling-rules"
|
||||
element={
|
||||
<RequirePermission permission={FREIGHT_PERMS.trainScheduling.view}>
|
||||
<TrainSchedulingGlobalRulesPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="configuration/:resource" element={<RuleEngineResourcePage />} />
|
||||
<Route
|
||||
path="rules"
|
||||
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
|
||||
/>
|
||||
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||
|
||||
<Route
|
||||
path="rules"
|
||||
element={<Navigate to="/dashboard/rules/priority-configs" replace />}
|
||||
/>
|
||||
<Route path="rules/:resource" element={<RuleEngineResourcePage />} />
|
||||
<Route
|
||||
path="rule-engine"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
/>
|
||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||
|
||||
<Route
|
||||
path="rule-engine"
|
||||
element={<Navigate to="/dashboard/configuration/cargo-types" replace />}
|
||||
/>
|
||||
<Route path="rule-engine/:resource" element={<RuleEngineLegacyRedirect />} />
|
||||
<Route path="user1" element={<DemoUser1Page />} />
|
||||
<Route path="user2" element={<DemoUser2Page />} />
|
||||
|
||||
<Route path="user1" element={<DemoUser1Page />} />
|
||||
<Route path="user2" element={<DemoUser2Page />} />
|
||||
<Route path="org-structure" element={<Navigate to="/um" replace />} />
|
||||
<Route path="org-structure/*" element={<Navigate to="/um" replace />} />
|
||||
</Route>
|
||||
|
||||
<Route
|
||||
path="org-structure"
|
||||
element={<Navigate to="/dashboard/user-management" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="org-structure/*"
|
||||
element={<Navigate to="/dashboard/user-management" replace />}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
<Route path="*" element={<Navigate to="/dashboard/overview" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Stack, Text, Textarea, TextInput } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useDeliverInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface DeliverInventoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function DeliverInventoryModal({ opened, onClose, item }: DeliverInventoryModalProps) {
|
||||
const { toast } = useToast();
|
||||
const deliverMutation = useDeliverInventory();
|
||||
const [receiverName, setReceiverName] = useState('');
|
||||
const [remarks, setRemarks] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setReceiverName('');
|
||||
setRemarks('');
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!receiverName.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Receiver name is required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deliverMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { receiverName: receiverName.trim(), remarks: remarks.trim() || undefined },
|
||||
});
|
||||
toast({ title: 'Delivered — proof of delivery captured' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Delivery failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Deliver to customer (proof of delivery)" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="green" variant="light">
|
||||
<Text size="sm">
|
||||
A release order must already be issued. Capturing the receiver marks the goods <b>DELIVERED</b>.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Receiver name"
|
||||
required
|
||||
placeholder="Who received the goods"
|
||||
value={receiverName}
|
||||
onChange={(e) => setReceiverName(e.currentTarget.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label="Remarks"
|
||||
placeholder="Optional delivery notes"
|
||||
minRows={2}
|
||||
value={remarks}
|
||||
onChange={(e) => setRemarks(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={deliverMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="green" onClick={handleSubmit} loading={deliverMutation.isPending}>
|
||||
Confirm delivery
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import { useState } from 'react';
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
import { Button, Center, Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
useBulkMarkInspected,
|
||||
useDispatchInventory,
|
||||
useMarkReadyForLoading,
|
||||
useMarkReadyForPickup,
|
||||
useStoreInventory,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { DeliverInventoryModal } from './DeliverInventoryModal';
|
||||
import { FeePreviewModal } from './FeePreviewModal';
|
||||
import { InspectionReportModal } from './InspectionReportModal';
|
||||
import { InventoryHistoryModal } from './InventoryHistoryModal';
|
||||
import { LoadInventoryModal } from './LoadInventoryModal';
|
||||
import { MoveInventoryModal } from './MoveInventoryModal';
|
||||
import { ReleaseOrderModal } from './ReleaseOrderModal';
|
||||
import { ReserveInventoryModal } from './ReserveInventoryModal';
|
||||
import { WarehouseInventoryTable } from './WarehouseInventoryTable';
|
||||
import { extractErrorMessage } from './options';
|
||||
@@ -20,10 +25,12 @@ import { extractErrorMessage } from './options';
|
||||
interface InventoryWorkbenchProps {
|
||||
items: WarehouseInventoryItem[];
|
||||
isLoading?: boolean;
|
||||
/** Optional Last Mile action (Batch 8) — only shown for items whose booking requested door delivery. */
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
}
|
||||
|
||||
/** Inventory table + all lifecycle actions (advance / move / reserve / history). */
|
||||
export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps) {
|
||||
export function InventoryWorkbench({ items, isLoading, onLastMile }: InventoryWorkbenchProps) {
|
||||
const { toast } = useToast();
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [moveItem, setMoveItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
@@ -32,10 +39,46 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
const [historyItem, setHistoryItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [inspectItem, setInspectItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [feeItem, setFeeItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [releaseItem, setReleaseItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
const [deliverItem, setDeliverItem] = useState<WarehouseInventoryItem | null>(null);
|
||||
|
||||
const storeMutation = useStoreInventory();
|
||||
const readyMutation = useMarkReadyForLoading();
|
||||
const pickupMutation = useMarkReadyForPickup();
|
||||
const dispatchMutation = useDispatchInventory();
|
||||
const inspectMutation = useBulkMarkInspected();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const allSelected = items.length > 0 && selected.size === items.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleSelect = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
const toggleSelectAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(items.map((i) => i.id)));
|
||||
|
||||
const markInspected = async () => {
|
||||
if (selected.size === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select at least one item' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = (await inspectMutation.mutateAsync({ inventoryIds: [...selected] })) as {
|
||||
data: { inspectedCount: number; skippedCount: number };
|
||||
};
|
||||
const r = res.data;
|
||||
toast({
|
||||
title: `${r.inspectedCount} marked inspected`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
});
|
||||
setSelected(new Set());
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const runDirect = async (item: WarehouseInventoryItem, fn: () => Promise<unknown>, label: string) => {
|
||||
setBusyId(item.id);
|
||||
@@ -63,6 +106,14 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
return;
|
||||
case 'dispatch':
|
||||
return runDirect(item, () => dispatchMutation.mutateAsync(item.id), 'Inventory dispatched');
|
||||
case 'ready-for-pickup':
|
||||
return runDirect(item, () => pickupMutation.mutateAsync(item.id), 'Ready for pickup');
|
||||
case 'release':
|
||||
setReleaseItem(item);
|
||||
return;
|
||||
case 'deliver':
|
||||
setDeliverItem(item);
|
||||
return;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
@@ -78,15 +129,39 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
|
||||
return (
|
||||
<>
|
||||
<WarehouseInventoryTable
|
||||
items={items}
|
||||
busyId={busyId}
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
/>
|
||||
<Stack gap="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Selected: <b>{selected.size}</b>
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={inspectMutation.isPending}
|
||||
onClick={markInspected}
|
||||
>
|
||||
Mark Selected as Inspected
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<WarehouseInventoryTable
|
||||
items={items}
|
||||
busyId={busyId}
|
||||
onAdvance={advance}
|
||||
onMove={setMoveItem}
|
||||
onHistory={setHistoryItem}
|
||||
onInspect={setInspectItem}
|
||||
onFeePreview={setFeeItem}
|
||||
onLastMile={onLastMile}
|
||||
selectedIds={selected}
|
||||
onToggleSelect={toggleSelect}
|
||||
onToggleSelectAll={toggleSelectAll}
|
||||
allSelected={allSelected}
|
||||
someSelected={someSelected}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<MoveInventoryModal opened={Boolean(moveItem)} onClose={() => setMoveItem(null)} item={moveItem} />
|
||||
<ReserveInventoryModal
|
||||
@@ -110,6 +185,8 @@ export function InventoryWorkbench({ items, isLoading }: InventoryWorkbenchProps
|
||||
onClose={() => setFeeItem(null)}
|
||||
inventoryId={feeItem?.id ?? null}
|
||||
/>
|
||||
<ReleaseOrderModal opened={Boolean(releaseItem)} onClose={() => setReleaseItem(null)} item={releaseItem} />
|
||||
<DeliverInventoryModal opened={Boolean(deliverItem)} onClose={() => setDeliverItem(null)} item={deliverItem} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { useReleaseInventory } from '@/hooks/useWarehouses';
|
||||
import type { WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from './options';
|
||||
|
||||
interface ReleaseOrderModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useReleaseInventory();
|
||||
const [reference, setReference] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setReference(item?.releaseOrderReference ?? '');
|
||||
}, [opened, item]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
try {
|
||||
await releaseMutation.mutateAsync({ id: item.id, payload: { reference: reference.trim() || undefined } });
|
||||
toast({ title: 'Release order issued' });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Release failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue release order (DO)" centered size="md">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Records the delivery order / release order sent to the customer. Once issued, the goods can be
|
||||
picked up and delivered.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
label="Release order reference"
|
||||
placeholder="e.g. DO-2026-001"
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
/>
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending}>
|
||||
Issue release order
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -23,15 +23,15 @@ interface WarehouseDashboardChartsProps {
|
||||
}
|
||||
|
||||
const ORANGE = '#f08c00';
|
||||
const GREEN = '#5bbf4a';
|
||||
const GREEN = '#22c55e'; // green from bookings
|
||||
|
||||
/** Inventory lifecycle status series — alternating orange / light green. */
|
||||
/** Inventory lifecycle status series — one distinct color per status (aligned with status badges). */
|
||||
const STATUS_SERIES = [
|
||||
{ key: 'stored', label: 'Stored', color: ORANGE },
|
||||
{ key: 'reserved', label: 'Reserved', color: GREEN },
|
||||
{ key: 'readyForLoading', label: 'Ready', color: ORANGE },
|
||||
{ key: 'loaded', label: 'Loaded', color: GREEN },
|
||||
{ key: 'dispatched', label: 'Dispatched', color: ORANGE },
|
||||
{ key: 'stored', label: 'Stored', color: '#228be6' }, // blue
|
||||
{ key: 'reserved', label: 'Reserved', color: '#ae3ec9' }, // grape
|
||||
{ key: 'readyForLoading', label: 'Ready', color: '#f08c00' }, // orange
|
||||
{ key: 'loaded', label: 'Loaded', color: '#12b886' }, // teal
|
||||
{ key: 'dispatched', label: 'Dispatched', color: GREEN }, // green (bookings)
|
||||
] as const;
|
||||
|
||||
type Granularity = 'week' | 'month' | 'year';
|
||||
@@ -157,8 +157,8 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps
|
||||
outerRadius={95}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{statusData.map((entry, i) => (
|
||||
<Cell key={entry.name} fill={i % 2 === 0 ? ORANGE : GREEN} />
|
||||
{statusData.map((entry) => (
|
||||
<Cell key={entry.name} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ArrowRightLeft, ClipboardList, Coins, History } from 'lucide-react';
|
||||
import { DataTable, type ColumnDef } from '@edr/ui-common';
|
||||
|
||||
import type { InventoryAction, WarehouseInventoryItem } from '@/types/warehouse';
|
||||
import { INVENTORY_NEXT_ACTION } from '@/types/warehouse';
|
||||
import { getNextInventoryAction } from '@/types/warehouse';
|
||||
import { InventoryStatusBadge } from './badges';
|
||||
import { formatDate, formatNumber, humanizeEnum } from './options';
|
||||
|
||||
@@ -16,6 +16,14 @@ interface WarehouseInventoryTableProps {
|
||||
onHistory: (item: WarehouseInventoryItem) => void;
|
||||
onInspect?: (item: WarehouseInventoryItem) => void;
|
||||
onFeePreview?: (item: WarehouseInventoryItem) => void;
|
||||
// Optional Last Mile action — only rendered for items whose booking requested door delivery.
|
||||
onLastMile?: (item: WarehouseInventoryItem) => void;
|
||||
// Optional row selection (used for bulk Mark-as-Inspected).
|
||||
selectedIds?: Set<string>;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
onToggleSelectAll?: () => void;
|
||||
allSelected?: boolean;
|
||||
someSelected?: boolean;
|
||||
}
|
||||
|
||||
const itemKind = (item: WarehouseInventoryItem) => {
|
||||
@@ -31,6 +39,9 @@ const actionColor: Record<InventoryAction, string> = {
|
||||
'ready-for-loading': 'cyan',
|
||||
load: 'teal',
|
||||
dispatch: 'edr-green',
|
||||
'ready-for-pickup': 'orange',
|
||||
release: 'yellow',
|
||||
deliver: 'green',
|
||||
};
|
||||
|
||||
export function WarehouseInventoryTable({
|
||||
@@ -41,6 +52,12 @@ export function WarehouseInventoryTable({
|
||||
onHistory,
|
||||
onInspect,
|
||||
onFeePreview,
|
||||
onLastMile,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
}: WarehouseInventoryTableProps) {
|
||||
const columns = useMemo<ColumnDef<WarehouseInventoryItem>[]>(
|
||||
() => [
|
||||
|
||||
@@ -34,12 +34,14 @@ export function WarehouseStatusBadge({ status }: { status: WarehouseStatus }) {
|
||||
}
|
||||
|
||||
const inventoryStatusColor: Record<InventoryStatus, string> = {
|
||||
UNLOADED: 'indigo',
|
||||
RECEIVED: 'yellow',
|
||||
STORED: 'blue',
|
||||
RESERVED: 'grape',
|
||||
READY_FOR_LOADING: 'cyan',
|
||||
LOADED: 'teal',
|
||||
DISPATCHED: 'edr-green',
|
||||
DELIVERED: 'edr-green',
|
||||
};
|
||||
|
||||
export function InventoryStatusBadge({ status }: { status: InventoryStatus }) {
|
||||
|
||||
@@ -143,6 +143,7 @@ export const URL_CONSTANTS = {
|
||||
TRAIN_SCHEDULING: {
|
||||
ELIGIBLE_BOOKINGS: "/train-scheduling/eligible-bookings",
|
||||
BOOKABLE_SCHEDULES: "/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/train-scheduling/available-days",
|
||||
AVAILABLE_LOCOMOTIVES: "/train-scheduling/available-locomotives",
|
||||
BATCH_BOARD: "/train-scheduling/batch-board",
|
||||
BATCH_BOARD_DETAIL: (scheduleId: string) =>
|
||||
@@ -302,6 +303,27 @@ export const URL_CONSTANTS = {
|
||||
MARK_READY: (id: string) => `/warehouse-inventory/${id}/ready-for-loading`,
|
||||
LOAD: (id: string) => `/warehouse-inventory/${id}/load`,
|
||||
DISPATCH: (id: string) => `/warehouse-inventory/${id}/dispatch`,
|
||||
// Import branch
|
||||
MARK_READY_PICKUP: (id: string) => `/warehouse-inventory/${id}/ready-for-pickup`,
|
||||
RELEASE: (id: string) => `/warehouse-inventory/${id}/release`,
|
||||
DELIVER: (id: string) => `/warehouse-inventory/${id}/deliver`,
|
||||
// Receive (Import/Export bulk)
|
||||
ELIGIBLE_BOOKINGS: (direction?: string) =>
|
||||
direction
|
||||
? `/warehouse-inventory/eligible-bookings?direction=${direction}`
|
||||
: `/warehouse-inventory/eligible-bookings`,
|
||||
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
||||
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
||||
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
||||
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
||||
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
||||
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
||||
IMPORT_ARRIVE_QUEUE: '/warehouse-inventory/import/arrive-queue',
|
||||
IMPORT_TRAIN_ITEMS: (scheduleId: string) =>
|
||||
`/warehouse-inventory/import/trains/${scheduleId}/items`,
|
||||
IMPORT_AUTO_UNLOAD_ARRIVED: '/warehouse-inventory/import/auto-unload-arrived-bookings',
|
||||
IMPORT_UNLOADED_QUEUE: '/warehouse-inventory/import/unloaded-queue',
|
||||
IMPORT_PICKUP_READY_QUEUE: '/warehouse-inventory/import/pickup-ready-queue',
|
||||
},
|
||||
|
||||
WAREHOUSE_LOADINGS: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
// API host is env-driven (set VITE_API_URL per environment, e.g. the remote
|
||||
// https://edrfreightapi.triaplc.com for prod). Falls back to the local API for dev.
|
||||
export const API_BASE_URL =
|
||||
(import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:3001';
|
||||
|
||||
@@ -132,6 +132,29 @@ export const useBookableSchedules = (
|
||||
enabled: Boolean(originYardId && destinationYardId),
|
||||
});
|
||||
|
||||
/**
|
||||
* Day-level pool: which days have an OPEN departure on the route. Staff pick a
|
||||
* day (not a train) when creating a booking; the engine assigns the train.
|
||||
*/
|
||||
export const useAvailableDays = (
|
||||
originYardId?: string | null,
|
||||
destinationYardId?: string | null,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: [
|
||||
...QUERY_KEYS.TRAIN_SCHEDULING.ROOT,
|
||||
"available-days",
|
||||
originYardId ?? "",
|
||||
destinationYardId ?? "",
|
||||
],
|
||||
queryFn: () =>
|
||||
trainSchedulingService.getAvailableDays(
|
||||
originYardId ?? undefined,
|
||||
destinationYardId ?? undefined,
|
||||
),
|
||||
enabled: Boolean(originYardId && destinationYardId),
|
||||
});
|
||||
|
||||
export const useTrainTrack = (id: string | undefined) =>
|
||||
useQuery({
|
||||
queryKey: QUERY_KEYS.TRAIN_SCHEDULING.track(id ?? ""),
|
||||
|
||||
@@ -12,6 +12,10 @@ import type {
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReleaseOrderPayload,
|
||||
DeliverInventoryPayload,
|
||||
BulkReceivePayload,
|
||||
BulkInspectPayload,
|
||||
ReserveInventoryPayload,
|
||||
SaveWarehousePayload,
|
||||
SaveYardPayload,
|
||||
@@ -172,6 +176,97 @@ export const useMoveInventory = () =>
|
||||
warehouseService.move(args.id, args.payload),
|
||||
);
|
||||
|
||||
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ───────────────────────────
|
||||
export const useMarkReadyForPickup = () =>
|
||||
useInventoryMutation((id: string) => warehouseService.markReadyForPickup(id));
|
||||
export const useReleaseInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: ReleaseOrderPayload }) =>
|
||||
warehouseService.release(args.id, args.payload),
|
||||
);
|
||||
export const useDeliverInventory = () =>
|
||||
useInventoryMutation((args: { id: string; payload: DeliverInventoryPayload }) =>
|
||||
warehouseService.deliver(args.id, args.payload),
|
||||
);
|
||||
|
||||
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
|
||||
/**
|
||||
* All not-yet-received PAID bookings, classified IMPORT/EXPORT by route, in one call.
|
||||
* Both Receive tabs share this single query (same key) — only one HTTP request fires —
|
||||
* then filter client-side by direction.
|
||||
*/
|
||||
export function useEligibleBookings(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'eligible-bookings'],
|
||||
queryFn: () => warehouseService.eligibleBookings().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
export const useBulkReceive = () =>
|
||||
useInventoryMutation((payload: BulkReceivePayload) => warehouseService.receiveBulk(payload));
|
||||
export const useLoadPassedExport = () =>
|
||||
useInventoryMutation(() => warehouseService.loadPassedExport());
|
||||
export const useBulkMarkInspected = () =>
|
||||
useInventoryMutation((payload: BulkInspectPayload) => warehouseService.bulkMarkInspected(payload));
|
||||
|
||||
export function useReadyToLoadExport(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'ready-to-load-export'],
|
||||
queryFn: () => warehouseService.readyToLoadExport().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLoadedExport(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'loaded-export'],
|
||||
queryFn: () => warehouseService.loadedExport().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export const useBulkDispatchExport = () =>
|
||||
useInventoryMutation((inventoryIds: string[]) => warehouseService.bulkDispatchExport(inventoryIds));
|
||||
|
||||
/** Arrived IMPORT trains (route-derived). Read-only. */
|
||||
export function useImportArriveQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'import-arrive-queue'],
|
||||
queryFn: () => warehouseService.importArriveQueue().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** Assigned bookings/items for an arrived import train. Read-only. */
|
||||
export function useImportTrainItems(scheduleId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'import-train-items', scheduleId],
|
||||
queryFn: () => warehouseService.importTrainItems(scheduleId as string).then((r) => r.data),
|
||||
enabled: Boolean(scheduleId),
|
||||
});
|
||||
}
|
||||
|
||||
/** Unload all eligible assigned bookings of an ARRIVED import train (→ UNLOADED). */
|
||||
export const useAutoUnloadArrivedBookings = () =>
|
||||
useInventoryMutation((scheduleId: string) => warehouseService.autoUnloadArrivedBookings(scheduleId));
|
||||
|
||||
/** IMPORT inventory in the Unloaded Queue (UNLOADED / destination inspection). Read-only. */
|
||||
export function useImportUnloadedQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'import-unloaded-queue'],
|
||||
queryFn: () => warehouseService.importUnloadedQueue().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
/** IMPORT inventory that is PICKUP_READY (READY_FOR_PICKUP) awaiting pickup/dispatch. Read-only. */
|
||||
export function useImportPickupReadyQueue(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['warehouse-inventory', 'import-pickup-ready-queue'],
|
||||
queryFn: () => warehouseService.importPickupReadyQueue().then((r) => r.data),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
|
||||
export function useLoadableWagons(enabled = true) {
|
||||
|
||||
@@ -44,7 +44,7 @@ import toast from "react-hot-toast";
|
||||
|
||||
import Breadcrumbs from "@/components/ui/Breadcrumbs";
|
||||
import { bookingsService } from "@/services/bookings.service";
|
||||
import { useBookableSchedules } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { useAvailableDays } from "@/hooks/trainScheduling/useTrainScheduling";
|
||||
import { api } from "@/auth/http";
|
||||
import { unwrap } from "@/utils/endpoint";
|
||||
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||
@@ -194,9 +194,9 @@ export default function NewBookingPage() {
|
||||
const [freightType, setFreightType] = useState<FreightType>("CONTAINER");
|
||||
const [originYardId, setOriginYardId] = useState<string | null>(null);
|
||||
const [destinationYardId, setDestinationYardId] = useState<string | null>(null);
|
||||
const [trainScheduleId, setTrainScheduleId] = useState<string | null>(null);
|
||||
const [serviceTypeId, setServiceTypeId] = useState<string | null>(null);
|
||||
const [scheduledDate, setScheduledDate] = useState("");
|
||||
// Day-level pool: staff pick a DAY (yyyy-MM-dd); the engine assigns the train.
|
||||
const [scheduledDay, setScheduledDay] = useState<string | null>(null);
|
||||
const [paymentCurrency, setPaymentCurrency] = useState("ETB");
|
||||
|
||||
// container freight
|
||||
@@ -232,34 +232,37 @@ export default function NewBookingPage() {
|
||||
label: c.name || c.email || c.tin || c.id,
|
||||
}));
|
||||
|
||||
const { data: bookableSchedules, isLoading: schedulesLoading } = useBookableSchedules(
|
||||
// Day-level pool: fetch only the days that have a departure on the route (no
|
||||
// train, no capacity). The batch engine assigns the train after booking.
|
||||
const { data: availableDays, isLoading: daysLoading } = useAvailableDays(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
);
|
||||
const scheduleOptions = (bookableSchedules ?? []).map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.routeName ?? `${s.origin} → ${s.destination}`} · ${new Date(
|
||||
s.scheduleDate,
|
||||
).toLocaleString()} · ${s.remainingWagons}/${s.maxWagons} wagons free`,
|
||||
const dayOptions = (availableDays ?? []).map((day) => ({
|
||||
value: day,
|
||||
label: new Date(`${day}T00:00:00`).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
}),
|
||||
}));
|
||||
const selectedSchedule = (bookableSchedules ?? []).find((s) => s.id === trainScheduleId);
|
||||
const hasAvailableDays = (availableDays ?? []).length > 0;
|
||||
|
||||
// When a schedule is chosen its date IS the departure; otherwise fall back to the manual field.
|
||||
const effectiveDepartureIso = selectedSchedule
|
||||
? new Date(selectedSchedule.scheduleDate).toISOString()
|
||||
: scheduledDate
|
||||
? new Date(scheduledDate).toISOString()
|
||||
: "";
|
||||
// The chosen day becomes the booking's scheduledDate (start of day, ISO).
|
||||
const effectiveDepartureIso = scheduledDay
|
||||
? new Date(`${scheduledDay}T00:00:00`).toISOString()
|
||||
: "";
|
||||
|
||||
const yardRecords = refData?.yard ?? [];
|
||||
const yards = yardRecords.map((y) => ({ value: y.id, label: y.name ?? y.code }));
|
||||
const originYard = yardRecords.find((y) => y.id === originYardId) ?? null;
|
||||
const destinationYard = yardRecords.find((y) => y.id === destinationYardId) ?? null;
|
||||
const tradeDirection = deriveTradeDirectionFromYards(originYard, destinationYard);
|
||||
const hasBookableSchedules = (bookableSchedules ?? []).length > 0;
|
||||
|
||||
// Reset the day when the route changes — available days depend on the route.
|
||||
useEffect(() => {
|
||||
setTrainScheduleId(null);
|
||||
setScheduledDay(null);
|
||||
}, [originYardId, destinationYardId]);
|
||||
const services = (refData?.service ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
const shippingLines = (refData?.shipping_line ?? []).map((s) => ({ value: s.id, label: s.name ?? s.code }));
|
||||
@@ -302,16 +305,15 @@ export default function NewBookingPage() {
|
||||
const allLinesValid = lines.length > 0 && lines.every(lineValid);
|
||||
const sameYard = Boolean(originYardId && originYardId === destinationYardId);
|
||||
|
||||
const scheduleSatisfied =
|
||||
hasBookableSchedules ? Boolean(trainScheduleId) : Boolean(scheduledDate);
|
||||
const departureSatisfied = Boolean(selectedSchedule) || Boolean(scheduledDate);
|
||||
// Day-level pool: a shipment DAY is all staff pick. The batch engine assigns
|
||||
// the train afterwards (same flow as the customer portal).
|
||||
const departureSatisfied = Boolean(scheduledDay);
|
||||
|
||||
const canSubmit =
|
||||
Boolean(originYardId) &&
|
||||
Boolean(destinationYardId) &&
|
||||
!sameYard &&
|
||||
Boolean(tradeDirection) &&
|
||||
scheduleSatisfied &&
|
||||
Boolean(serviceTypeId) &&
|
||||
departureSatisfied &&
|
||||
(isGovernment ? governmentInstitution.trim().length >= 2 : Boolean(companyId)) &&
|
||||
@@ -339,7 +341,7 @@ export default function NewBookingPage() {
|
||||
scheduledDate: effectiveDepartureIso || new Date().toISOString(),
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
trainScheduleId: trainScheduleId || undefined,
|
||||
// Day-level pool: no trainScheduleId — the engine assigns the train.
|
||||
serviceTypeId,
|
||||
shippingLineId: shippingLineId || undefined,
|
||||
firstMilePickupAddress: firstMilePickupAddress.trim() || undefined,
|
||||
@@ -464,36 +466,30 @@ export default function NewBookingPage() {
|
||||
value={destinationYardId}
|
||||
onChange={(v) => {
|
||||
setDestinationYardId(v);
|
||||
setTrainScheduleId(null);
|
||||
setScheduledDay(null);
|
||||
}}
|
||||
searchable
|
||||
disabled={isLoading}
|
||||
error={sameYard ? "Same as origin" : undefined}
|
||||
/>
|
||||
</Group>
|
||||
{hasBookableSchedules ? (
|
||||
<Select
|
||||
label="Train schedule"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select an open schedule on this route"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={scheduleOptions}
|
||||
value={trainScheduleId}
|
||||
onChange={setTrainScheduleId}
|
||||
searchable
|
||||
required
|
||||
disabled={!originYardId || !destinationYardId || schedulesLoading}
|
||||
nothingFoundMessage="No open schedules on this route"
|
||||
description="The booking will be batched against this schedule once its contract is signed."
|
||||
/>
|
||||
) : originYardId && destinationYardId ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No open train schedule on this route — set a preferred departure below. Staff can
|
||||
link a schedule later.
|
||||
</Text>
|
||||
) : null}
|
||||
<Select
|
||||
label="Shipment day"
|
||||
placeholder={
|
||||
originYardId && destinationYardId
|
||||
? "Select a day with a departure"
|
||||
: "Pick origin & destination first"
|
||||
}
|
||||
data={dayOptions}
|
||||
value={scheduledDay}
|
||||
onChange={setScheduledDay}
|
||||
searchable
|
||||
disabled={!originYardId || !destinationYardId || daysLoading}
|
||||
nothingFoundMessage={
|
||||
hasAvailableDays ? "No match" : "No departures on this route"
|
||||
}
|
||||
description="Pick a day with a departure. The batch engine assigns the train by priority."
|
||||
/>
|
||||
<Group grow align="flex-end">
|
||||
<Select
|
||||
label="Service type"
|
||||
@@ -528,21 +524,22 @@ export default function NewBookingPage() {
|
||||
|
||||
<FormSection icon={CalendarClock} title="Schedule & payment" accent="grape">
|
||||
<Group grow align="flex-start">
|
||||
{selectedSchedule ? (
|
||||
<TextInput
|
||||
label="Departure"
|
||||
value={new Date(selectedSchedule.scheduleDate).toLocaleString()}
|
||||
readOnly
|
||||
description="Taken from the selected train schedule"
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
label="Preferred departure"
|
||||
type="datetime-local"
|
||||
value={scheduledDate}
|
||||
onChange={(e) => setScheduledDate(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
label="Shipment day"
|
||||
value={
|
||||
scheduledDay
|
||||
? new Date(`${scheduledDay}T00:00:00`).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
: ""
|
||||
}
|
||||
placeholder="Pick a day in the Route section"
|
||||
readOnly
|
||||
description="The engine assigns the train on this day"
|
||||
/>
|
||||
<Select
|
||||
label="Payment currency"
|
||||
data={[
|
||||
|
||||
@@ -1,113 +1,82 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { getCookie } from '@/auth/cookies';
|
||||
import { useEffect, useRef } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import {
|
||||
UserManagementApp,
|
||||
type UserManagementRuntimeOptions,
|
||||
type UserManagementSessionSeed,
|
||||
} from "@tria-plc/iamui";
|
||||
|
||||
function readToken(): string | null {
|
||||
return getCookie('auth-token') ?? null;
|
||||
}
|
||||
import { getCookie } from "@/auth/cookies";
|
||||
|
||||
function readRefreshToken(): string | null {
|
||||
return getCookie('refresh-token') ?? null;
|
||||
import { iamConfig } from "./iamConfig";
|
||||
|
||||
function readInitialSession(): UserManagementSessionSeed | null {
|
||||
const token = getCookie("auth-token");
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const refreshToken = getCookie("refresh-token") ?? undefined;
|
||||
|
||||
return {
|
||||
token,
|
||||
refreshToken,
|
||||
rememberMe: true,
|
||||
};
|
||||
}
|
||||
|
||||
export default function UserManagementHostPage() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const rootRef = useRef<Root | null>(null);
|
||||
const unmountTimerRef = useRef<number | null>(null);
|
||||
|
||||
const mountBase = (
|
||||
(import.meta.env.VITE_USER_MANAGEMENT_BASE as string | undefined) ?? '/_um'
|
||||
).replace(/\/$/, '');
|
||||
|
||||
const moduleOrigin = window.location.origin;
|
||||
|
||||
const [iframeSrc] = useState(() => {
|
||||
const sub = location.pathname.replace(/^\/(?:dashboard\/)?um(?=\/|$)/, '');
|
||||
return mountBase + (sub || '/') + location.search;
|
||||
});
|
||||
|
||||
// ✅ Send token when iframe loads
|
||||
const handleIframeLoad = () => {
|
||||
const token = readToken();
|
||||
const refreshToken = readRefreshToken();
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
|
||||
if (!token) {
|
||||
console.warn('⚠️ No authentication token found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target) {
|
||||
console.warn('⚠️ No iframe reference');
|
||||
return;
|
||||
}
|
||||
|
||||
target.postMessage(
|
||||
{
|
||||
type: 'UM_AUTH_TOKEN',
|
||||
token,
|
||||
refreshToken,
|
||||
},
|
||||
moduleOrigin
|
||||
);
|
||||
|
||||
console.log('✅ Token sent to iframe module');
|
||||
};
|
||||
|
||||
// ✅ Listen for messages from iframe
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
// Security: Only accept from same origin
|
||||
if (event.origin !== moduleOrigin) {
|
||||
console.warn('🚫 Blocked message from different origin:', event.origin);
|
||||
return;
|
||||
}
|
||||
const mountNode = mountRef.current;
|
||||
|
||||
const data = event.data as { type?: string; path?: string } | undefined;
|
||||
if (!data) return;
|
||||
if (!mountNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle auth request (if module asks for token again)
|
||||
if (data.type === 'UM_REQUEST_AUTH') {
|
||||
const token = readToken();
|
||||
const refreshToken = readRefreshToken();
|
||||
const target = iframeRef.current?.contentWindow;
|
||||
if (unmountTimerRef.current !== null) {
|
||||
window.clearTimeout(unmountTimerRef.current);
|
||||
unmountTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (token && target) {
|
||||
target.postMessage(
|
||||
{
|
||||
type: 'UM_AUTH_TOKEN',
|
||||
token,
|
||||
refreshToken,
|
||||
},
|
||||
moduleOrigin
|
||||
);
|
||||
console.log('✅ Token resent to iframe (on request)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!rootRef.current) {
|
||||
rootRef.current = createRoot(mountNode);
|
||||
}
|
||||
|
||||
// Handle route synchronization
|
||||
if (data.type === 'UM_ROUTE_CHANGED' && typeof data.path === 'string') {
|
||||
const target = '/dashboard/um' + data.path;
|
||||
if (window.location.pathname + window.location.search !== target) {
|
||||
navigate(target, { replace: true });
|
||||
}
|
||||
}
|
||||
const apiBaseUrl = import.meta.env.VITE_BASE_API_URL.replace(/\/+$/, "");
|
||||
const iamApiUrl = "/um-api";
|
||||
const runtime: UserManagementRuntimeOptions = {
|
||||
basename: "/um",
|
||||
apiBaseUrl,
|
||||
apiUrl: iamApiUrl,
|
||||
recordApiUrl: iamApiUrl,
|
||||
chronicleUrl: iamApiUrl,
|
||||
auditApiUrl: iamApiUrl,
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [moduleOrigin, navigate]);
|
||||
rootRef.current.render(
|
||||
<UserManagementApp
|
||||
config={iamConfig}
|
||||
runtime={runtime}
|
||||
session={{
|
||||
initialSession: readInitialSession(),
|
||||
enableEmbeddedAuthBridge: false,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ position: 'fixed', inset: 0 }}>
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="User Management"
|
||||
src={iframeSrc}
|
||||
onLoad={handleIframeLoad}
|
||||
style={{ width: '100%', height: '100%', border: 0, display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
return () => {
|
||||
unmountTimerRef.current = window.setTimeout(() => {
|
||||
rootRef.current?.unmount();
|
||||
rootRef.current = null;
|
||||
unmountTimerRef.current = null;
|
||||
}, 0);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <div ref={mountRef} style={{ position: "fixed", inset: 0 }} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { DesignConfig } from "@tria-plc/iamui";
|
||||
|
||||
import {
|
||||
FREIGHT_BRAND,
|
||||
FREIGHT_BRAND_DARK,
|
||||
FREIGHT_BRAND_LIGHT,
|
||||
freightBrand,
|
||||
} from "@/theme/freight-brand";
|
||||
|
||||
export const iamConfig: DesignConfig = {
|
||||
brand: {
|
||||
appName: "EDR Freight Backoffice",
|
||||
logoUrl: "/assets/logo.svg",
|
||||
},
|
||||
colors: {
|
||||
primary: FREIGHT_BRAND,
|
||||
primaryForeground: "#ffffff",
|
||||
secondary: "#f4f7fb",
|
||||
background: "#f7f9fb",
|
||||
foreground: "#0f172a",
|
||||
border: "#eef1f4",
|
||||
muted: "#f1f5f9",
|
||||
mutedForeground: "#64748b",
|
||||
card: "#ffffff",
|
||||
sidebar: "#ffffff",
|
||||
danger: "#ef4444",
|
||||
},
|
||||
typography: {
|
||||
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
headingFontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
baseFontSize: "15px",
|
||||
fontWeight: "500",
|
||||
},
|
||||
shape: {
|
||||
radius: "1rem",
|
||||
},
|
||||
shadows: {
|
||||
card: "0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
dropdown: "0 12px 30px rgba(15, 23, 42, 0.12)",
|
||||
modal: "0 20px 45px rgba(15, 23, 42, 0.2)",
|
||||
},
|
||||
components: {
|
||||
buttonDefaultVariant: "filled",
|
||||
inputDefaultSize: "sm",
|
||||
inputRadius: "md",
|
||||
modalRadius: "lg",
|
||||
tableHighlightOnHover: true,
|
||||
},
|
||||
layout: {
|
||||
userManagementView: "classic",
|
||||
showTopBar: true,
|
||||
sidebarWidth: "280px",
|
||||
sidebarCollapsedWidth: "80px",
|
||||
headerHeight: "80px",
|
||||
contentMaxWidth: "none",
|
||||
sidebarBackground: "#ffffff",
|
||||
sidebarColor: "#475569",
|
||||
sidebarMutedColor: "#94a3b8",
|
||||
sidebarActiveBackground:
|
||||
"linear-gradient(135deg, rgba(45, 191, 149, 0.14) 0%, rgba(27, 158, 122, 0.06) 100%)",
|
||||
sidebarActiveColor: FREIGHT_BRAND,
|
||||
sidebarHoverBackground: "#f5f7fa",
|
||||
sidebarBorder: "#eef1f4",
|
||||
sidebarRail: `linear-gradient(180deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
|
||||
sidebarBrandLabel: "EDR Freight",
|
||||
sidebarBrandSublabel: "Backoffice Console",
|
||||
menuBackground: "#ffffff",
|
||||
menuActiveColor: FREIGHT_BRAND,
|
||||
menuActiveBorderColor: FREIGHT_BRAND,
|
||||
menuColor: "#64748b",
|
||||
menuHoverColor: "#0f172a",
|
||||
modalAccentColor: `linear-gradient(135deg, ${FREIGHT_BRAND_LIGHT} 0%, ${FREIGHT_BRAND} 100%)`,
|
||||
modalHeaderBackground: "#ffffff",
|
||||
modalHeaderEditBackground: "#ffffff",
|
||||
modalIconBackground: freightBrand.mutedBg,
|
||||
modalIconColor: FREIGHT_BRAND,
|
||||
modalTitleColor: "#0f172a",
|
||||
modalFocusColor: FREIGHT_BRAND,
|
||||
modalSurface: "#ffffff",
|
||||
},
|
||||
appearance: {
|
||||
colorScheme: "light",
|
||||
slots: {
|
||||
root: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
color: "#0f172a",
|
||||
fontFamily: "'Outfit', var(--font-sans), system-ui, sans-serif",
|
||||
},
|
||||
},
|
||||
shell: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
},
|
||||
},
|
||||
content: {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
},
|
||||
},
|
||||
page: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "24px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
card: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "20px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
sidebar: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "16px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
"sidebar-brand": {
|
||||
styles: {
|
||||
minHeight: "80px",
|
||||
borderBottom: "1px solid #f1f5f9",
|
||||
},
|
||||
},
|
||||
topbar: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "16px",
|
||||
boxShadow:
|
||||
"0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px -16px rgba(15, 23, 42, 0.12)",
|
||||
},
|
||||
},
|
||||
"topbar-panel": {
|
||||
styles: {
|
||||
background: "#f7f9fb",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "12px",
|
||||
},
|
||||
},
|
||||
"topbar-user-summary": {
|
||||
styles: {
|
||||
borderRadius: "14px",
|
||||
},
|
||||
},
|
||||
table: {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
border: "1px solid #eef1f4",
|
||||
borderRadius: "20px",
|
||||
overflow: "hidden",
|
||||
},
|
||||
},
|
||||
"table-header": {
|
||||
styles: {
|
||||
background: "#f8fafc",
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
styles: {
|
||||
borderRadius: "24px",
|
||||
overflow: "hidden",
|
||||
},
|
||||
},
|
||||
"modal-header": {
|
||||
styles: {
|
||||
background: "#ffffff",
|
||||
borderBottom: "1px solid #eef1f4",
|
||||
},
|
||||
},
|
||||
},
|
||||
customCss: `
|
||||
[data-um-app="user-management"] {
|
||||
--um-page-gap: 20px;
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] h1,
|
||||
[data-um-app="user-management"] h2,
|
||||
[data-um-app="user-management"] h3,
|
||||
[data-um-app="user-management"] h4,
|
||||
[data-um-app="user-management"] h5,
|
||||
[data-um-app="user-management"] h6 {
|
||||
letter-spacing: -0.02em;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] [data-um-slot="sidebar-item"][aria-current="page"] {
|
||||
box-shadow: inset 3px 0 0 ${FREIGHT_BRAND};
|
||||
}
|
||||
|
||||
[data-um-app="user-management"] button,
|
||||
[data-um-app="user-management"] input,
|
||||
[data-um-app="user-management"] select,
|
||||
[data-um-app="user-management"] textarea {
|
||||
font-family: 'Outfit', var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
`,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,512 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Navigate, useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Breadcrumbs,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Tooltip,
|
||||
UnstyledButton,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
Boxes,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Home,
|
||||
Layers,
|
||||
Package,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useAuth } from "@/auth/useAuth";
|
||||
import { canAccessRuleEngineResource } from "@/lib/permissions";
|
||||
import RuleEngineFormDialog from "@/components/ruleEngine/RuleEngineFormDialog";
|
||||
import {
|
||||
getRuleEngineResource,
|
||||
type FormFieldDef,
|
||||
} from "@/pages/ruleEngine/config/resources";
|
||||
import {
|
||||
useRuleEngineList,
|
||||
useRuleEngineMutations,
|
||||
} from "@/hooks/rule-engine/useRuleEngine";
|
||||
import type { RuleEngineRecord } from "@/types/rule-engine";
|
||||
|
||||
const CARGO_SLUG = "cargo-types";
|
||||
const BASE_PATH = "/dashboard/configuration/cargo-types";
|
||||
|
||||
interface CargoNode extends RuleEngineRecord {
|
||||
cargoTypeName?: string;
|
||||
code?: string;
|
||||
parentGroupId?: string | null;
|
||||
showFreeTextBox?: boolean;
|
||||
requiresDirectorApproval?: boolean;
|
||||
isActive?: boolean;
|
||||
displayOrder?: number;
|
||||
}
|
||||
|
||||
const str = (v: unknown): string => (v == null ? "" : String(v));
|
||||
const orderOf = (n: CargoNode): number => Number(n.displayOrder ?? 0);
|
||||
|
||||
/** Create/edit form fields. Parent is set from the current page, never picked. */
|
||||
const FORM_FIELDS: FormFieldDef[] = [
|
||||
{ name: "cargoTypeName", label: "Cargo type name", type: "text", required: true },
|
||||
{ name: "showFreeTextBox", label: "Show free text box", type: "boolean" },
|
||||
{ name: "requiresDirectorApproval", label: "Requires director approval", type: "boolean" },
|
||||
{ name: "isActive", label: "Active", type: "boolean" },
|
||||
];
|
||||
|
||||
type FormMode = { kind: "create" } | { kind: "edit"; record: CargoNode };
|
||||
|
||||
const CargoTypesPage = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { id: currentId } = useParams<{ id: string }>();
|
||||
const config = getRuleEngineResource(CARGO_SLUG);
|
||||
|
||||
const canView = canAccessRuleEngineResource(user, CARGO_SLUG, "view");
|
||||
const canManage = canAccessRuleEngineResource(user, CARGO_SLUG, "manage");
|
||||
|
||||
// One fetch of the whole (small) set; the tree, ancestry and each level are
|
||||
// derived client-side so drilling between levels is instant.
|
||||
const { data, isLoading, isError } = useRuleEngineList(CARGO_SLUG, {
|
||||
page: 1,
|
||||
pageSize: 500,
|
||||
sortBy: "displayOrder",
|
||||
sortOrder: "ASC",
|
||||
});
|
||||
|
||||
const { create, update, remove } = useRuleEngineMutations(CARGO_SLUG);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [formMode, setFormMode] = useState<FormMode | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CargoNode | null>(null);
|
||||
|
||||
const all = (data?.data ?? []) as CargoNode[];
|
||||
|
||||
const { byId, childrenOf } = useMemo(() => {
|
||||
const byId = new Map<string, CargoNode>(all.map((n) => [n.id, n]));
|
||||
const childrenOf = new Map<string, CargoNode[]>();
|
||||
for (const node of all) {
|
||||
const parentId = node.parentGroupId && byId.has(node.parentGroupId) ? node.parentGroupId : "";
|
||||
const key = parentId || "__root__";
|
||||
const list = childrenOf.get(key) ?? [];
|
||||
list.push(node);
|
||||
childrenOf.set(key, list);
|
||||
}
|
||||
for (const list of childrenOf.values()) {
|
||||
list.sort(
|
||||
(a, b) =>
|
||||
orderOf(a) - orderOf(b) ||
|
||||
str(a.cargoTypeName).localeCompare(str(b.cargoTypeName)),
|
||||
);
|
||||
}
|
||||
return { byId, childrenOf };
|
||||
}, [all]);
|
||||
|
||||
// Current node (null at root) and its ancestor chain for the breadcrumb.
|
||||
const current = currentId ? byId.get(currentId) ?? null : null;
|
||||
const ancestors = useMemo(() => {
|
||||
const chain: CargoNode[] = [];
|
||||
let node = current;
|
||||
const seen = new Set<string>();
|
||||
while (node && !seen.has(node.id)) {
|
||||
chain.unshift(node);
|
||||
seen.add(node.id);
|
||||
node = node.parentGroupId ? byId.get(node.parentGroupId) ?? null : null;
|
||||
}
|
||||
return chain;
|
||||
}, [current, byId]);
|
||||
|
||||
const levelKey = current ? current.id : "__root__";
|
||||
const levelNodes = childrenOf.get(levelKey) ?? [];
|
||||
|
||||
const term = search.trim().toLowerCase();
|
||||
const matches = (n: CargoNode) =>
|
||||
!term ||
|
||||
str(n.cargoTypeName).toLowerCase().includes(term) ||
|
||||
str(n.code).toLowerCase().includes(term);
|
||||
const visibleNodes = useMemo(
|
||||
() => (term ? levelNodes.filter(matches) : levelNodes),
|
||||
[levelNodes, term],
|
||||
);
|
||||
|
||||
if (!config) return <Navigate to="/dashboard/overview" replace />;
|
||||
if (!canView) return <Navigate to="/dashboard/overview" replace />;
|
||||
// A bad/stale :id (after data loads) → fall back to the root list.
|
||||
if (!isLoading && currentId && !current) return <Navigate to={BASE_PATH} replace />;
|
||||
|
||||
const atRoot = !current;
|
||||
const countAtRoot = (childrenOf.get("__root__") ?? []).length;
|
||||
|
||||
const handleSubmit = (values: Record<string, unknown>) => {
|
||||
const payload: Record<string, unknown> = { ...values };
|
||||
// Add always attaches to the page we're on; edit keeps the node's parent.
|
||||
if (formMode?.kind === "create" && current) {
|
||||
payload.parentGroupId = current.id;
|
||||
}
|
||||
const done = () => setFormMode(null);
|
||||
if (formMode?.kind === "edit") {
|
||||
update.mutate({ id: formMode.record.id, payload }, { onSuccess: done });
|
||||
} else {
|
||||
create.mutate(payload, { onSuccess: done });
|
||||
}
|
||||
};
|
||||
|
||||
const addLabel = atRoot ? "Add category" : "Add cargo type";
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
{/* ── Header ─────────────────────────────────────────────── */}
|
||||
<Card
|
||||
p="lg"
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{ background: "white", boxShadow: "0 1px 3px rgba(0,0,0,0.05)" }}
|
||||
>
|
||||
{/* Breadcrumb */}
|
||||
<Breadcrumbs
|
||||
separator={<ChevronRight size={14} style={{ color: "var(--mantine-color-gray-5)" }} />}
|
||||
mb="md"
|
||||
>
|
||||
<UnstyledButton onClick={() => navigate(BASE_PATH)}>
|
||||
<Group gap={5} wrap="nowrap">
|
||||
<Home size={14} style={{ color: "var(--mantine-color-teal-7)" }} />
|
||||
<Text fz={13} fw={600} c={atRoot ? "dark.7" : "teal.7"}>
|
||||
Cargo Types
|
||||
</Text>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
{ancestors.map((node, i) => {
|
||||
const isLast = i === ancestors.length - 1;
|
||||
return (
|
||||
<UnstyledButton
|
||||
key={node.id}
|
||||
onClick={() => !isLast && navigate(`${BASE_PATH}/${node.id}`)}
|
||||
style={{ cursor: isLast ? "default" : "pointer" }}
|
||||
>
|
||||
<Text fz={13} fw={isLast ? 700 : 600} c={isLast ? "dark.7" : "teal.7"} truncate maw={220}>
|
||||
{str(node.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
);
|
||||
})}
|
||||
</Breadcrumbs>
|
||||
|
||||
<Group justify="space-between" align="flex-start" wrap="wrap" gap="md">
|
||||
<Group gap="md" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<ThemeIcon size={48} radius="md" variant="light" color="teal">
|
||||
{atRoot ? <Boxes size={26} /> : <Layers size={26} />}
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={800} fz={22} c="dark.8" truncate>
|
||||
{atRoot ? "Cargo Types" : str(current?.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
{!atRoot && current?.code ? (
|
||||
<Badge variant="default" radius="sm">
|
||||
{str(current.code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{!atRoot && current?.isActive === false ? (
|
||||
<Badge variant="light" color="gray" radius="sm">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fz={13} c="dimmed" mt={2}>
|
||||
{atRoot
|
||||
? `${countAtRoot} top-level categor${countAtRoot === 1 ? "y" : "ies"} — click one to see what's inside`
|
||||
: `${levelNodes.length} cargo type${levelNodes.length === 1 ? "" : "s"} directly under this category`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<TextInput
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
placeholder="Search this level…"
|
||||
leftSection={<Search size={16} />}
|
||||
w={240}
|
||||
/>
|
||||
{canManage && (
|
||||
<Button
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setFormMode({ kind: "create" })}
|
||||
>
|
||||
{addLabel}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
|
||||
{/* ── Level list ─────────────────────────────────────────── */}
|
||||
<Card
|
||||
p={0}
|
||||
radius="lg"
|
||||
withBorder
|
||||
style={{
|
||||
background: "white",
|
||||
boxShadow: "0 1px 3px rgba(0,0,0,0.05)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="xl">
|
||||
<Loader color="teal" />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Text p="xl" c="red" ta="center">
|
||||
Failed to load cargo types.
|
||||
</Text>
|
||||
) : visibleNodes.length === 0 ? (
|
||||
<Stack align="center" gap="sm" py={56}>
|
||||
<ThemeIcon size={52} radius="xl" variant="light" color="gray">
|
||||
<Package size={26} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} c="dark.6">
|
||||
{term
|
||||
? "Nothing matches your search"
|
||||
: atRoot
|
||||
? "No cargo categories yet"
|
||||
: `No cargo types under “${str(current?.cargoTypeName)}” yet`}
|
||||
</Text>
|
||||
{!term && canManage && (
|
||||
<Button
|
||||
variant="light"
|
||||
color="teal"
|
||||
leftSection={<Plus size={16} />}
|
||||
onClick={() => setFormMode({ kind: "create" })}
|
||||
>
|
||||
{atRoot ? "Add your first category" : "Add the first cargo type"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Stack gap={0}>
|
||||
{visibleNodes.map((node, i) => (
|
||||
<CargoRow
|
||||
key={node.id}
|
||||
node={node}
|
||||
childCount={(childrenOf.get(node.id) ?? []).length}
|
||||
topBorder={i > 0}
|
||||
canManage={canManage}
|
||||
onOpen={() => navigate(`${BASE_PATH}/${node.id}`)}
|
||||
onEdit={() => setFormMode({ kind: "edit", record: node })}
|
||||
onDelete={() => setDeleteTarget(node)}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── Create / edit dialog ───────────────────────────────── */}
|
||||
<RuleEngineFormDialog
|
||||
open={Boolean(formMode)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setFormMode(null);
|
||||
}}
|
||||
title={
|
||||
formMode?.kind === "edit"
|
||||
? `Edit ${str(formMode.record.cargoTypeName)}`
|
||||
: atRoot
|
||||
? "Add category"
|
||||
: `Add cargo under “${str(current?.cargoTypeName)}”`
|
||||
}
|
||||
description={
|
||||
formMode?.kind === "edit"
|
||||
? "Update this cargo type."
|
||||
: atRoot
|
||||
? "Create a top-level cargo category."
|
||||
: "Create a cargo type inside this category. It's attached here automatically."
|
||||
}
|
||||
fields={FORM_FIELDS}
|
||||
initialRecord={formMode?.kind === "edit" ? formMode.record : null}
|
||||
isSubmitting={create.isPending || update.isPending}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
|
||||
{/* ── Delete confirm ─────────────────────────────────────── */}
|
||||
<Modal
|
||||
opened={Boolean(deleteTarget)}
|
||||
onClose={() => setDeleteTarget(null)}
|
||||
title="Delete cargo type?"
|
||||
centered
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm">
|
||||
{deleteTarget && (childrenOf.get(deleteTarget.id)?.length ?? 0) > 0 ? (
|
||||
<>
|
||||
<Text span fw={600}>
|
||||
{str(deleteTarget?.cargoTypeName)}
|
||||
</Text>{" "}
|
||||
has {childrenOf.get(deleteTarget!.id)?.length} cargo type(s) under it. Deleting it
|
||||
leaves them without a category. Continue?
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will delete{" "}
|
||||
<Text span fw={600}>
|
||||
{str(deleteTarget?.cargoTypeName)}
|
||||
</Text>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
loading={remove.isPending}
|
||||
onClick={() => {
|
||||
if (!deleteTarget) return;
|
||||
remove.mutate(deleteTarget.id, { onSuccess: () => setDeleteTarget(null) });
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
// ── A single cargo row — drills into its own page on click ──────────────────
|
||||
interface CargoRowProps {
|
||||
node: CargoNode;
|
||||
childCount: number;
|
||||
topBorder: boolean;
|
||||
canManage: boolean;
|
||||
onOpen: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function CargoRow({
|
||||
node,
|
||||
childCount,
|
||||
topBorder,
|
||||
canManage,
|
||||
onOpen,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: CargoRowProps) {
|
||||
const inactive = node.isActive === false;
|
||||
const hasChildren = childCount > 0;
|
||||
return (
|
||||
<Group
|
||||
justify="space-between"
|
||||
wrap="nowrap"
|
||||
px="lg"
|
||||
py="md"
|
||||
style={{
|
||||
borderTop: topBorder ? "1px solid var(--mantine-color-gray-2)" : undefined,
|
||||
transition: "background 120ms ease",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = "var(--mantine-color-teal-0)";
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = "";
|
||||
}}
|
||||
>
|
||||
<UnstyledButton onClick={onOpen} style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon size={36} radius="md" variant="light" color={inactive ? "gray" : "teal"}>
|
||||
{hasChildren ? <Layers size={18} /> : <Package size={18} />}
|
||||
</ThemeIcon>
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<Group gap={8} wrap="nowrap">
|
||||
<Text fw={650} fz={15} c="dark.8" truncate>
|
||||
{str(node.cargoTypeName) || "Untitled"}
|
||||
</Text>
|
||||
{node.code ? (
|
||||
<Badge size="xs" variant="default" radius="sm">
|
||||
{str(node.code)}
|
||||
</Badge>
|
||||
) : null}
|
||||
{node.requiresDirectorApproval ? (
|
||||
<Tooltip label="Requires director approval" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="sm"
|
||||
leftSection={<ShieldCheck size={11} />}
|
||||
>
|
||||
Approval
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{node.showFreeTextBox ? (
|
||||
<Tooltip label="Shows a free-text box on booking" withArrow>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
radius="sm"
|
||||
leftSection={<FileText size={11} />}
|
||||
>
|
||||
Free text
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{inactive ? (
|
||||
<Badge size="xs" variant="light" color="gray" radius="sm">
|
||||
Inactive
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
<Text fz={12.5} c="dimmed" mt={2}>
|
||||
{hasChildren
|
||||
? `${childCount} cargo type${childCount === 1 ? "" : "s"} inside`
|
||||
: "No cargo types inside yet — open to add"}
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
|
||||
<Group gap={4} wrap="nowrap">
|
||||
{canManage && (
|
||||
<>
|
||||
<Tooltip label="Edit" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="gray" onClick={onEdit} px={8}>
|
||||
<Pencil size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label="Delete" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="red" onClick={onDelete} px={8}>
|
||||
<Trash2 size={15} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
<Tooltip label="Open" withArrow>
|
||||
<Button size="compact-sm" variant="subtle" color="teal" onClick={onOpen} px={8}>
|
||||
<ChevronRight size={18} />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export default CargoTypesPage;
|
||||
@@ -2,8 +2,12 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { Card, Center, Group, Loader, SimpleGrid, Text, ThemeIcon } from '@mantine/core';
|
||||
import {
|
||||
ClipboardCheck,
|
||||
ClipboardList,
|
||||
ShieldCheck,
|
||||
PackageCheck,
|
||||
PackagePlus,
|
||||
PackageSearch,
|
||||
CircleCheck,
|
||||
Send,
|
||||
Truck,
|
||||
Warehouse as WarehouseIcon,
|
||||
@@ -25,14 +29,18 @@ interface Metric {
|
||||
}
|
||||
|
||||
const METRICS: Metric[] = [
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses' },
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory' },
|
||||
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED' },
|
||||
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED' },
|
||||
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED' },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue' },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory' },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue' },
|
||||
{ key: 'totalWarehouses', label: 'Total Warehouses', icon: <WarehouseIcon size={22} />, to: '/dashboard/warehouses', theme: ORANGE },
|
||||
{ key: 'totalInventory', label: 'Total Inventory', icon: <Boxes size={22} />, to: '/dashboard/warehouse-inventory', theme: GREEN },
|
||||
{ key: 'receivedToday', label: 'Received Today', icon: <PackagePlus size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: ORANGE },
|
||||
{ key: 'awaitingInspection', label: 'Awaiting Inspection', icon: <ClipboardList size={22} />, to: '/dashboard/warehouse-inventory?status=RECEIVED', theme: GREEN },
|
||||
{ key: 'inspected', label: 'Inspected', icon: <ShieldCheck size={22} />, to: '/dashboard/warehouse-inventory', theme: ORANGE },
|
||||
{ key: 'stored', label: 'Stored', icon: <Layers size={22} />, to: '/dashboard/warehouse-inventory?status=STORED', theme: GREEN },
|
||||
{ key: 'reserved', label: 'Reserved', icon: <ClipboardCheck size={22} />, to: '/dashboard/warehouse-inventory?status=RESERVED', theme: ORANGE },
|
||||
{ key: 'readyForLoading', label: 'Ready For Loading', icon: <PackageCheck size={22} />, to: '/dashboard/loading-queue', theme: GREEN },
|
||||
{ key: 'loaded', label: 'Loaded', icon: <Truck size={22} />, to: '/dashboard/loaded-inventory', theme: ORANGE },
|
||||
{ key: 'dispatched', label: 'Dispatched', icon: <Send size={22} />, to: '/dashboard/dispatch-queue', theme: GREEN },
|
||||
{ key: 'readyForPickup', label: 'Ready For Pickup', icon: <PackageSearch size={22} />, to: '/dashboard/warehouse-inventory?status=READY_FOR_PICKUP', theme: ORANGE },
|
||||
{ key: 'delivered', label: 'Delivered', icon: <CircleCheck size={22} />, to: '/dashboard/warehouse-inventory?status=DELIVERED', theme: GREEN },
|
||||
];
|
||||
|
||||
export default function WarehouseDashboardPage() {
|
||||
|
||||
@@ -109,6 +109,21 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
/**
|
||||
* Day-level pool: the days that have an OPEN departure on the route. Staff pick
|
||||
* a day; the batch engine assigns the train. No capacity is returned.
|
||||
*/
|
||||
getAvailableDays: async (
|
||||
originYardId?: string,
|
||||
destinationYardId?: string,
|
||||
): Promise<string[]> => {
|
||||
const response = await client.get<{ days: string[] }>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
|
||||
{ params: { originYardId, destinationYardId } },
|
||||
);
|
||||
return unwrap(response.data).days;
|
||||
},
|
||||
|
||||
runBatch: async (scheduleId: string): Promise<BatchBoardScheduleDetail> => {
|
||||
const response = await client.post<BatchBoardScheduleDetail>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.RUN_BATCH(scheduleId),
|
||||
|
||||
@@ -27,6 +27,20 @@ import type {
|
||||
LoadInventoryPayload,
|
||||
MoveInventoryPayload,
|
||||
ReceiveInventoryPayload,
|
||||
ReleaseOrderPayload,
|
||||
DeliverInventoryPayload,
|
||||
EligibleBooking,
|
||||
BulkReceivePayload,
|
||||
BulkReceiveResult,
|
||||
LoadPassedExportResult,
|
||||
BulkInspectPayload,
|
||||
BulkInspectResult,
|
||||
ReadyToLoadRow,
|
||||
BulkDispatchResult,
|
||||
ImportTrain,
|
||||
ImportTrainItem,
|
||||
ImportUnloadedItem,
|
||||
AutoUnloadArrivedResult,
|
||||
ReserveInventoryPayload,
|
||||
SaveWarehousePayload,
|
||||
SaveYardPayload,
|
||||
@@ -104,6 +118,45 @@ export const warehouseService = {
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD(id), payload),
|
||||
dispatch: (id: string) =>
|
||||
apiClient.patch<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DISPATCH(id)),
|
||||
|
||||
// ── Import branch (READY_FOR_PICKUP → DELIVERED) ─────────────────────────
|
||||
markReadyForPickup: (id: string) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MARK_READY_PICKUP(id)),
|
||||
release: (id: string, payload: ReleaseOrderPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RELEASE(id), payload),
|
||||
deliver: (id: string, payload: DeliverInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.DELIVER(id), payload),
|
||||
|
||||
// ── Receive (Import/Export bulk) ─────────────────────────────────────────
|
||||
eligibleBookings: (direction?: 'IMPORT' | 'EXPORT') =>
|
||||
apiClient.get<EligibleBooking[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.ELIGIBLE_BOOKINGS(direction)),
|
||||
receiveBulk: (payload: BulkReceivePayload) =>
|
||||
apiClient.post<BulkReceiveResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVE_BULK, payload),
|
||||
loadPassedExport: () =>
|
||||
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
|
||||
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
||||
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
|
||||
readyToLoadExport: () =>
|
||||
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT),
|
||||
loadedExport: () =>
|
||||
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOADED_EXPORT),
|
||||
bulkDispatchExport: (inventoryIds: string[]) =>
|
||||
apiClient.post<BulkDispatchResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_DISPATCH_EXPORT, {
|
||||
inventoryIds,
|
||||
}),
|
||||
importArriveQueue: () =>
|
||||
apiClient.get<ImportTrain[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_ARRIVE_QUEUE),
|
||||
importTrainItems: (scheduleId: string) =>
|
||||
apiClient.get<ImportTrainItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_TRAIN_ITEMS(scheduleId)),
|
||||
autoUnloadArrivedBookings: (scheduleId: string) =>
|
||||
apiClient.post<AutoUnloadArrivedResult>(
|
||||
URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_AUTO_UNLOAD_ARRIVED,
|
||||
{ scheduleId },
|
||||
),
|
||||
importUnloadedQueue: () =>
|
||||
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_UNLOADED_QUEUE),
|
||||
importPickupReadyQueue: () =>
|
||||
apiClient.get<ImportUnloadedItem[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.IMPORT_PICKUP_READY_QUEUE),
|
||||
move: (id: string, payload: MoveInventoryPayload) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVENTORY.MOVE(id), payload),
|
||||
movements: (id: string) =>
|
||||
|
||||
@@ -23,26 +23,72 @@ export const WAREHOUSE_ZONE_TYPES = [
|
||||
export type WarehouseZoneType = (typeof WAREHOUSE_ZONE_TYPES)[number];
|
||||
|
||||
export const INVENTORY_STATUSES = [
|
||||
'UNLOADED',
|
||||
'RECEIVED',
|
||||
'STORED',
|
||||
'RESERVED',
|
||||
'READY_FOR_LOADING',
|
||||
'LOADED',
|
||||
'DISPATCHED',
|
||||
// Import branch
|
||||
'READY_FOR_PICKUP',
|
||||
'DELIVERED',
|
||||
] as const;
|
||||
export type InventoryStatus = (typeof INVENTORY_STATUSES)[number];
|
||||
|
||||
/** Next allowed lifecycle action keyed by current status. */
|
||||
export type InventoryAction =
|
||||
| 'store'
|
||||
| 'reserve'
|
||||
| 'ready-for-loading'
|
||||
| 'load'
|
||||
| 'dispatch'
|
||||
// Import branch
|
||||
| 'ready-for-pickup'
|
||||
| 'release'
|
||||
| 'deliver';
|
||||
|
||||
/**
|
||||
* Default next lifecycle action keyed by current status. The RECEIVED and
|
||||
* READY_FOR_PICKUP rows are direction/release dependent — use
|
||||
* {@link getNextInventoryAction} which resolves those at runtime.
|
||||
*/
|
||||
export const INVENTORY_NEXT_ACTION: Record<InventoryStatus, InventoryAction | null> = {
|
||||
UNLOADED: 'store',
|
||||
RECEIVED: 'store',
|
||||
STORED: 'reserve',
|
||||
RESERVED: 'ready-for-loading',
|
||||
READY_FOR_LOADING: 'load',
|
||||
LOADED: 'dispatch',
|
||||
DISPATCHED: null,
|
||||
READY_FOR_PICKUP: 'release',
|
||||
DELIVERED: null,
|
||||
};
|
||||
|
||||
export type InventoryAction = 'store' | 'reserve' | 'ready-for-loading' | 'load' | 'dispatch';
|
||||
/**
|
||||
* Resolve the next action for an inventory item, accounting for trade
|
||||
* direction, the inspection gate, and whether a release order was issued.
|
||||
* Returns null when no advance button should be shown (e.g. awaiting inspection).
|
||||
*/
|
||||
export function getNextInventoryAction(item: WarehouseInventoryItem): InventoryAction | null {
|
||||
const inspected = item.inspectionStatus === 'PASSED';
|
||||
const isImport = item.booking?.tradeDirection === 'IMPORT';
|
||||
|
||||
switch (item.status) {
|
||||
case 'UNLOADED':
|
||||
case 'RECEIVED':
|
||||
// Import goods skip storage; they need inspection before pickup.
|
||||
if (isImport) return inspected ? 'ready-for-pickup' : null;
|
||||
return 'store';
|
||||
case 'RESERVED':
|
||||
// Export loading is gated on a passed inspection.
|
||||
return inspected ? 'ready-for-loading' : null;
|
||||
case 'READY_FOR_PICKUP':
|
||||
// Issue the DO / release order first, then hand over the goods.
|
||||
return item.releaseDate ? 'deliver' : 'release';
|
||||
default:
|
||||
return INVENTORY_NEXT_ACTION[item.status];
|
||||
}
|
||||
}
|
||||
|
||||
export interface WarehouseZone {
|
||||
id: string;
|
||||
@@ -136,6 +182,7 @@ export interface WarehouseInventoryItem {
|
||||
weight: number;
|
||||
volume: number | null;
|
||||
status: InventoryStatus;
|
||||
inspectionStatus: string | null;
|
||||
arrivedAt: string | null;
|
||||
storedAt: string | null;
|
||||
reservedAt: string | null;
|
||||
@@ -143,6 +190,11 @@ export interface WarehouseInventoryItem {
|
||||
readyForLoadingAt: string | null;
|
||||
loadedAt: string | null;
|
||||
dispatchedAt: string | null;
|
||||
// Import branch
|
||||
readyForPickupAt: string | null;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
deliveredAt: string | null;
|
||||
notes: string | null;
|
||||
warehouse?: Warehouse | null;
|
||||
yard?: WarehouseYard | null;
|
||||
@@ -156,6 +208,9 @@ export interface InventoryBookingRef {
|
||||
reference?: string | null;
|
||||
status?: string | null;
|
||||
paymentStatus?: string | null;
|
||||
tradeDirection?: string | null;
|
||||
// Present when the customer requested last-mile (door) delivery — gates the Last Mile action.
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryMovement {
|
||||
@@ -197,11 +252,15 @@ export interface WarehouseDashboard {
|
||||
totalWarehouses: number;
|
||||
totalInventory: number;
|
||||
receivedToday: number;
|
||||
awaitingInspection: number;
|
||||
inspected: number;
|
||||
stored: number;
|
||||
reserved: number;
|
||||
readyForLoading: number;
|
||||
loaded: number;
|
||||
dispatched: number;
|
||||
readyForPickup: number;
|
||||
delivered: number;
|
||||
}
|
||||
|
||||
// ── Loading (Batch 3) ────────────────────────────────────────────────────────
|
||||
@@ -265,6 +324,139 @@ export interface ReserveInventoryPayload {
|
||||
inventoryId: string;
|
||||
}
|
||||
|
||||
/** Import branch: DO / release order sent to the customer. */
|
||||
export interface ReleaseOrderPayload {
|
||||
reference?: string;
|
||||
releaseDate?: string;
|
||||
}
|
||||
|
||||
/** Import branch: proof of delivery captured on customer pickup. */
|
||||
export interface DeliverInventoryPayload {
|
||||
receiverName: string;
|
||||
deliveredAt?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
/** Receive (Import/Export) bulk flow. */
|
||||
export interface EligibleBooking {
|
||||
id: string;
|
||||
reference: string;
|
||||
customerId: string | null;
|
||||
customer: string | null;
|
||||
direction: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
freightType: string | null;
|
||||
cargo: string | null;
|
||||
weight: string | null;
|
||||
paymentStatus: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
direction: 'IMPORT' | 'EXPORT';
|
||||
warehouseId: string;
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
bookingIds: string[];
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
receivedCount: number;
|
||||
skippedCount: number;
|
||||
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface LoadPassedExportResult {
|
||||
loadedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface BulkInspectPayload {
|
||||
inventoryIds: string[];
|
||||
inspectionType?: string;
|
||||
remarks?: string;
|
||||
}
|
||||
|
||||
export interface BulkInspectResult {
|
||||
inspectedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface ReadyToLoadRow {
|
||||
id: string;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
inspectionStatus: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BulkDispatchResult {
|
||||
dispatchedCount: number;
|
||||
skippedCount: number;
|
||||
results: { inventoryId: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface ImportTrain {
|
||||
scheduleId: string;
|
||||
trainNumber: string | null;
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
arrivalTime: string | null;
|
||||
totalBookings: number;
|
||||
totalContainers: number;
|
||||
totalCargoes: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface AutoUnloadArrivedResult {
|
||||
unloadedCount: number;
|
||||
skippedCount: number;
|
||||
failedCount: number;
|
||||
results: { bookingId: string; inventoryId?: string; status: string; reason?: string }[];
|
||||
}
|
||||
|
||||
export interface ImportUnloadedItem {
|
||||
id: string;
|
||||
bookingId: string | null;
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
arrivalTime: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
trainSchedule: string | null;
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
currentStatus: string;
|
||||
}
|
||||
|
||||
export interface ImportTrainItem {
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
customerId: string | null;
|
||||
customerName: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoType: string | null;
|
||||
weight: number | null;
|
||||
arrivalTime: string | null;
|
||||
currentStatus: string | null;
|
||||
lastMileRequested: boolean;
|
||||
pickupOption: string;
|
||||
}
|
||||
|
||||
export interface InventoryInquiryResult {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
|
||||
1
apps/edr-freight-web/backoffice/user-management
Submodule
1
apps/edr-freight-web/backoffice/user-management
Submodule
Submodule apps/edr-freight-web/backoffice/user-management added at e3a50a7c91
@@ -1,58 +1,127 @@
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { loadEnv, type Plugin } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import type { ViteDevServer, PreviewServer } from "vite";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function userManagementSpaFallback() {
|
||||
const rewrite = (req: IncomingMessage) => {
|
||||
const url = req.url ?? '';
|
||||
if (!url.startsWith('/_um/') && url !== '/_um') return;
|
||||
if (/\.[a-zA-Z0-9]+$/.test(url.split('?')[0])) return;
|
||||
req.url = '/_um/index.html';
|
||||
};
|
||||
function createIamApiAdapter(apiBaseUrl: string): Plugin {
|
||||
const upstreamBaseUrl = `${apiBaseUrl.replace(/\/+$/, "")}/api`;
|
||||
|
||||
return {
|
||||
name: 'user-management-spa-fallback',
|
||||
configureServer(s: ViteDevServer) {
|
||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||
rewrite(req);
|
||||
next();
|
||||
});
|
||||
|
||||
},
|
||||
configurePreviewServer(s: PreviewServer) {
|
||||
s.middlewares.use((req: IncomingMessage, _r: ServerResponse, next: () => void) => {
|
||||
rewrite(req);
|
||||
next();
|
||||
name: "iam-api-adapter",
|
||||
configureServer(server) {
|
||||
server.middlewares.use("/um-api", async (req, res) => {
|
||||
const requestPath = req.url ?? "/";
|
||||
const normalizedPath = requestPath.replace(/^\/+/, "");
|
||||
const targetUrl = new URL(normalizedPath, `${upstreamBaseUrl}/`);
|
||||
|
||||
try {
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value || key.toLowerCase() === "host") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
headers.append(key, item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
headers.set(key, value);
|
||||
}
|
||||
|
||||
const body =
|
||||
req.method === "GET" || req.method === "HEAD"
|
||||
? undefined
|
||||
: await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk) =>
|
||||
chunks.push(
|
||||
Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk),
|
||||
),
|
||||
);
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
|
||||
const upstreamResponse = await fetch(targetUrl, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
|
||||
if (targetUrl.pathname.endsWith("/auth/me")) {
|
||||
const payload = await upstreamResponse.json();
|
||||
const unwrappedPayload =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"success" in payload &&
|
||||
"data" in payload
|
||||
? payload.data
|
||||
: payload;
|
||||
|
||||
res.statusCode = upstreamResponse.status;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(JSON.stringify(unwrappedPayload));
|
||||
return;
|
||||
}
|
||||
|
||||
res.statusCode = upstreamResponse.status;
|
||||
upstreamResponse.headers.forEach((value, key) => {
|
||||
res.setHeader(key, value);
|
||||
});
|
||||
res.end(Buffer.from(await upstreamResponse.arrayBuffer()));
|
||||
} catch (error) {
|
||||
server.ssrFixStacktrace(error as Error);
|
||||
res.statusCode = 502;
|
||||
res.setHeader("content-type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
message: "Failed to forward IAM request",
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [userManagementSpaFallback(), react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(__dirname, "../../../packages/types/src/index.ts"),
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, __dirname, "");
|
||||
const apiBaseUrl =
|
||||
env.VITE_BASE_API_URL?.trim() || "http://localhost:3000";
|
||||
|
||||
return {
|
||||
plugins: [react(), tailwindcss(), createIamApiAdapter(apiBaseUrl)],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
"node:buffer": "buffer",
|
||||
"node:stream": "stream-browserify",
|
||||
// Resolve from TS source so Vite gets ESM named exports (dist is CommonJS).
|
||||
"@edr/types": path.resolve(
|
||||
__dirname,
|
||||
"../../../packages/types/src/index.ts",
|
||||
),
|
||||
},
|
||||
// Force a single copy of these singletons so MantineProvider context is
|
||||
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||
// own node_modules copy). Without this, two separate @mantine/core
|
||||
// instances are bundled and the context lookup fails at runtime.
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
// Force a single copy of these singletons so MantineProvider context is
|
||||
// shared between the backoffice app and @edr/ui-common (which ships its
|
||||
// own node_modules copy). Without this, two separate @mantine/core
|
||||
// instances are bundled and the context lookup fails at runtime.
|
||||
dedupe: ["react", "react-dom", "@mantine/core", "@mantine/hooks"],
|
||||
},
|
||||
server: {
|
||||
port: 5183,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
server: {
|
||||
port: 5183,
|
||||
host: "0.0.0.0",
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"@mantine/core": "^9.3.0",
|
||||
"@mantine/hooks": "^9.3.0",
|
||||
"@tanstack/react-query": "^5.59.0",
|
||||
"@tria-plc/iamui-common": "1.1.2",
|
||||
"@tria-plc/iamui": "file:../../../local-packages/tria-plc-iamui-0.0.3.tgz",
|
||||
"axios": "^1.7.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -101,6 +101,7 @@ export const URL_CONSTANTS = {
|
||||
|
||||
TRAIN_SCHEDULING: {
|
||||
BOOKABLE_SCHEDULES: "/api/train-scheduling/bookable-schedules",
|
||||
AVAILABLE_DAYS: "/api/train-scheduling/available-days",
|
||||
},
|
||||
|
||||
PAYMENTS: {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLocation, useNavigate } from "react-router-dom";
|
||||
import useAuth from "@/hooks/useAuth";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
type LoginMethod = "email" | "phone";
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import useAuth from "@/hooks/useAuth";
|
||||
import type { SignupPayload } from "@/types/auth";
|
||||
import AuthShell, { fieldClass, primaryButtonClass } from "@/components/auth/AuthShell";
|
||||
|
||||
const EDR_LOGO = "/assets/logo.svg";
|
||||
const EDR_LOGO = "/assets/edr-logo.png";
|
||||
|
||||
const passwordRequirements = [
|
||||
{ label: "At least 8 characters", test: (v: string) => v.length >= 8 },
|
||||
|
||||
@@ -12,7 +12,11 @@ import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { DocRow, IconSquare } from "./components/Documents";
|
||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
import { CancelledBanner } from "./components/Notices";
|
||||
import {
|
||||
CancelledBanner,
|
||||
ConsolidationPairedNotice,
|
||||
ConsolidationWaitingBanner,
|
||||
} from "./components/Notices";
|
||||
import { HeaderButton, PageHeader } from "./components/PageHeader";
|
||||
import { PaymentDeadlineCard } from "./components/PaymentDeadlineCard";
|
||||
import { PaymentMethodModal } from "./components/PaymentMethodModal";
|
||||
@@ -48,6 +52,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
status === "SELECTED_FOR_BATCH" && booking.paymentStatus !== "PAID";
|
||||
const showCountdown = canPay && !!booking.paymentDeadline;
|
||||
const isExpired = status === "EXPIRED";
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
// Paired: a consolidation partner was found and the booking resumed the normal
|
||||
// flow. Surface the "partner found" reassurance only in the early stages,
|
||||
// before approval, so it doesn't linger for the rest of the booking's life.
|
||||
const showPairedNotice =
|
||||
!!booking.consolidationPartnerId &&
|
||||
["SUBMITTED", "PENDING_APPROVAL", "CHANGES_REQUESTED"].includes(status);
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
@@ -92,10 +103,16 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
subtitle="Payment wasn't completed in time, so this booking lost its slot. Rebook to try another schedule."
|
||||
onRebook={() => navigate("/bookings/new")}
|
||||
/>
|
||||
) : isPendingConsolidation ? (
|
||||
<ConsolidationWaitingBanner
|
||||
priceLabel={pricing ? priceTotal(pricing) : undefined}
|
||||
/>
|
||||
) : (
|
||||
<StatusHero booking={booking} />
|
||||
)}
|
||||
|
||||
{showPairedNotice && <ConsolidationPairedNotice />}
|
||||
|
||||
<ContractCard booking={booking} navigate={navigate} />
|
||||
|
||||
<BodyGrid
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Box, Button, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { AlertCircle, AlertTriangle, PencilLine, StickyNote, XCircle } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Link2,
|
||||
PencilLine,
|
||||
StickyNote,
|
||||
XCircle,
|
||||
} from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export function NoticeBanner({
|
||||
@@ -186,6 +194,90 @@ export function CancelledBanner({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown to the customer while their booking is PENDING_CONSOLIDATION: it is
|
||||
* waiting for another shipment to share the wagon. The price shown is this
|
||||
* booking's own held amount — bookings and contracts are independent, so the
|
||||
* partner's amount is never shown. Once a partner is found the backend moves
|
||||
* the booking back to SUBMITTED and it continues the normal flow.
|
||||
*/
|
||||
export function ConsolidationWaitingBanner({
|
||||
priceLabel,
|
||||
}: {
|
||||
priceLabel?: string;
|
||||
}) {
|
||||
return (
|
||||
<Paper radius={16} p={20} bg="#FDF3E0" className="border border-[#F4D9A8]">
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap={20}>
|
||||
<Group gap={16} align="center" wrap="nowrap" miw={0}>
|
||||
<div
|
||||
className="flex shrink-0 items-center justify-center rounded-[13px] border border-[#F4D9A8]"
|
||||
style={{ width: 46, height: 46, backgroundColor: "#fff", color: "#C77F12" }}
|
||||
>
|
||||
<Link2 size={24} />
|
||||
</div>
|
||||
<Box miw={0}>
|
||||
<span
|
||||
className="inline-flex rounded-full px-[10px] py-1 text-[10.5px] font-extrabold uppercase tracking-[0.3px] text-white"
|
||||
style={{ backgroundColor: "#C77F12" }}
|
||||
>
|
||||
Waiting for a partner
|
||||
</span>
|
||||
<Text mt={6} fz="15.5px" fw={700} c="#10202F">
|
||||
Your shipment is waiting to share a wagon
|
||||
</Text>
|
||||
<Text mt={2} fz="13px" c="#7A6A4E" className="leading-[1.45]">
|
||||
Your cargo only fills part of a wagon, so we’re pairing it with
|
||||
another shipment on the same route to share the space. As soon as a
|
||||
matching shipment is found, your booking continues automatically —
|
||||
acceptance, approval and contract stay independent and yours alone.
|
||||
</Text>
|
||||
</Box>
|
||||
</Group>
|
||||
{priceLabel && (
|
||||
<div
|
||||
className="flex shrink-0 flex-col items-end rounded-xl border border-[#F4D9A8] px-[16px] py-[12px]"
|
||||
style={{ backgroundColor: "#fff" }}
|
||||
>
|
||||
<Text fz="10.5px" fw={700} c="#B07A2A" tt="uppercase" className="tracking-[0.5px]">
|
||||
Your price (held)
|
||||
</Text>
|
||||
<Text mt={3} fz="18px" fw={800} c="#10202F">
|
||||
{priceLabel}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A brief positive notice shown once a consolidation partner has been found and
|
||||
* the booking has resumed the normal flow (SUBMITTED with a partner linked).
|
||||
* Reassures the customer the wait ended; the booking proceeds independently.
|
||||
*/
|
||||
export function ConsolidationPairedNotice() {
|
||||
return (
|
||||
<div
|
||||
className="flex items-start gap-3 rounded-[14px] border p-4"
|
||||
style={{ borderColor: "#BFE6C9", backgroundColor: "#EAF7EE", color: "#1B7A3D" }}
|
||||
>
|
||||
<CheckCircle2 size={18} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<Text fz="13.5px" fw={800} c="#1B7A3D">
|
||||
Consolidation partner found
|
||||
</Text>
|
||||
<Text fz="13px" c="#2E6B43" className="leading-[1.45]">
|
||||
A matching shipment was found to share the wagon, so your booking is
|
||||
back on track and now moving through review and approval as usual.
|
||||
Nothing more is needed from you for now.
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MutationErrors({
|
||||
mutations,
|
||||
}: {
|
||||
|
||||
@@ -61,7 +61,6 @@ export function ScheduleCard({
|
||||
: "Rail only";
|
||||
const equipmentReturn =
|
||||
booking.equipmentReturn === "WITH_RETURN" ? "With return" : "Without return";
|
||||
const consolidation = booking.allowConsolidation ? "Allowed" : "Not allowed";
|
||||
const assignedTrain: Row = {
|
||||
label: "Assigned train",
|
||||
value: booking.trainId ?? "Not yet assigned",
|
||||
@@ -80,7 +79,6 @@ export function ScheduleCard({
|
||||
{ label: "Equipment return", value: equipmentReturn },
|
||||
assignedTrain,
|
||||
{ label: "Scheduled", value: fmtDate(booking.scheduledDate) },
|
||||
{ label: "Consolidation", value: consolidation },
|
||||
]
|
||||
: [
|
||||
statusRow,
|
||||
@@ -88,7 +86,6 @@ export function ScheduleCard({
|
||||
{ label: "Equipment return", value: equipmentReturn },
|
||||
{ label: "Proposed date", value: fmtDate(booking.scheduledDate) },
|
||||
assignedTrain,
|
||||
{ label: "Consolidation", value: consolidation },
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -44,10 +44,7 @@ export function ShipmentDetailsCard({ booking }: { booking: Freight.IBooking })
|
||||
],
|
||||
["Scheduled date", fmtDate(booking.scheduledDate)],
|
||||
],
|
||||
[
|
||||
["Consolidation", booking.allowConsolidation ? "Allowed" : "Not allowed"],
|
||||
["Assigned train", booking.trainId ?? "Not yet assigned"],
|
||||
],
|
||||
[["Assigned train", booking.trainId ?? "Not yet assigned"]],
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -136,14 +136,13 @@ function mapBookingToFormValues(
|
||||
cargoWeight: String(booking.cargoTotalWeightVgm ?? ""),
|
||||
isHazardous: booking.isHazardous ?? false,
|
||||
isRefrigerated: booking.isRefrigerated ?? false,
|
||||
shippingLine: (booking as any).shippingLine?.name ?? "",
|
||||
shippingLine: (booking as any).shippingLine?.id ?? "",
|
||||
consolidationEnabled: booking.allowConsolidation ?? false,
|
||||
paymentCurrency:
|
||||
booking.paymentCurrency === "ETB" ? "ETB" : "USD",
|
||||
scheduledDate: booking.scheduledDate
|
||||
? new Date(booking.scheduledDate).toISOString().slice(0, 10)
|
||||
: "",
|
||||
trainScheduleId: (booking as { trainScheduleId?: string }).trainScheduleId ?? "",
|
||||
notes: "",
|
||||
containers: [],
|
||||
} as BookingFormInputValues;
|
||||
@@ -360,10 +359,16 @@ export default function EditBookingPage() {
|
||||
|
||||
const shippingLineOptions = useMemo(() => {
|
||||
if (!referenceData?.shipping_line) return [];
|
||||
return referenceData.shipping_line.map((sl) => ({
|
||||
value: sl.name,
|
||||
label: sl.name,
|
||||
}));
|
||||
// Dedupe by name (the value the form keys on) so two lines sharing a name
|
||||
// can't produce a duplicate Select option and crash Mantine.
|
||||
const seen = new Set<string>();
|
||||
const options: { value: string; label: string }[] = [];
|
||||
for (const sl of referenceData.shipping_line) {
|
||||
if (!sl.name || seen.has(sl.name)) continue;
|
||||
seen.add(sl.name);
|
||||
options.push({ value: sl.name, label: sl.name });
|
||||
}
|
||||
return options;
|
||||
}, [referenceData]);
|
||||
|
||||
const setDocument = (key: string, file: File | null) => {
|
||||
@@ -376,12 +381,8 @@ export default function EditBookingPage() {
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit((data) => {
|
||||
const shippingLines = referenceData?.shipping_line ?? [];
|
||||
const containerGroups = referenceData?.containers ?? [];
|
||||
|
||||
const findShippingLineId = (name: string): string | undefined =>
|
||||
shippingLines.find((l) => l.name === name)?.id;
|
||||
|
||||
const cargoTypePath = data.cargoTypePath ?? [];
|
||||
const cargoTypeId =
|
||||
data.cargoType === "container" ? undefined : (cargoTypePath[1] ?? "");
|
||||
@@ -410,7 +411,8 @@ export default function EditBookingPage() {
|
||||
scheduledDate: data.scheduledDate
|
||||
? new Date(data.scheduledDate).toISOString()
|
||||
: undefined,
|
||||
trainScheduleId: data.trainScheduleId || undefined,
|
||||
// Day-level pool: the customer edits only the day; the engine assigns the
|
||||
// train, so trainScheduleId is not sent.
|
||||
contractType:
|
||||
data.contractType.toUpperCase() as CreateBookingPayload["contractType"],
|
||||
serviceTypeId: data.serviceTypeId,
|
||||
@@ -456,7 +458,7 @@ export default function EditBookingPage() {
|
||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||
: {}),
|
||||
...(data.shippingLine
|
||||
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
||||
? { shippingLineId: data.shippingLine }
|
||||
: {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -231,13 +231,9 @@ export default function NewBookingPage() {
|
||||
)
|
||||
: Number(data.cargoWeight || 0);
|
||||
|
||||
const shippingLines = referenceData?.shipping_line ?? [];
|
||||
const cargoTree = referenceData?.cargo_type ?? [];
|
||||
const containerGroups = referenceData?.containers ?? [];
|
||||
|
||||
const findShippingLineId = (name: string): string | undefined =>
|
||||
shippingLines.find((l) => l.name === name)?.id;
|
||||
|
||||
const findContainerTypeId = (name: string): string => {
|
||||
for (const group of containerGroups) {
|
||||
const ct = group.types.find((t) => t.name === name);
|
||||
@@ -279,7 +275,8 @@ export default function NewBookingPage() {
|
||||
destinationYardId: data.destinationYard,
|
||||
tradeDirection: direction!,
|
||||
cargoTypeId,
|
||||
trainScheduleId: data.trainScheduleId,
|
||||
// Day-level pool: the customer picks only a day (scheduledDate); the batch
|
||||
// engine assigns the train, so no trainScheduleId is sent.
|
||||
cargoTotalWeightVgm: totalWeight,
|
||||
isHazardous: data.isHazardous,
|
||||
allowConsolidation: data.consolidationEnabled,
|
||||
@@ -308,7 +305,7 @@ export default function NewBookingPage() {
|
||||
? { lastMileDeliveryAddress: data.lastMile.deliveryAddress }
|
||||
: {}),
|
||||
...(data.shippingLine
|
||||
? { shippingLineId: findShippingLineId(data.shippingLine) }
|
||||
? { shippingLineId: data.shippingLine }
|
||||
: {}),
|
||||
...(cargoFreeText ? { cargoFreeText } : {}),
|
||||
};
|
||||
|
||||
@@ -113,8 +113,9 @@ export const bookingFormSchema = z
|
||||
originYard: z.string().min(1, "Select an origin yard."),
|
||||
destinationYard: z.string().min(1, "Select a destination yard."),
|
||||
shippingLine: z.string(),
|
||||
// Day-level pool: the customer selects only a DAY. The batch engine assigns
|
||||
// the specific train later, so no trainScheduleId is collected here.
|
||||
scheduledDate: z.string().min(1, "Select a shipment date."),
|
||||
trainScheduleId: z.string().min(1, "Select a shipment date."),
|
||||
cargoType: z.enum(["container", "bulk"], "Select a cargo type."),
|
||||
cargoWeight: z.string(),
|
||||
cargoTypePath: z.array(z.string()).default([]),
|
||||
@@ -137,7 +138,10 @@ export const bookingFormSchema = z
|
||||
.refine((vgm) => Number(vgm) >= 0, "Must be greater than 0"),
|
||||
}),
|
||||
),
|
||||
consolidationEnabled: z.boolean(),
|
||||
// Consolidation is system-managed, not a customer choice. The backend only
|
||||
// consolidates partial-wagon bookings, so this is always allowed; the
|
||||
// customer neither sees nor toggles it.
|
||||
consolidationEnabled: z.boolean().default(true),
|
||||
documents: z.record(z.string(), z.any()).default({}),
|
||||
notes: z.string(),
|
||||
})
|
||||
@@ -236,14 +240,13 @@ export const initialBookingFormValues: DeepPartial<BookingFormValues> = {
|
||||
destinationYard: "",
|
||||
shippingLine: "",
|
||||
scheduledDate: "",
|
||||
trainScheduleId: "",
|
||||
cargoWeight: "",
|
||||
cargoTypePath: [],
|
||||
cargoFreeText: "",
|
||||
isHazardous: false,
|
||||
isRefrigerated: false,
|
||||
containers: [{ type: "20ft", containerType: "", qty: "1", vgm: "" }],
|
||||
consolidationEnabled: false,
|
||||
consolidationEnabled: true,
|
||||
documents: {},
|
||||
notes: "",
|
||||
};
|
||||
@@ -265,14 +268,8 @@ export const stepFields: Record<number, Array<Path<BookingFormValues>>> = {
|
||||
"isRefrigerated",
|
||||
"shippingLine",
|
||||
],
|
||||
4: [
|
||||
"cargoType",
|
||||
"cargoWeight",
|
||||
"cargoTypePath",
|
||||
"containers",
|
||||
"consolidationEnabled",
|
||||
],
|
||||
5: ["scheduledDate", "trainScheduleId"],
|
||||
4: ["cargoType", "cargoWeight", "cargoTypePath", "containers"],
|
||||
5: ["scheduledDate"],
|
||||
6: ["documents"],
|
||||
7: ["notes"],
|
||||
};
|
||||
@@ -288,22 +285,33 @@ export interface WagonConfig {
|
||||
type: "20ft" | "40ft";
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the trade direction from the origin/destination yard countries.
|
||||
*
|
||||
* Mirrors the backend's `deriveTradeDirection` exactly so the value the portal
|
||||
* sends always matches what the API re-derives (the API rejects mismatches):
|
||||
* - origin in Djibouti → IMPORT
|
||||
* - destination in Djibouti (origin not) → EXPORT
|
||||
* - everything else (e.g. Ethiopia↔Ethiopia)→ DOMESTIC
|
||||
*
|
||||
* Returns null only while a yard is still unselected, so the UI can wait.
|
||||
*/
|
||||
export function getRouteDirection(
|
||||
origin: Freight.BookingReferenceYard | null | undefined,
|
||||
dest: Freight.BookingReferenceYard | null | undefined,
|
||||
): Freight.ScheduleTradeDirection | null {
|
||||
if (!origin || !dest) return null;
|
||||
if (origin.country === "Ethiopia" && dest.country === "Ethiopia") {
|
||||
return "DOMESTIC";
|
||||
}
|
||||
if (origin.country === "Ethiopia" && dest.country === "Djibouti") {
|
||||
return "EXPORT";
|
||||
}
|
||||
if (origin.country === "Djibouti" && dest.country === "Ethiopia") {
|
||||
|
||||
const originCountry = origin.country?.trim();
|
||||
const destCountry = dest.country?.trim();
|
||||
|
||||
if (originCountry === "Djibouti") {
|
||||
return "IMPORT";
|
||||
}
|
||||
|
||||
return null;
|
||||
if (destCountry === "Djibouti" && originCountry !== "Djibouti") {
|
||||
return "EXPORT";
|
||||
}
|
||||
return "DOMESTIC";
|
||||
}
|
||||
|
||||
export function calcWagons(containers: ContainerConfig[]) {
|
||||
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Modal,
|
||||
Stack,
|
||||
Text,
|
||||
useMantineTheme
|
||||
useMantineTheme,
|
||||
} from "@mantine/core";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -46,17 +45,15 @@ interface DayData {
|
||||
isToday: boolean;
|
||||
isCurrentMonth: boolean;
|
||||
isSelectedDate: boolean;
|
||||
schedules: Freight.BookableScheduleItem[];
|
||||
hasSchedule: boolean;
|
||||
/** True when the route has at least one departure on this day. */
|
||||
hasDeparture: boolean;
|
||||
}
|
||||
|
||||
export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
const theme = useMantineTheme();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [selectedDayForModal, setSelectedDayForModal] = useState<DayData | null>(null);
|
||||
|
||||
const selectedDate = form.watch("scheduledDate");
|
||||
const selectedScheduleId = form.watch("trainScheduleId");
|
||||
const originYardId = form.watch("originYard");
|
||||
const destinationYardId = form.watch("destinationYard");
|
||||
const cargoType = form.watch("cargoType");
|
||||
@@ -75,41 +72,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
[referenceData, destinationYardId],
|
||||
);
|
||||
|
||||
const { data: bookableSchedules } = useQuery(
|
||||
api.bookings.getBookableSchedules.queryOptions({
|
||||
// Day-level pool: the customer picks a DAY, not a train. We only fetch which
|
||||
// days have a departure — no capacity, no per-train detail. The batch engine
|
||||
// assigns the train later, distributing the day's pool by priority.
|
||||
const { data: availableDays } = useQuery(
|
||||
api.bookings.getAvailableDays.queryOptions({
|
||||
input: { originYardId, destinationYardId },
|
||||
enabled: !!originYardId && !!destinationYardId,
|
||||
}),
|
||||
);
|
||||
|
||||
// Group all schedules per date — multiple departures per day are allowed.
|
||||
// scheduleDate comes back as a full ISO timestamp; slice to "yyyy-MM-dd" to
|
||||
// match the format used by the calendar day keys.
|
||||
const schedulesByDate = useMemo(() => {
|
||||
const map = new Map<string, Freight.BookableScheduleItem[]>();
|
||||
if (bookableSchedules) {
|
||||
for (const s of bookableSchedules) {
|
||||
const dateKey = s.scheduleDate.slice(0, 10);
|
||||
const existing = map.get(dateKey) ?? [];
|
||||
map.set(dateKey, [...existing, s]);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [bookableSchedules]);
|
||||
|
||||
const selectedSchedule = useMemo(
|
||||
() => bookableSchedules?.find((s) => s.id === selectedScheduleId),
|
||||
[bookableSchedules, selectedScheduleId],
|
||||
const departureDays = useMemo(
|
||||
() => new Set(availableDays ?? []),
|
||||
[availableDays],
|
||||
);
|
||||
|
||||
const availableCount = useMemo(() => {
|
||||
let count = 0;
|
||||
schedulesByDate.forEach((schedules) => {
|
||||
if (schedules.some((s) => s.remainingWagons > 0)) count++;
|
||||
});
|
||||
return count;
|
||||
}, [schedulesByDate]);
|
||||
|
||||
const days = useMemo((): DayData[] => {
|
||||
const monthStart = startOfMonth(currentDate);
|
||||
const monthEnd = endOfMonth(currentDate);
|
||||
@@ -118,19 +95,21 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd }).map((date) => {
|
||||
const dateString = format(date, "yyyy-MM-dd");
|
||||
const schedules = schedulesByDate.get(dateString) ?? [];
|
||||
|
||||
return {
|
||||
day: date.getDate(),
|
||||
dateString,
|
||||
isToday: isToday(date),
|
||||
isCurrentMonth: isSameMonth(date, currentDate),
|
||||
isSelectedDate: selectedDate === dateString,
|
||||
schedules,
|
||||
hasSchedule: schedules.length > 0,
|
||||
hasDeparture: departureDays.has(dateString),
|
||||
};
|
||||
});
|
||||
}, [currentDate, schedulesByDate, selectedDate]);
|
||||
}, [currentDate, departureDays, selectedDate]);
|
||||
|
||||
const availableCount = useMemo(
|
||||
() => days.filter((d) => d.isCurrentMonth && d.hasDeparture).length,
|
||||
[days],
|
||||
);
|
||||
|
||||
const cargoSummary = useMemo(() => {
|
||||
if (!cargoType) return "Not selected";
|
||||
@@ -151,28 +130,9 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
const weeksCount = Math.ceil(days.length / 7);
|
||||
|
||||
const handleDayClick = (day: DayData) => {
|
||||
if (day.schedules.length > 1) {
|
||||
setSelectedDayForModal(day);
|
||||
} else if (day.schedules.length === 1) {
|
||||
form.setValue("scheduledDate", day.dateString, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("trainScheduleId", day.schedules[0].id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectScheduleFromModal = (scheduleId: string) => {
|
||||
if (selectedDayForModal) {
|
||||
form.setValue("scheduledDate", selectedDayForModal.dateString, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
form.setValue("trainScheduleId", scheduleId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
setSelectedDayForModal(null);
|
||||
}
|
||||
if (!day.hasDeparture) return;
|
||||
// Record only the day — no specific train is chosen.
|
||||
form.setValue("scheduledDate", day.dateString, { shouldValidate: true });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -229,7 +189,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
<Stack gap={14} px={24} py={18}>
|
||||
<Text fz={13} fw={600} c="edr-text.0">
|
||||
{originYardId && destinationYardId
|
||||
? `${availableCount} available departure${availableCount !== 1 ? "s" : ""} in ${format(currentDate, "MMMM")} — pick one to continue`
|
||||
? `${availableCount} day${availableCount !== 1 ? "s" : ""} with a departure in ${format(currentDate, "MMMM")} — pick one to continue`
|
||||
: "Select origin and destination to see available departures"}
|
||||
</Text>
|
||||
|
||||
@@ -268,11 +228,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
}}
|
||||
>
|
||||
{days.slice(wi * 7, wi * 7 + 7).map((d, di) => (
|
||||
<DayCell
|
||||
key={di}
|
||||
day={d}
|
||||
onDayClick={handleDayClick}
|
||||
/>
|
||||
<DayCell key={di} day={d} onDayClick={handleDayClick} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
@@ -311,7 +267,7 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
value={cargoSummary}
|
||||
/>
|
||||
|
||||
{selectedSchedule && selectedDate && (
|
||||
{selectedDate && (
|
||||
<Box
|
||||
p={14}
|
||||
style={{
|
||||
@@ -328,28 +284,15 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
c="edr-green.7"
|
||||
style={{ letterSpacing: "0.08em" }}
|
||||
>
|
||||
SELECTED DEPARTURE
|
||||
SELECTED DAY
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={800} fz={16} c="edr-text.0">
|
||||
{format(new Date(selectedDate + "T00:00:00"), "EEE, MMM d yyyy")}
|
||||
</Text>
|
||||
<Group justify="space-between">
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
Train
|
||||
</Text>
|
||||
<Text fz={12.5} fw={700} c="edr-text.0">
|
||||
{selectedSchedule.trainNumber ?? selectedSchedule.id.slice(0, 8)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
Wagons available
|
||||
</Text>
|
||||
<Text fz={12.5} fw={700} c="edr-text.0">
|
||||
{selectedSchedule.remainingWagons} / {selectedSchedule.maxWagons}
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
Your train is confirmed by our freight desk after booking.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
)}
|
||||
@@ -380,154 +323,6 @@ export function StepScheduling({ form, referenceData }: StepSchedulingProps) {
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* ── Schedule Selection Modal ──────────────────────────── */}
|
||||
<Modal
|
||||
opened={!!selectedDayForModal}
|
||||
onClose={() => setSelectedDayForModal(null)}
|
||||
centered
|
||||
size={520}
|
||||
radius={18}
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 3 }}
|
||||
>
|
||||
{/* Header */}
|
||||
<Box
|
||||
px={24}
|
||||
py={20}
|
||||
style={{
|
||||
background: "linear-gradient(120deg, #0C1A2B 0%, #123047 70%, #0A6F4D 150%)",
|
||||
}}
|
||||
>
|
||||
<Group gap={7} align="center" mb={6}>
|
||||
<CalendarIcon size={15} color="#9FE9CC" />
|
||||
<Text fz={11} fw={700} tt="uppercase" c="#9FE9CC" style={{ letterSpacing: 0.6 }}>
|
||||
Available departures
|
||||
</Text>
|
||||
</Group>
|
||||
<Text fw={800} fz={19} c="#fff">
|
||||
{selectedDayForModal
|
||||
? format(new Date(selectedDayForModal.dateString + "T00:00:00"), "EEEE, MMM d yyyy")
|
||||
: ""}
|
||||
</Text>
|
||||
<Text fz={12.5} c="#A9BBCB" mt={2}>
|
||||
{selectedDayForModal?.schedules.length ?? 0} train
|
||||
{(selectedDayForModal?.schedules.length ?? 0) !== 1 ? "s" : ""} on{" "}
|
||||
{originName} → {destinationName}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Schedule list */}
|
||||
<Stack gap={12} p={24}>
|
||||
{selectedDayForModal?.schedules.map((schedule) => {
|
||||
const remaining = schedule.remainingWagons;
|
||||
const max = schedule.maxWagons || 1;
|
||||
const pct = Math.max(0, Math.min(100, Math.round((remaining / max) * 100)));
|
||||
const isSelected = schedule.id === selectedScheduleId;
|
||||
return (
|
||||
<Box
|
||||
key={schedule.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => handleSelectScheduleFromModal(schedule.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleSelectScheduleFromModal(schedule.id);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
cursor: "pointer",
|
||||
borderRadius: 14,
|
||||
padding: 16,
|
||||
border: `1.5px solid ${isSelected ? theme.colors["edr-green"][5] : theme.colors["edr-border"][0]}`,
|
||||
background: isSelected ? theme.colors["edr-soft"][0] : "#fff",
|
||||
boxShadow: isSelected
|
||||
? `0 0 0 1px ${theme.colors["edr-green"][5]}`
|
||||
: "0 1px 2px rgba(16,24,40,0.04)",
|
||||
transition: "all 150ms ease",
|
||||
}}
|
||||
>
|
||||
<Group gap={14} wrap="nowrap" align="center">
|
||||
<Box
|
||||
style={{
|
||||
width: 50,
|
||||
height: 50,
|
||||
flexShrink: 0,
|
||||
borderRadius: 13,
|
||||
background: "linear-gradient(135deg, #ECF6F1, #E0F1E9)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Train size={24} color={theme.colors["edr-green"][6]} />
|
||||
</Box>
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Group gap={8} align="baseline">
|
||||
<Text fw={800} fz={18} c="edr-text.0">
|
||||
{format(new Date(schedule.scheduleDate), "HH:mm")}
|
||||
</Text>
|
||||
<Text fz={12.5} c="edr-muted">
|
||||
{schedule.trainNumber
|
||||
? `Train ${schedule.trainNumber}`
|
||||
: `#${schedule.id.slice(0, 6)}`}
|
||||
</Text>
|
||||
</Group>
|
||||
{/* capacity bar */}
|
||||
<Box mt={8}>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text fz={11} fw={600} c="edr-muted">
|
||||
{remaining} / {max} wagons free
|
||||
</Text>
|
||||
<Text fz={11} fw={700} c={pct > 25 ? "edr-green.7" : "#C77F09"}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Group>
|
||||
<Box
|
||||
style={{
|
||||
height: 6,
|
||||
borderRadius: 999,
|
||||
background: "#EEF2F6",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
borderRadius: 999,
|
||||
background:
|
||||
pct > 25
|
||||
? `linear-gradient(90deg, ${theme.colors["edr-green"][7]}, ${theme.colors["edr-green"][5]})`
|
||||
: "#F2A516",
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
flexShrink: 0,
|
||||
borderRadius: "50%",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
border: `2px solid ${isSelected ? theme.colors["edr-green"][5] : "#CBD5E1"}`,
|
||||
background: isSelected ? theme.colors["edr-green"][5] : "transparent",
|
||||
}}
|
||||
>
|
||||
{isSelected && <Check size={14} color="#fff" strokeWidth={3} />}
|
||||
</Box>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -537,7 +332,7 @@ interface DayCellProps {
|
||||
onDayClick: (day: DayData) => void;
|
||||
}
|
||||
|
||||
function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
function DayCell({ day: d, onDayClick }: DayCellProps) {
|
||||
const theme = useMantineTheme();
|
||||
|
||||
if (!d.isCurrentMonth) {
|
||||
@@ -561,19 +356,19 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
|
||||
const cellBg = d.isSelectedDate
|
||||
? theme.colors["edr-soft"][0]
|
||||
: d.hasSchedule
|
||||
: d.hasDeparture
|
||||
? "#FFFFFF"
|
||||
: "transparent";
|
||||
|
||||
const cellBorder = d.isSelectedDate
|
||||
? `2px solid ${theme.colors["edr-green"][5]}`
|
||||
: d.hasSchedule
|
||||
: d.hasDeparture
|
||||
? `1px solid ${theme.colors["edr-border"][0]}`
|
||||
: "none";
|
||||
|
||||
return (
|
||||
<Box
|
||||
onClick={() => d.hasSchedule && onDayClick(d)}
|
||||
onClick={() => d.hasDeparture && onDayClick(d)}
|
||||
style={{
|
||||
height: 92,
|
||||
borderRadius: theme.radius.md,
|
||||
@@ -584,18 +379,18 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 4,
|
||||
cursor: d.hasSchedule ? "pointer" : "default",
|
||||
cursor: d.hasDeparture ? "pointer" : "default",
|
||||
transition: "all 150ms ease",
|
||||
boxShadow: d.hasSchedule && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
|
||||
boxShadow: d.hasDeparture && !d.isSelectedDate ? "0 1px 3px rgba(0, 0, 0, 0.05)" : "none",
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (d.hasSchedule && !d.isSelectedDate) {
|
||||
if (d.hasDeparture && !d.isSelectedDate) {
|
||||
e.currentTarget.style.boxShadow = "0 4px 12px rgba(0, 0, 0, 0.1)";
|
||||
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (d.hasSchedule && !d.isSelectedDate) {
|
||||
if (d.hasDeparture && !d.isSelectedDate) {
|
||||
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.05)";
|
||||
e.currentTarget.style.borderColor = theme.colors["edr-border"][0];
|
||||
}
|
||||
@@ -610,7 +405,7 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
c={
|
||||
d.isToday && !d.isSelectedDate
|
||||
? "edr-green.6"
|
||||
: d.hasSchedule
|
||||
: d.hasDeparture
|
||||
? "edr-text.0"
|
||||
: "edr-muted"
|
||||
}
|
||||
@@ -635,50 +430,24 @@ function DayCell({ day: d, onDayClick, }: DayCellProps) {
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
size={14}
|
||||
color="white"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
<Check size={14} color="white" strokeWidth={3} />
|
||||
</Box>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* Availability marker — a dot + count, never the schedule list itself. */}
|
||||
{d.hasSchedule && (
|
||||
{/* Availability marker — a single dot for days that have a departure.
|
||||
No counts or capacity are shown: it's a day-level pool. */}
|
||||
{d.hasDeparture && (
|
||||
<Box style={{ flex: 1, display: "flex", alignItems: "flex-end" }}>
|
||||
<Group
|
||||
gap={6}
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
px={9}
|
||||
py={4}
|
||||
<Box
|
||||
style={{
|
||||
borderRadius: 999,
|
||||
backgroundColor: d.isSelectedDate
|
||||
? "#fff"
|
||||
: theme.colors["edr-soft"][0],
|
||||
border: `1px solid ${
|
||||
d.isSelectedDate
|
||||
? theme.colors["edr-green"][2]
|
||||
: "transparent"
|
||||
}`,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme.colors["edr-green"][5],
|
||||
flexShrink: 0,
|
||||
boxShadow: `0 0 0 3px ${theme.colors["edr-green"][0]}`,
|
||||
}}
|
||||
/>
|
||||
<Text fz={11} fw={700} c="edr-green.7" style={{ whiteSpace: "nowrap" }}>
|
||||
{d.schedules.length} departure{d.schedules.length !== 1 ? "s" : ""}
|
||||
</Text>
|
||||
</Group>
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -174,8 +174,8 @@ export function Step1ContractType({
|
||||
form.setValue("containers", mappedContainers);
|
||||
}
|
||||
|
||||
// ── Consolidation ───────────────────────────────────────────────────
|
||||
form.setValue("consolidationEnabled", booking.allowConsolidation);
|
||||
// Consolidation is system-managed (always allowed) — not copied from the
|
||||
// previous booking and not customer-controllable.
|
||||
|
||||
// ── Scheduled date ──────────────────────────────────────────────────
|
||||
if (booking.scheduledDate) {
|
||||
|
||||
@@ -35,10 +35,17 @@ export function Step4Route({
|
||||
|
||||
const shippingLineOptions = useMemo(() => {
|
||||
if (!referenceData?.shipping_line) return [];
|
||||
return referenceData.shipping_line.map((sl) => ({
|
||||
value: sl.name,
|
||||
label: sl.name,
|
||||
}));
|
||||
// The form keys shipping line by name, so options are keyed by name too.
|
||||
// Dedupe by name: if the reference data has two lines sharing a name, a
|
||||
// duplicate option would crash Mantine's Select ("Duplicate options...").
|
||||
const seen = new Set<string>();
|
||||
const options: { value: string; label: string }[] = [];
|
||||
for (const sl of referenceData.shipping_line) {
|
||||
if (!sl.name || seen.has(sl.name)) continue;
|
||||
seen.add(sl.name);
|
||||
options.push({ value: sl.name, label: sl.name });
|
||||
}
|
||||
return options;
|
||||
}, [referenceData]);
|
||||
|
||||
const originData = useMemo(() => {
|
||||
|
||||
@@ -326,10 +326,6 @@ export function Step8Review({
|
||||
onEdit={() => setStep(REVIEW_STEP_TARGETS.schedule)}
|
||||
>
|
||||
<DetailRow label="Shipment date" value={scheduleLabel} />
|
||||
<DetailRow
|
||||
label="Train schedule"
|
||||
value={values.trainScheduleId ? "Selected" : "—"}
|
||||
/>
|
||||
</OverviewSection>
|
||||
|
||||
<OverviewSection
|
||||
@@ -342,10 +338,6 @@ export function Step8Review({
|
||||
label="Total VGM"
|
||||
value={totalVgm > 0 ? `${totalVgm.toFixed(1)} tons` : "—"}
|
||||
/>
|
||||
<DetailRow
|
||||
label="Consolidation"
|
||||
value={values.consolidationEnabled ? "Allowed" : "Not allowed"}
|
||||
/>
|
||||
{values.cargoType === "container" && values.containers.length > 0 && (
|
||||
<Table mt="sm" withTableBorder withColumnBorders fz="sm">
|
||||
<Table.Thead>
|
||||
@@ -444,8 +436,8 @@ export function Step8Review({
|
||||
label="Route selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={Boolean(values.scheduledDate && values.trainScheduleId)}
|
||||
label="Schedule selected"
|
||||
done={Boolean(values.scheduledDate)}
|
||||
label="Shipment day selected"
|
||||
/>
|
||||
<ReadinessItem
|
||||
done={
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
|
||||
import { Button } from "@edr/ui-common";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertTriangle, CheckCircle2, FileSearch, FileText, Home, RotateCcw } from "lucide-react";
|
||||
import { api } from "@/services/api";
|
||||
|
||||
function extractOrderId(): string | null {
|
||||
@@ -13,6 +21,35 @@ function extractOrderId(): string | null {
|
||||
return segments[segments.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function PaymentCard({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
background: "#f8fafc",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 460,
|
||||
background: "#fff",
|
||||
borderRadius: 24,
|
||||
border: "1.5px solid #e5e7eb",
|
||||
boxShadow: "0 4px 24px 0 rgba(0,0,0,0.07)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CheckPaymentPage() {
|
||||
const navigate = useNavigate();
|
||||
const orderId = useMemo(() => extractOrderId(), []);
|
||||
@@ -29,105 +66,307 @@ export default function CheckPaymentPage() {
|
||||
|
||||
if (!orderId) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<XCircle className="size-10 text-destructive" />
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
No payment reference found
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/bookings")}
|
||||
>
|
||||
Back to My Bookings
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PaymentCard>
|
||||
<Box
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #f97316 0%, #fb923c 100%)",
|
||||
padding: "40px 32px 32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<ThemeIcon
|
||||
size={80}
|
||||
radius="xl"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
border: "2px solid rgba(255,255,255,0.3)",
|
||||
margin: "0 auto 20px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<FileSearch size={42} color="#fff" />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||
No payment reference
|
||||
</Text>
|
||||
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8}>
|
||||
We could not find a payment order to verify.
|
||||
</Text>
|
||||
</Box>
|
||||
<Stack gap={10} p={32}>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
color="orange"
|
||||
onClick={() => navigate("/bookings")}
|
||||
styles={{ root: { height: 48, fontWeight: 700 } }}
|
||||
>
|
||||
Back to my bookings
|
||||
</Button>
|
||||
</Stack>
|
||||
</PaymentCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<LoaderCircle className="size-10 animate-spin text-primary" />
|
||||
<p className="text-lg font-semibold text-foreground">
|
||||
Checking payment status…
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
if (isLoading) {
|
||||
return (
|
||||
<PaymentCard>
|
||||
<Box style={{ padding: "64px 32px", textAlign: "center" }}>
|
||||
<Loader size={48} color="edr-green" type="dots" mx="auto" mb={24} />
|
||||
<Text fw={700} fz={18} c="#10202F">
|
||||
Verifying your payment…
|
||||
</Text>
|
||||
<Text fz={14} c="dimmed" mt={8}>
|
||||
Please wait, this usually takes a few seconds.
|
||||
</Text>
|
||||
</Box>
|
||||
</PaymentCard>
|
||||
);
|
||||
}
|
||||
|
||||
{isSuccess && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-primary/10">
|
||||
<CheckCircle2 className="size-8 text-primary" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
Payment was successful!
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your booking has been confirmed and payment is complete.
|
||||
</p>
|
||||
if (isSuccess) {
|
||||
return (
|
||||
<PaymentCard>
|
||||
<Box
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #059669 0%, #0ea371 100%)",
|
||||
padding: "40px 32px 32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<ThemeIcon
|
||||
size={80}
|
||||
radius="xl"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
border: "2px solid rgba(255,255,255,0.35)",
|
||||
margin: "0 auto 20px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={42} color="#fff" strokeWidth={2} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||
Payment verified
|
||||
</Text>
|
||||
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||
Your booking is confirmed and payment is complete.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0} p={32}>
|
||||
<Box
|
||||
style={{
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
borderRadius: 14,
|
||||
padding: "14px 18px",
|
||||
}}
|
||||
>
|
||||
<Text fz={13.5} c="#14532d" lh={1.5} ta="center">
|
||||
EDR staff will assign a train and you will be notified of any updates.
|
||||
</Text>
|
||||
</Box>
|
||||
<Divider my={24} color="#e5e7eb" />
|
||||
<Stack gap={10}>
|
||||
<Button
|
||||
type="button"
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
color="edr-green"
|
||||
leftSection={<FileText size={17} />}
|
||||
onClick={() => navigate("/bookings")}
|
||||
className="mt-2"
|
||||
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
|
||||
>
|
||||
Go to My Bookings
|
||||
Go to my bookings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data && !isSuccess && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="size-8 text-destructive" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
Payment status: {data.status}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Please try again or contact support if the issue persists.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => navigate("/bookings")}
|
||||
className="mt-2"
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Home size={17} />}
|
||||
onClick={() => navigate("/")}
|
||||
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
|
||||
>
|
||||
Back to My Bookings
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
<Text fz={12} c="dimmed" ta="center" mt={20}>
|
||||
Questions?{" "}
|
||||
<Text span c="edr-green" fw={600}>
|
||||
support@edr.et
|
||||
</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
</PaymentCard>
|
||||
);
|
||||
}
|
||||
|
||||
{isError && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-14 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="size-8 text-destructive" />
|
||||
</div>
|
||||
<p className="text-lg font-bold text-foreground">
|
||||
Something went wrong
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
if (isError) {
|
||||
return (
|
||||
<PaymentCard>
|
||||
<Box
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
|
||||
padding: "40px 32px 32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<ThemeIcon
|
||||
size={80}
|
||||
radius="xl"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
border: "2px solid rgba(255,255,255,0.3)",
|
||||
margin: "0 auto 20px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||
Verification failed
|
||||
</Text>
|
||||
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||
We could not verify your payment status.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0} p={32}>
|
||||
<Box
|
||||
style={{
|
||||
background: "#fff7f7",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 14,
|
||||
padding: "14px 18px",
|
||||
}}
|
||||
>
|
||||
<Text fz={13.5} c="#7f1d1d" ta="center" lh={1.5}>
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Failed to check payment status."}
|
||||
</p>
|
||||
: "An unexpected error occurred. Please try again or contact support."}
|
||||
</Text>
|
||||
</Box>
|
||||
<Divider my={24} color="#e5e7eb" />
|
||||
<Stack gap={10}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
color="red"
|
||||
leftSection={<RotateCcw size={17} />}
|
||||
onClick={() => navigate("/bookings")}
|
||||
className="mt-2"
|
||||
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
|
||||
>
|
||||
Back to My Bookings
|
||||
Back to my bookings
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Home size={17} />}
|
||||
onClick={() => navigate("/")}
|
||||
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</Stack>
|
||||
<Text fz={12} c="dimmed" ta="center" mt={20}>
|
||||
Need help?{" "}
|
||||
<Text span c="red.6" fw={600}>
|
||||
support@edr.et
|
||||
</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
</PaymentCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Non-success status (e.g. PAY_FAIL, PENDING, etc.)
|
||||
return (
|
||||
<PaymentCard>
|
||||
<Box
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #d97706 0%, #f59e0b 100%)",
|
||||
padding: "40px 32px 32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<ThemeIcon
|
||||
size={80}
|
||||
radius="xl"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
border: "2px solid rgba(255,255,255,0.3)",
|
||||
margin: "0 auto 20px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||
Payment incomplete
|
||||
</Text>
|
||||
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||
Status:{" "}
|
||||
<Text span fw={700}>
|
||||
{data?.status ?? "Unknown"}
|
||||
</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Stack gap={0} p={32}>
|
||||
<Box
|
||||
style={{
|
||||
background: "#fffbeb",
|
||||
border: "1px solid #fde68a",
|
||||
borderRadius: 14,
|
||||
padding: "14px 18px",
|
||||
}}
|
||||
>
|
||||
<Text fz={13.5} c="#78350f" ta="center" lh={1.5}>
|
||||
Your payment did not complete successfully. Nothing has been charged.
|
||||
You can retry from your booking page.
|
||||
</Text>
|
||||
</Box>
|
||||
<Divider my={24} color="#e5e7eb" />
|
||||
<Stack gap={10}>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
color="orange"
|
||||
leftSection={<RotateCcw size={17} />}
|
||||
onClick={() => navigate("/bookings")}
|
||||
styles={{ root: { height: 48, fontWeight: 700, fontSize: 15 } }}
|
||||
>
|
||||
Back to my bookings — retry payment
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Home size={17} />}
|
||||
onClick={() => navigate("/")}
|
||||
styles={{ root: { height: 44, fontWeight: 600, fontSize: 14 } }}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</Stack>
|
||||
<Text fz={12} c="dimmed" ta="center" mt={20}>
|
||||
Need help?{" "}
|
||||
<Text span c="orange.7" fw={600}>
|
||||
support@edr.et
|
||||
</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
</PaymentCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,171 @@
|
||||
import { XCircle } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { AlertTriangle, Home, RotateCcw } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Public page the payment provider redirects the browser to after a failed or
|
||||
* cancelled payment (PAYMENT_FAILURE_URL). Generic — it explains nothing was
|
||||
* charged and sends the customer back to their bookings to retry from "Pay now".
|
||||
*/
|
||||
export default function PaymentFailurePage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-destructive/10">
|
||||
<XCircle className="size-9 text-destructive" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
Payment was not completed
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your payment didn't go through and you haven't been charged. You can
|
||||
try again from your booking using "Pay now".
|
||||
</p>
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||
Back to My Bookings
|
||||
<Box
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
background: "linear-gradient(135deg, #fff7f7 0%, #f8fafc 60%, #fef2f2 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 460,
|
||||
background: "#fff",
|
||||
borderRadius: 24,
|
||||
border: "1.5px solid #fecaca",
|
||||
boxShadow:
|
||||
"0 4px 24px 0 rgba(220,38,38,0.07), 0 1px 4px 0 rgba(0,0,0,0.04)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Red header stripe */}
|
||||
<Box
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #dc2626 0%, #ef4444 100%)",
|
||||
padding: "40px 32px 32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<ThemeIcon
|
||||
size={80}
|
||||
radius="xl"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.18)",
|
||||
border: "2px solid rgba(255,255,255,0.3)",
|
||||
margin: "0 auto 20px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={42} color="#fff" strokeWidth={2} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||
Payment not completed
|
||||
</Text>
|
||||
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||
Nothing was charged — your booking is still active.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Body */}
|
||||
<Stack gap={0} p={32}>
|
||||
<Stack gap={16}>
|
||||
<Box
|
||||
style={{
|
||||
background: "#fff7f7",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 14,
|
||||
padding: "16px 20px",
|
||||
}}
|
||||
>
|
||||
<Stack gap={10}>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#ef4444",
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
/>
|
||||
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
|
||||
Your payment was declined or cancelled. No charge was made.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#ef4444",
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
/>
|
||||
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
|
||||
You can retry using the <strong>Pay now</strong> button on
|
||||
your booking page.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#ef4444",
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
/>
|
||||
<Text fz={13.5} c="#7f1d1d" lh={1.5}>
|
||||
Contact support if the problem persists.
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Divider my={24} color="#e5e7eb" />
|
||||
|
||||
<Stack gap={10}>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
color="red"
|
||||
leftSection={<RotateCcw size={17} />}
|
||||
onClick={() => navigate("/bookings")}
|
||||
styles={{
|
||||
root: { height: 48, fontWeight: 700, fontSize: 15 },
|
||||
}}
|
||||
>
|
||||
Back to my bookings — retry payment
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Home size={17} />}
|
||||
onClick={() => navigate("/")}
|
||||
styles={{
|
||||
root: { height: 44, fontWeight: 600, fontSize: 14 },
|
||||
}}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Text fz={12} c="dimmed" ta="center" mt={20} lh={1.5}>
|
||||
Need help?{" "}
|
||||
<Text span c="red.6" fw={600}>
|
||||
support@edr.et
|
||||
</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,43 +1,170 @@
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
} from "@mantine/core";
|
||||
import { CheckCircle2, FileText, Home } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Button } from "@edr/ui-common";
|
||||
|
||||
/**
|
||||
* Public page the payment provider redirects the browser to after a successful
|
||||
* payment (PAYMENT_RETURN_URL). Generic — it confirms success and points the
|
||||
* customer back to their bookings, where the booking reflects the paid state.
|
||||
*/
|
||||
export default function PaymentSuccessPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-border bg-card p-8 text-center shadow-sm">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="flex size-16 items-center justify-center rounded-full bg-primary/10">
|
||||
<CheckCircle2 className="size-9 text-primary" />
|
||||
</div>
|
||||
<p className="text-xl font-bold text-foreground">
|
||||
<Box
|
||||
style={{
|
||||
minHeight: "100dvh",
|
||||
background: "linear-gradient(135deg, #f0fdf4 0%, #f8fafc 60%, #ecfdf5 100%)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "24px",
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
width: "100%",
|
||||
maxWidth: 460,
|
||||
background: "#fff",
|
||||
borderRadius: 24,
|
||||
border: "1.5px solid #d1fae5",
|
||||
boxShadow:
|
||||
"0 4px 24px 0 rgba(10,111,77,0.08), 0 1px 4px 0 rgba(0,0,0,0.04)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* Green header stripe */}
|
||||
<Box
|
||||
style={{
|
||||
background: "linear-gradient(135deg, #059669 0%, #0ea371 100%)",
|
||||
padding: "40px 32px 32px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<ThemeIcon
|
||||
size={80}
|
||||
radius="xl"
|
||||
style={{
|
||||
background: "rgba(255,255,255,0.2)",
|
||||
border: "2px solid rgba(255,255,255,0.35)",
|
||||
margin: "0 auto 20px",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={42} color="#fff" strokeWidth={2} />
|
||||
</ThemeIcon>
|
||||
<Text fw={800} fz={24} c="#fff" lh={1.2}>
|
||||
Payment successful
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Thank you — your payment has been received. Your booking will be
|
||||
updated shortly and is now confirmed for scheduling.
|
||||
</p>
|
||||
<div className="mt-2 flex w-full flex-col gap-2">
|
||||
<Button type="button" onClick={() => navigate("/bookings")}>
|
||||
Go to My Bookings
|
||||
</Text>
|
||||
<Text fz={14} c="rgba(255,255,255,0.82)" mt={8} lh={1.5}>
|
||||
Your payment has been received and confirmed.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Body */}
|
||||
<Stack gap={0} p={32}>
|
||||
<Stack gap={16}>
|
||||
<Box
|
||||
style={{
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
borderRadius: 14,
|
||||
padding: "16px 20px",
|
||||
}}
|
||||
>
|
||||
<Stack gap={10}>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#16a34a",
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
/>
|
||||
<Text fz={13.5} c="#14532d" lh={1.5}>
|
||||
Your booking is now confirmed for scheduling.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#16a34a",
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
/>
|
||||
<Text fz={13.5} c="#14532d" lh={1.5}>
|
||||
A receipt will be sent to your registered email.
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap={10} wrap="nowrap">
|
||||
<Box
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: "50%",
|
||||
background: "#16a34a",
|
||||
flexShrink: 0,
|
||||
marginTop: 2,
|
||||
}}
|
||||
/>
|
||||
<Text fz={13.5} c="#14532d" lh={1.5}>
|
||||
EDR staff will process your booking and assign a train.
|
||||
</Text>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<Divider my={24} color="#e5e7eb" />
|
||||
|
||||
<Stack gap={10}>
|
||||
<Button
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
color="edr-green"
|
||||
leftSection={<FileText size={17} />}
|
||||
onClick={() => navigate("/bookings")}
|
||||
styles={{
|
||||
root: { height: 48, fontWeight: 700, fontSize: 15 },
|
||||
}}
|
||||
>
|
||||
Go to my bookings
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
fullWidth
|
||||
size="md"
|
||||
radius={12}
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
leftSection={<Home size={17} />}
|
||||
onClick={() => navigate("/")}
|
||||
styles={{
|
||||
root: { height: 44, fontWeight: 600, fontSize: 14 },
|
||||
}}
|
||||
>
|
||||
Back to home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Text fz={12} c="dimmed" ta="center" mt={20} lh={1.5}>
|
||||
Questions? Contact{" "}
|
||||
<Text span c="edr-green" fw={600}>
|
||||
support@edr.et
|
||||
</Text>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function TabDocuments({ profile, mode = "edit", onContinue }: Tab
|
||||
|
||||
const docSettingQuery = useQuery(
|
||||
api.fileUploadSettings.getByCode.queryOptions({
|
||||
input: { code: "customer_documents" },
|
||||
input: { code: "customer_file_documents" },
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -232,6 +232,13 @@ export const api = {
|
||||
destinationYardId,
|
||||
}),
|
||||
),
|
||||
|
||||
getAvailableDays: endpoint<
|
||||
{ originYardId?: string; destinationYardId?: string },
|
||||
string[]
|
||||
>("train-scheduling", "availableDays", ({ originYardId, destinationYardId }) =>
|
||||
bookingsService.getAvailableDays({ originYardId, destinationYardId }),
|
||||
),
|
||||
},
|
||||
|
||||
payments: {
|
||||
|
||||
@@ -195,4 +195,18 @@ export const bookingsService = {
|
||||
);
|
||||
return data.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Day-level pool: the days that have a departure on the route. The customer
|
||||
* picks a day; the engine assigns the train. No capacity is returned.
|
||||
*/
|
||||
getAvailableDays: async (
|
||||
query: Freight.AvailableDaysQuery = {},
|
||||
): Promise<string[]> => {
|
||||
const { data } = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.AVAILABLE_DAYS,
|
||||
{ params: query },
|
||||
);
|
||||
return (data.data as Freight.AvailableDaysResponse).days;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,7 +34,6 @@
|
||||
"@nestjs/schedule": "^6.1.3",
|
||||
"@nestjs/swagger": "^7.4.0",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"@sendgrid/mail": "^8.1.0",
|
||||
"axios": "^1.7.7",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
|
||||
@@ -141,6 +141,9 @@ ALTER TABLE "SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
ALTER TABLE "Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
-- AlterTable
|
||||
-- gender is created here on a clean migration history (no prior migration adds it);
|
||||
-- on an already-drifted DB where it exists as varchar, normalize it to TEXT.
|
||||
ALTER TABLE "User" ADD COLUMN IF NOT EXISTS "gender" TEXT;
|
||||
ALTER TABLE "User" ALTER COLUMN "gender" SET DATA TYPE TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
|
||||
@@ -1,28 +1,52 @@
|
||||
-- Fix missing columns from 20260617 migration (failed due to missing schema prefix)
|
||||
-- Create passenger schema if it doesn't exist
|
||||
CREATE SCHEMA IF NOT EXISTS passenger;
|
||||
|
||||
-- Move all enums from public to passenger schema
|
||||
DO $$
|
||||
DECLARE
|
||||
e text;
|
||||
BEGIN
|
||||
FOR e IN
|
||||
SELECT typname FROM pg_type
|
||||
JOIN pg_namespace ON pg_namespace.oid = pg_type.typnamespace
|
||||
WHERE pg_namespace.nspname = 'public' AND pg_type.typtype = 'e'
|
||||
LOOP
|
||||
EXECUTE format('ALTER TYPE public.%I SET SCHEMA passenger', e);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Move all tables from public to passenger schema
|
||||
DO $$
|
||||
DECLARE
|
||||
t text;
|
||||
BEGIN
|
||||
FOR t IN
|
||||
SELECT tablename FROM pg_tables
|
||||
WHERE schemaname = 'public' AND tablename NOT IN ('_prisma_migrations')
|
||||
LOOP
|
||||
EXECUTE format('ALTER TABLE public.%I SET SCHEMA passenger', t);
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Add missing columns to Booking
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "returnScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnOriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnDestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnHoldId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT;
|
||||
ADD COLUMN IF NOT EXISTS "returnSeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");
|
||||
|
||||
-- Transit leg-2 columns (never migrated)
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "leg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2DestinationStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "leg2SeatClassId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2ScheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2OriginStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2DestStationId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "returnLeg2SeatClassId" TEXT;
|
||||
|
||||
-- ReturnLegStatus enum + columns (from 20260625 migration, may have also failed)
|
||||
-- Add ReturnLegStatus enum and column
|
||||
DO $$ BEGIN
|
||||
CREATE TYPE "passenger"."ReturnLegStatus" AS ENUM (
|
||||
'NOT_APPLICABLE', 'BOTH_USED', 'OUTBOUND_ONLY', 'INBOUND_ONLY', 'NEITHER_USED'
|
||||
@@ -30,9 +54,15 @@ DO $$ BEGIN
|
||||
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
||||
|
||||
ALTER TABLE "passenger"."Booking"
|
||||
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE',
|
||||
ADD COLUMN IF NOT EXISTS "outboundBoardedAt" TIMESTAMP(3),
|
||||
ADD COLUMN IF NOT EXISTS "returnBoardedAt" TIMESTAMP(3);
|
||||
ADD COLUMN IF NOT EXISTS "returnLegStatus" "passenger"."ReturnLegStatus" NOT NULL DEFAULT 'NOT_APPLICABLE';
|
||||
|
||||
ALTER TABLE "passenger"."GateValidationLog"
|
||||
ADD COLUMN IF NOT EXISTS "leg" TEXT;
|
||||
-- Add missing columns to other tables
|
||||
ALTER TABLE "passenger"."GateValidationLog" ADD COLUMN IF NOT EXISTS "leg" TEXT;
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1;
|
||||
ALTER TABLE "passenger"."BookingSeat" ADD COLUMN IF NOT EXISTS "scheduleId" TEXT;
|
||||
ALTER TABLE "passenger"."Ticket" ADD COLUMN IF NOT EXISTS "boardedAt" TIMESTAMP(3);
|
||||
|
||||
ALTER TABLE "passenger"."SeatClass" ALTER COLUMN "baseFareMinor" SET DEFAULT 0;
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "status" SET DEFAULT 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "Booking_bookingType_idx" ON "passenger"."Booking"("bookingType");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
provider = "prisma-client-js"
|
||||
previewFeatures = ["multiSchema"]
|
||||
}
|
||||
|
||||
datasource db {
|
||||
@@ -79,7 +80,6 @@ model CoachType {
|
||||
updatedAt DateTime @updatedAt
|
||||
coaches Coach[]
|
||||
seatClasses SeatClass[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -116,11 +116,11 @@ enum BookingStatus {
|
||||
}
|
||||
|
||||
enum ReturnLegStatus {
|
||||
NOT_APPLICABLE // one-way booking
|
||||
BOTH_USED // passenger used both legs
|
||||
OUTBOUND_ONLY // return leg not used (no-show on return)
|
||||
INBOUND_ONLY // outbound leg not used, return leg used
|
||||
NEITHER_USED // neither leg boarded yet
|
||||
NOT_APPLICABLE
|
||||
BOTH_USED
|
||||
OUTBOUND_ONLY
|
||||
INBOUND_ONLY
|
||||
NEITHER_USED
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
@@ -269,7 +269,6 @@ model User {
|
||||
fraudAlerts FraudAlert[]
|
||||
|
||||
faydaVerificationSessions FaydaVerificationSession[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -283,7 +282,6 @@ model Session {
|
||||
lastActivityAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -315,7 +313,6 @@ model TravelerProfile {
|
||||
notes String?
|
||||
createdAt DateTime @default(now())
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -350,7 +347,6 @@ model Train {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
schedules TrainSchedule[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -412,7 +408,6 @@ model TripLiveStatus {
|
||||
platformLabel String?
|
||||
updatedAt DateTime @updatedAt
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -500,7 +495,6 @@ model FareRule {
|
||||
validFrom DateTime
|
||||
validUntil DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -584,7 +578,6 @@ model BookingSeat {
|
||||
displayFareMinor Int?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -600,7 +593,6 @@ model PaymentMethod {
|
||||
sortOrder Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -661,7 +653,6 @@ model PaymentRefund {
|
||||
status String
|
||||
createdAt DateTime @default(now())
|
||||
paymentIntent PaymentIntent @relation(fields: [paymentIntentId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -681,7 +672,6 @@ model Ticket {
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
validationLogs GateValidationLog[]
|
||||
seats TicketSeat[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -709,7 +699,6 @@ model LoyaltyAccount {
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
ledger LoyaltyLedgerEntry[]
|
||||
rewards LoyaltyReward[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -722,7 +711,6 @@ model LoyaltyLedgerEntry {
|
||||
balanceAfter Int
|
||||
createdAt DateTime @default(now())
|
||||
account LoyaltyAccount @relation(fields: [accountId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -734,7 +722,6 @@ model LoyaltyReward {
|
||||
available Boolean @default(true)
|
||||
description String?
|
||||
account LoyaltyAccount @relation(fields: [accountId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -763,7 +750,6 @@ model WalletLedgerEntry {
|
||||
relatedBookingId String?
|
||||
createdAt DateTime @default(now())
|
||||
wallet WalletAccount @relation(fields: [walletId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -778,7 +764,6 @@ model Notification {
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -794,7 +779,6 @@ model Promotion {
|
||||
deepLink String?
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -808,7 +792,6 @@ model StationCrowdSignal {
|
||||
observedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
station Station @relation(fields: [stationId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -820,7 +803,6 @@ model WeatherAlert {
|
||||
message String
|
||||
validUntil DateTime
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -828,7 +810,6 @@ model MenuCategory {
|
||||
id String @id @default(uuid())
|
||||
name String
|
||||
items MenuItem[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -843,7 +824,6 @@ model MenuItem {
|
||||
availableUntil DateTime?
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
category MenuCategory @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -858,7 +838,6 @@ model FoodOrder {
|
||||
createdAt DateTime @default(now())
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
items FoodOrderItem[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -871,7 +850,6 @@ model FoodOrderItem {
|
||||
unitPriceMinor Int?
|
||||
lineTotalMinor Int
|
||||
order FoodOrder @relation(fields: [orderId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -880,7 +858,6 @@ model FaqCategory {
|
||||
title String
|
||||
iconKey String?
|
||||
articles FaqArticle[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -891,7 +868,6 @@ model FaqArticle {
|
||||
answerMarkdown String
|
||||
rank Int @default(0)
|
||||
category FaqCategory @relation(fields: [categoryId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -902,7 +878,6 @@ model SupportConversation {
|
||||
status SupportConversationStatus @default(OPEN)
|
||||
createdAt DateTime @default(now())
|
||||
messages SupportMessage[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -914,7 +889,6 @@ model SupportMessage {
|
||||
attachments Json?
|
||||
createdAt DateTime @default(now())
|
||||
conversation SupportConversation @relation(fields: [conversationId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -934,7 +908,6 @@ model UserPreferences {
|
||||
darkMode Boolean @default(false)
|
||||
language String @default("en")
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -947,7 +920,6 @@ model Device {
|
||||
trusted Boolean @default(false)
|
||||
lastSeenAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -961,7 +933,6 @@ model SavedRoute {
|
||||
tripCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
passenger Passenger @relation(fields: [passengerId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -973,7 +944,6 @@ model Journey {
|
||||
currency String @default("ETB")
|
||||
createdAt DateTime @default(now())
|
||||
journeySegments JourneySegment[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -988,7 +958,6 @@ model JourneySegment {
|
||||
arrivalStationId String
|
||||
journey Journey @relation(fields: [journeyId], references: [id])
|
||||
schedule TrainSchedule @relation(fields: [scheduleId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1032,7 +1001,6 @@ model Route {
|
||||
fareRules RouteFareRule[]
|
||||
segmentFares SegmentFareRule[]
|
||||
schedules TrainSchedule[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1102,7 +1070,6 @@ model Agent {
|
||||
bookings AgentBooking[]
|
||||
shifts AgentShift[]
|
||||
commissions AgentCommission[]
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1117,7 +1084,6 @@ model AgentBooking {
|
||||
createdAt DateTime @default(now())
|
||||
agent Agent @relation(fields: [agentId], references: [id])
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1177,7 +1143,6 @@ model BookingCancellation {
|
||||
processedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1205,7 +1170,6 @@ model BaggageAllowance {
|
||||
excessFeePerKg Int
|
||||
currency String @default("ETB")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1249,7 +1213,6 @@ model NotificationTemplate {
|
||||
bodyTemplate String
|
||||
active Boolean @default(true)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
@@ -1288,7 +1251,6 @@ model FraudRule {
|
||||
config Json?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
|
||||
@@ -212,7 +212,7 @@ async function seedRoute() {
|
||||
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const returnRoute = await prisma.route.upsert({
|
||||
where: { code: 'Route-102' },
|
||||
update: {},
|
||||
@@ -317,7 +317,7 @@ async function seedTrips() {
|
||||
const now = new Date();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(now.getDate() + 1);
|
||||
|
||||
|
||||
const schedules = [];
|
||||
|
||||
for (let d = 0; d < 5; d++) {
|
||||
@@ -403,7 +403,7 @@ async function seedTrips() {
|
||||
|
||||
const coachAssignments = [];
|
||||
const liveStatuses = [];
|
||||
|
||||
|
||||
for (const schedule of createdSchedules) {
|
||||
for (let p = 0; p < coaches.length; p++) {
|
||||
coachAssignments.push({
|
||||
@@ -418,12 +418,12 @@ async function seedTrips() {
|
||||
progressPercent: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
await Promise.all([
|
||||
...coachAssignments.map(ca => prisma.coachAssignment.create({ data: ca })),
|
||||
...liveStatuses.map(ls => prisma.tripLiveStatus.create({ data: ls })),
|
||||
]);
|
||||
|
||||
|
||||
console.log(` ✅ Train with ${createdSchedules.length} upcoming trips created`);
|
||||
}
|
||||
|
||||
@@ -453,7 +453,7 @@ async function seedFareRules() {
|
||||
validFrom,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
await Promise.all(
|
||||
fareRules.map(fr => prisma.routeFareRule.create({ data: fr }))
|
||||
);
|
||||
@@ -518,7 +518,7 @@ async function seedSegmentFares() {
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
});
|
||||
const seatClasses = await prisma.seatClass.findMany();
|
||||
const validFrom = new Date('2024-01-01');
|
||||
const validFrom = new Date('2026-01-01');
|
||||
|
||||
if (route && route.stops.length > 2) {
|
||||
for (const sc of seatClasses) {
|
||||
@@ -531,7 +531,7 @@ async function seedSegmentFares() {
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.4),
|
||||
validFrom,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
|
||||
await prisma.segmentFareRule.create({
|
||||
data: {
|
||||
@@ -542,7 +542,7 @@ async function seedSegmentFares() {
|
||||
baseFareMinor: Math.floor(sc.baseFareMinor * 0.6),
|
||||
validFrom,
|
||||
},
|
||||
}).catch(() => {});
|
||||
}).catch(() => { });
|
||||
}
|
||||
console.log(` ✅ ${seatClasses.length * 2} segment fare rules created`);
|
||||
}
|
||||
@@ -550,18 +550,24 @@ async function seedSegmentFares() {
|
||||
|
||||
async function seedNotificationTemplates() {
|
||||
console.log('\n🔔 Seeding notification templates...');
|
||||
// NOTE: `code` must match the templateKey passed by NotificationsService.send(...).
|
||||
// The event-driven handlers use the dotted event names (booking.created, payment.succeeded).
|
||||
const templates = [
|
||||
{ id: uuidv4(), code: 'BOOKING_CONFIRMED', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed for {{date}}' },
|
||||
{ id: uuidv4(), code: 'PAYMENT_RECEIVED', channel: 'SMS', bodyTemplate: 'Payment ETB {{amount}} received for {{bookingRef}}' },
|
||||
{ id: uuidv4(), code: 'TRIP_DEPARTURE', channel: 'PUSH', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
|
||||
{ id: uuidv4(), code: 'TRIP_DELAY', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
|
||||
{ id: uuidv4(), code: 'PROMOTION', channel: 'PUSH', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
|
||||
{ id: uuidv4(), code: 'booking.created', channel: 'EMAIL', subject: 'Booking Confirmed', bodyTemplate: 'Your booking {{bookingRef}} is confirmed. Total: {{amount}} {{currency}}.' },
|
||||
{ id: uuidv4(), code: 'payment.succeeded', channel: 'SMS', subject: 'Payment Received', bodyTemplate: 'Payment of {{amount}} {{currency}} received for booking {{bookingRef}}.' },
|
||||
{ id: uuidv4(), code: 'payment.failed', channel: 'SMS', subject: 'Payment Failed', bodyTemplate: 'Payment for booking {{bookingRef}} could not be completed. Please try again.' },
|
||||
{ id: uuidv4(), code: 'booking.cancelled', channel: 'EMAIL', subject: 'Booking Cancelled', bodyTemplate: 'Your booking {{bookingRef}} has been cancelled. Refund: {{refundAmount}} {{currency}}.' },
|
||||
// Templates below are not wired to handlers yet (Phase 2 — full event coverage).
|
||||
{ id: uuidv4(), code: 'trip.departure', channel: 'PUSH', subject: 'Trip Departing Soon', bodyTemplate: 'Your trip {{route}} departs in {{minutes}} minutes' },
|
||||
{ id: uuidv4(), code: 'trip.delay', channel: 'EMAIL', subject: 'Trip Delayed', bodyTemplate: 'Your trip {{route}} is delayed by {{delayMinutes}} minutes' },
|
||||
{ id: uuidv4(), code: 'promotion.offer', channel: 'PUSH', subject: 'Special Offer', bodyTemplate: 'Get {{percentOff}}% off on {{route}}' },
|
||||
];
|
||||
|
||||
for (const t of templates) {
|
||||
await prisma.notificationTemplate.upsert({
|
||||
where: { code: t.code },
|
||||
update: {},
|
||||
// Refresh the editable fields on re-seed so template tweaks actually take effect.
|
||||
update: { channel: t.channel, subject: t.subject ?? null, bodyTemplate: t.bodyTemplate, active: true },
|
||||
create: t,
|
||||
});
|
||||
}
|
||||
@@ -586,16 +592,16 @@ async function seedMenuAndFood() {
|
||||
const coffeeId = uuidv4();
|
||||
const juiceId = uuidv4();
|
||||
const sandwichId = uuidv4();
|
||||
|
||||
|
||||
await prisma.menuItem.create({
|
||||
data: { id: coffeeId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Ethiopian Coffee', priceMinor: 50 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
}).catch(() => { }); // ignore if exists
|
||||
await prisma.menuItem.create({
|
||||
data: { id: juiceId, scheduleId: schedule.id, categoryId: beverages.id, name: 'Fresh Juice', priceMinor: 35 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
}).catch(() => { }); // ignore if exists
|
||||
await prisma.menuItem.create({
|
||||
data: { id: sandwichId, scheduleId: schedule.id, categoryId: snacks.id, name: 'Sandwich', priceMinor: 80 },
|
||||
}).catch(() => {}); // ignore if exists
|
||||
}).catch(() => { }); // ignore if exists
|
||||
}
|
||||
console.log(` ✅ Menu categories and items created`);
|
||||
}
|
||||
@@ -682,6 +688,10 @@ async function main() {
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['system users', seedSystemUsers],
|
||||
['fare rules', seedFareRules],
|
||||
['segment fares', seedSegmentFares],
|
||||
['currency', seedCurrency],
|
||||
['notification templates', seedNotificationTemplates]
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -1007,6 +1007,7 @@ export class BookingsService {
|
||||
await this.prisma.bookingCancellation.create({ data: { bookingId: booking.id, cancelledBy: booking.passengerId, reason, refundAmount, refundMethod: booking.paymentIntent?.method ?? 'ORIGINAL', refundStatus: 'PENDING' } });
|
||||
await this.seatsService.releaseSeats(booking.seats.map((s) => s.seatId));
|
||||
await this.prisma.booking.update({ where: { bookingRef }, data: { status: 'CANCELLED' } });
|
||||
this.eventEmitter.emit('booking.cancelled', { booking, refundAmount });
|
||||
return { cancelled: true, refundAmount: refundAmount / 100, currency: 'ETB' };
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,9 @@ export class FareCalculateDto {
|
||||
|
||||
@ApiPropertyOptional({ example: 'WEEKEND15', description: 'Promo code for discount' })
|
||||
@IsOptional() @IsString() promoCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'schedule-uuid', description: 'Schedule UUID — used to match schedule-scoped FareRules first' })
|
||||
@IsOptional() @IsString() scheduleId?: string;
|
||||
}
|
||||
|
||||
export class FareBreakdownDto {
|
||||
|
||||
@@ -32,20 +32,59 @@ export class FareEngineService {
|
||||
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
|
||||
);
|
||||
|
||||
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
|
||||
if (missingDistance.length > 0)
|
||||
throw new BadRequestException(
|
||||
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
|
||||
);
|
||||
|
||||
const totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||
|
||||
const seatClass = await this.prisma.seatClass.findUnique({ where: { id: dto.seatClassId } });
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
if (!seatClass.isActive) throw new BadRequestException('Seat class is not active');
|
||||
|
||||
const ratePerKmMinor = seatClass.baseFareMinor;
|
||||
const baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
// Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
|
||||
const now = new Date();
|
||||
const [originStation, destStation] = await Promise.all([
|
||||
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
|
||||
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
|
||||
]);
|
||||
const segmentRoute = originStation && destStation
|
||||
? `${originStation.code}-${destStation.code}` : null;
|
||||
const fullRoute = `${route.code}`;
|
||||
|
||||
const fareRuleCandidates = await this.prisma.fareRule.findMany({
|
||||
where: {
|
||||
seatClassId: dto.seatClassId,
|
||||
validFrom: { lte: now },
|
||||
OR: [{ validUntil: null }, { validUntil: { gte: now } }],
|
||||
},
|
||||
});
|
||||
|
||||
const fareRule = this.pickBestFareRule(
|
||||
fareRuleCandidates,
|
||||
dto.scheduleId,
|
||||
segmentRoute,
|
||||
fullRoute,
|
||||
dto.nationality,
|
||||
);
|
||||
|
||||
let baseFarePerPassengerMinor: number;
|
||||
let ratePerKmMinor: number;
|
||||
let totalDistanceKm: number;
|
||||
let fareSource: string;
|
||||
|
||||
if (fareRule) {
|
||||
// Flat fare from FareRule — distance is informational only
|
||||
baseFarePerPassengerMinor = fareRule.baseFareMinor;
|
||||
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||
ratePerKmMinor = totalDistanceKm > 0 ? Math.round(baseFarePerPassengerMinor / totalDistanceKm) : 0;
|
||||
fareSource = fareRule.tripId ? 'SCHEDULE_FARE_RULE' : 'ROUTE_FARE_RULE';
|
||||
} else {
|
||||
// Distance × rate fallback
|
||||
const missingDistance = legStops.filter(s => s.distanceKm === null || s.distanceKm === undefined);
|
||||
if (missingDistance.length > 0)
|
||||
throw new BadRequestException(
|
||||
`Missing distanceKm on route stops at sequences: ${missingDistance.map(s => s.sequence).join(', ')}`,
|
||||
);
|
||||
totalDistanceKm = legStops.reduce((sum, s) => sum + (s.distanceKm ?? 0), 0);
|
||||
ratePerKmMinor = seatClass.baseFareMinor;
|
||||
baseFarePerPassengerMinor = totalDistanceKm * ratePerKmMinor;
|
||||
fareSource = 'DISTANCE_RATE';
|
||||
}
|
||||
|
||||
// Premium and insurance fees applied per passenger
|
||||
const premiumPerPassenger = seatClass.premiumMinor ?? 0;
|
||||
@@ -84,11 +123,6 @@ export class FareEngineService {
|
||||
const exchangeRate = await this.currencyService.getExchangeRate(Currency.ETB, billingCurrency);
|
||||
const totalInBillingCurrency = Math.round(totalEtbMinor * exchangeRate);
|
||||
|
||||
const [originStation, destStation] = await Promise.all([
|
||||
this.prisma.station.findUnique({ where: { id: dto.originStationId } }),
|
||||
this.prisma.station.findUnique({ where: { id: dto.destinationStationId } }),
|
||||
]);
|
||||
|
||||
const calculation = [
|
||||
`Distance: ${totalDistanceKm} km (${originStation?.name} → ${destStation?.name})`,
|
||||
`Rate per km: ${ratePerKmMinor} ETB minor (${seatClass.name})`,
|
||||
@@ -110,9 +144,11 @@ export class FareEngineService {
|
||||
`Nationality: ${dto.nationality ?? 'unspecified'} → ${billingCurrency}`,
|
||||
`Exchange rate: 1 ETB = ${exchangeRate} ${billingCurrency}`,
|
||||
`Total (${billingCurrency}): ${totalInBillingCurrency} ${billingCurrency} minor`,
|
||||
`Fare source: ${fareSource}`,
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
fareSource,
|
||||
routeCode: route.code,
|
||||
originName: originStation?.name ?? dto.originStationId,
|
||||
destinationName: destStation?.name ?? dto.destinationStationId,
|
||||
@@ -161,6 +197,37 @@ export class FareEngineService {
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
private pickBestFareRule(
|
||||
candidates: any[],
|
||||
scheduleId?: string,
|
||||
segmentRoute?: string | null,
|
||||
fullRoute?: string,
|
||||
nationality?: string,
|
||||
): any | null {
|
||||
const nat = nationality ?? null;
|
||||
const priorities = [
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality: nat },
|
||||
{ tripId: scheduleId, route: segmentRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality: nat },
|
||||
{ tripId: scheduleId, route: fullRoute, nationality: null },
|
||||
{ tripId: scheduleId, route: null, nationality: nat },
|
||||
{ tripId: scheduleId, route: null, nationality: null },
|
||||
{ tripId: null, route: segmentRoute, nationality: nat },
|
||||
{ tripId: null, route: segmentRoute, nationality: null },
|
||||
{ tripId: null, route: fullRoute, nationality: nat },
|
||||
{ tripId: null, route: fullRoute, nationality: null },
|
||||
{ tripId: null, route: null, nationality: nat },
|
||||
{ tripId: null, route: null, nationality: null },
|
||||
];
|
||||
for (const p of priorities) {
|
||||
const match = candidates.find(
|
||||
c => c.tripId === p.tripId && c.route === p.route && c.nationality === p.nationality,
|
||||
);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async calculateForSchedule(scheduleId: string, seatClassId: string, nationality?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
@@ -175,6 +242,7 @@ export class FareEngineService {
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId,
|
||||
nationality,
|
||||
scheduleId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -199,6 +267,7 @@ export class FareEngineService {
|
||||
destinationStationId: schedule.destinationStationId,
|
||||
seatClassId: sc.id,
|
||||
nationality,
|
||||
scheduleId,
|
||||
}).catch(() => null),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -40,18 +40,5 @@ export class SendEmail {
|
||||
@IsOptional()
|
||||
context?: Record<string, any>;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
templateName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsEmail()
|
||||
replyTo?: string;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export class SingleMessageDto {
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
sms: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class BulkMessagesDto {
|
||||
|
||||
@@ -28,12 +28,23 @@ export class EmailClientService implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
async sendEmail(dto: SendEmail) {
|
||||
if (!this.enabled) return {};
|
||||
async sendEmail(dto: SendEmail): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped EMAIL`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.emailServiceClient.emit("send-email", {
|
||||
...dto,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
return {};
|
||||
// Fire-and-forget enqueue: this confirms the message was handed to RabbitMQ, NOT delivered.
|
||||
this.logger.log(
|
||||
`EMAIL queued to RabbitMQ [${process.env.EMAIL_QUEUE ?? "email_queue"}] pattern='send-email'`,
|
||||
);
|
||||
// Recipient + content are PII — keep them at debug level only.
|
||||
this.logger.debug(
|
||||
`EMAIL payload to=${dto.to} subject="${dto.subject ?? ""}" body="${dto.text ?? dto.body ?? dto.html ?? ""}"`,
|
||||
);
|
||||
return { queued: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,199 +1,10 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as sgMail from '@sendgrid/mail';
|
||||
import { HttpService } from '@nestjs/axios';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface NotificationChannel {
|
||||
send(recipient: string, subject: string, body: string, context?: Record<string, unknown>): Promise<boolean>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmailAdapter implements NotificationChannel {
|
||||
private readonly logger = new Logger(EmailAdapter.name);
|
||||
|
||||
constructor(private readonly config: ConfigService) {
|
||||
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
|
||||
if (apiKey) {
|
||||
sgMail.setApiKey(apiKey);
|
||||
this.logger.log('SendGrid Email adapter initialized');
|
||||
} else {
|
||||
this.logger.warn('SENDGRID_API_KEY not configured - emails will be logged only');
|
||||
}
|
||||
}
|
||||
|
||||
async send(
|
||||
recipient: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
context?: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
const apiKey = this.config.get<string>('SENDGRID_API_KEY');
|
||||
const fromEmail = this.config.get<string>('SENDGRID_FROM_EMAIL') || 'noreply@edr-platform.com';
|
||||
|
||||
if (!apiKey) {
|
||||
this.logger.log(`[EMAIL MOCK] To: ${recipient} | Subject: ${subject} | Body: ${body.substring(0, 100)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const msg: sgMail.MailDataRequired = {
|
||||
to: recipient,
|
||||
from: fromEmail,
|
||||
subject,
|
||||
text: body,
|
||||
html: this.formatHtml(body, context),
|
||||
};
|
||||
|
||||
await sgMail.send(msg);
|
||||
this.logger.log(`Email sent successfully to ${recipient}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Failed to send email to ${recipient}: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private formatHtml(body: string, context?: Record<string, unknown>): string {
|
||||
const contextHtml = context
|
||||
? `<div style="margin-top: 20px; padding: 10px; background: #f5f5f5; border-radius: 4px;">
|
||||
<small>${JSON.stringify(context, null, 2)}</small>
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #0066cc; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; background: white; }
|
||||
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h2>Ethio-Djibouti Railway</h2>
|
||||
</div>
|
||||
<div class="content">
|
||||
${body.replace(/\n/g, '<br>')}
|
||||
${contextHtml}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>© 2024 Ethio-Djibouti Railway. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsAdapter implements NotificationChannel {
|
||||
private readonly logger = new Logger(SmsAdapter.name);
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly http: HttpService,
|
||||
) {
|
||||
const provider = this.config.get<string>('SMS_PROVIDER');
|
||||
this.logger.log(`SMS adapter initialized with provider: ${provider || 'MOCK'}`);
|
||||
}
|
||||
|
||||
async send(
|
||||
recipient: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
_context?: Record<string, unknown>,
|
||||
): Promise<boolean> {
|
||||
const provider = this.config.get<string>('SMS_PROVIDER');
|
||||
const apiKey = this.config.get<string>('SMS_API_KEY');
|
||||
|
||||
if (!provider || !apiKey) {
|
||||
this.logger.log(`[SMS MOCK] To: ${recipient} | Message: ${body.substring(0, 100)}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'twilio':
|
||||
return await this.sendViaTwilio(recipient, body);
|
||||
case 'africastalking':
|
||||
return await this.sendViaAfricasTalking(recipient, body);
|
||||
default:
|
||||
this.logger.warn(`Unknown SMS provider: ${provider}`);
|
||||
return false;
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Failed to send SMS to ${recipient}: ${message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async sendViaTwilio(to: string, body: string): Promise<boolean> {
|
||||
const accountSid = this.config.get<string>('TWILIO_ACCOUNT_SID');
|
||||
const authToken = this.config.get<string>('TWILIO_AUTH_TOKEN');
|
||||
const fromNumber = this.config.get<string>('TWILIO_FROM_NUMBER');
|
||||
|
||||
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`;
|
||||
const auth = Buffer.from(`${accountSid}:${authToken}`).toString('base64');
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.post(
|
||||
url,
|
||||
new URLSearchParams({
|
||||
To: to,
|
||||
From: fromNumber || '',
|
||||
Body: body,
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${auth}`,
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return response.status === 201;
|
||||
}
|
||||
|
||||
private async sendViaAfricasTalking(to: string, body: string): Promise<boolean> {
|
||||
const apiKey = this.config.get<string>('SMS_API_KEY');
|
||||
const username = this.config.get<string>('AFRICASTALKING_USERNAME');
|
||||
const from = this.config.get<string>('AFRICASTALKING_FROM');
|
||||
|
||||
const url = 'https://api.africastalking.com/version1/messaging';
|
||||
|
||||
const response = await firstValueFrom(
|
||||
this.http.post(
|
||||
url,
|
||||
new URLSearchParams({
|
||||
username: username || '',
|
||||
to,
|
||||
message: body,
|
||||
from: from || '',
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'apiKey': apiKey || '',
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
return response.status === 201;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushAdapter implements NotificationChannel {
|
||||
private readonly logger = new Logger(PushAdapter.name);
|
||||
|
||||
@@ -3,12 +3,13 @@ import { HttpModule } from '@nestjs/axios';
|
||||
import { ClientsModule, Transport } from '@nestjs/microservices';
|
||||
import { NotificationsController } from './notifications.controller';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
import { EmailAdapter, SmsAdapter, PushAdapter } from './notification.adapters';
|
||||
import { PushAdapter } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
// Required by IamGuard (injects HttpService) used in NotificationsController.
|
||||
HttpModule.register({ timeout: 10_000 }),
|
||||
ClientsModule.register([
|
||||
{
|
||||
@@ -34,8 +35,6 @@ import { SmsClientService } from './sms-client.service';
|
||||
controllers: [NotificationsController],
|
||||
providers: [
|
||||
NotificationsService,
|
||||
EmailAdapter,
|
||||
SmsAdapter,
|
||||
PushAdapter,
|
||||
EmailClientService,
|
||||
SmsClientService,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { SendNotificationDto, NotificationCategoryEnum } from './notifications.dto';
|
||||
import { PushAdapter, NotificationChannel } from './notification.adapters';
|
||||
import { EmailClientService } from './email-client.service';
|
||||
import { SmsClientService } from './sms-client.service';
|
||||
@@ -20,8 +19,8 @@ export class NotificationsService {
|
||||
private pushAdapter: PushAdapter,
|
||||
) {
|
||||
this.channels = new Map<NotificationChannelType, NotificationChannel>([
|
||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then(() => true) }],
|
||||
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, sms: body }).then(() => true) }],
|
||||
['EMAIL', { send: (to, subject, body) => this.emailClient.sendEmail({ to, subject, text: body }).then((r) => r.queued) }],
|
||||
['SMS', { send: (to, _subject, body) => this.smsClient.sendSms({ to, message: body }).then((r) => r.queued) }],
|
||||
['PUSH', this.pushAdapter as NotificationChannel],
|
||||
]);
|
||||
}
|
||||
@@ -38,27 +37,38 @@ export class NotificationsService {
|
||||
recipient: string,
|
||||
context: Record<string, unknown>,
|
||||
channels?: NotificationChannelType[],
|
||||
): Promise<{ sent: boolean; channels: string[] }> {
|
||||
): Promise<{ queued: boolean; channels: string[] }> {
|
||||
const template = await this.prisma.notificationTemplate.findUnique({
|
||||
where: { code: templateKey },
|
||||
});
|
||||
|
||||
if (!template || !template.active) {
|
||||
this.logger.warn(`Template ${templateKey} not found or inactive`);
|
||||
return { sent: false, channels: [] };
|
||||
return { queued: false, channels: [] };
|
||||
}
|
||||
|
||||
const { subject, body } = this.interpolate(template, context);
|
||||
const targetChannels = channels || await this.getUserPreferredChannels(recipient);
|
||||
const sentChannels: string[] = [];
|
||||
|
||||
// Always create in-app notification
|
||||
if (targetChannels.includes('IN_APP')) {
|
||||
await this.createInAppNotification(recipient, subject, body, context);
|
||||
sentChannels.push('IN_APP');
|
||||
// Channel resolution: explicit argument wins; otherwise honor the template's declared
|
||||
// channel(s); otherwise fall back to the recipient's preferences.
|
||||
let targetChannels: NotificationChannelType[];
|
||||
if (channels) {
|
||||
targetChannels = channels;
|
||||
} else if (template.channel) {
|
||||
targetChannels = this.parseTemplateChannels(template.channel);
|
||||
} else {
|
||||
targetChannels = await this.getUserPreferredChannels(recipient);
|
||||
}
|
||||
|
||||
// Channels successfully handed off (in-app persisted / email+SMS enqueued to RabbitMQ).
|
||||
// NOTE: enqueue is fire-and-forget — this is NOT a delivery confirmation.
|
||||
const queuedChannels: string[] = [];
|
||||
|
||||
if (targetChannels.includes('IN_APP')) {
|
||||
await this.createInAppNotification(recipient, subject, body, context);
|
||||
queuedChannels.push('IN_APP');
|
||||
}
|
||||
|
||||
// Send via other channels
|
||||
for (const channelType of targetChannels) {
|
||||
if (channelType === 'IN_APP') continue;
|
||||
|
||||
@@ -74,44 +84,26 @@ export class NotificationsService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const success = await adapter.send(recipientAddress, subject, body, context);
|
||||
if (success) {
|
||||
sentChannels.push(channelType);
|
||||
const queued = await adapter.send(recipientAddress, subject, body, context);
|
||||
if (queued) {
|
||||
queuedChannels.push(channelType);
|
||||
}
|
||||
}
|
||||
|
||||
return { sent: sentChannels.length > 0, channels: sentChannels };
|
||||
return { queued: queuedChannels.length > 0, channels: queuedChannels };
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy method for backward compatibility
|
||||
* Parses a template's `channel` column (e.g. "EMAIL" or "EMAIL,SMS") into valid channel
|
||||
* types, always including IN_APP so an in-app record is created.
|
||||
*/
|
||||
async sendDirect(dto: SendNotificationDto) {
|
||||
const notification = await this.prisma.notification.create({
|
||||
data: {
|
||||
passengerId: dto.passengerId,
|
||||
title: dto.title,
|
||||
body: dto.body,
|
||||
category: dto.category as any,
|
||||
deepLink: dto.deepLink,
|
||||
metadata: dto.metadata,
|
||||
},
|
||||
});
|
||||
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id: dto.passengerId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (passenger?.user) {
|
||||
await this.emailClient.sendEmail({
|
||||
to: passenger.user.email,
|
||||
subject: this.sanitize(dto.title),
|
||||
text: this.sanitize(dto.body),
|
||||
});
|
||||
}
|
||||
|
||||
return notification;
|
||||
private parseTemplateChannels(channel: string): NotificationChannelType[] {
|
||||
const valid: NotificationChannelType[] = ['EMAIL', 'SMS', 'PUSH', 'IN_APP'];
|
||||
const parsed = channel
|
||||
.split(',')
|
||||
.map((c) => c.trim().toUpperCase())
|
||||
.filter((c): c is NotificationChannelType => valid.includes(c as NotificationChannelType));
|
||||
return Array.from(new Set<NotificationChannelType>(['IN_APP', ...parsed]));
|
||||
}
|
||||
|
||||
private async createInAppNotification(
|
||||
@@ -154,22 +146,31 @@ export class NotificationsService {
|
||||
template: { subject?: string | null; bodyTemplate: string },
|
||||
context: Record<string, unknown>,
|
||||
): { subject: string; body: string } {
|
||||
const subject = template.subject || 'Notification';
|
||||
let body = template.bodyTemplate;
|
||||
return {
|
||||
subject: this.applyVars(template.subject || 'Notification', context),
|
||||
body: this.applyVars(template.bodyTemplate, context),
|
||||
};
|
||||
}
|
||||
|
||||
// Simple template interpolation: {{variable}}
|
||||
/** Replaces {{variable}} placeholders in a string with values from the context. */
|
||||
private applyVars(text: string, context: Record<string, unknown>): string {
|
||||
let out = text;
|
||||
for (const [key, value] of Object.entries(context)) {
|
||||
const regex = new RegExp(`{{\\s*${key}\\s*}}`, 'g');
|
||||
body = body.replace(regex, String(value));
|
||||
out = out.replace(regex, String(value));
|
||||
}
|
||||
|
||||
return { subject, body };
|
||||
return out;
|
||||
}
|
||||
|
||||
private async getUserPreferredChannels(recipient: string): Promise<NotificationChannelType[]> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
||||
OR: [
|
||||
{ id: recipient },
|
||||
{ email: recipient },
|
||||
{ phone: recipient },
|
||||
{ passenger: { id: recipient } },
|
||||
],
|
||||
},
|
||||
include: { preferences: true },
|
||||
});
|
||||
@@ -192,7 +193,12 @@ export class NotificationsService {
|
||||
): Promise<string | null> {
|
||||
const user = await this.prisma.user.findFirst({
|
||||
where: {
|
||||
OR: [{ id: recipient }, { email: recipient }, { phone: recipient }],
|
||||
OR: [
|
||||
{ id: recipient },
|
||||
{ email: recipient },
|
||||
{ phone: recipient },
|
||||
{ passenger: { id: recipient } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -211,12 +217,6 @@ export class NotificationsService {
|
||||
}
|
||||
}
|
||||
|
||||
private sanitize(value: string): string {
|
||||
return value
|
||||
.replace(/[\r\n]/g, ' ')
|
||||
.replace(/[<>&"']/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }[c] ?? c));
|
||||
}
|
||||
|
||||
getForPassenger(passengerId: string) {
|
||||
return this.prisma.notification.findMany({
|
||||
where: { passengerId },
|
||||
@@ -239,27 +239,238 @@ export class NotificationsService {
|
||||
|
||||
@OnEvent('booking.created')
|
||||
async onBookingCreated(payload: any) {
|
||||
const booking = payload.booking;
|
||||
await this.send(
|
||||
'booking.created',
|
||||
payload.booking.passengerId,
|
||||
booking.passengerId,
|
||||
{
|
||||
bookingRef: payload.booking.bookingRef,
|
||||
bookingRef: booking.bookingRef,
|
||||
amount: this.formatAmount(booking),
|
||||
currency: booking.displayCurrency ?? 'ETB',
|
||||
category: 'BOOKING',
|
||||
deepLink: `edr://bookings/${payload.booking.bookingRef}`,
|
||||
deepLink: `edr://bookings/${booking.bookingRef}`,
|
||||
},
|
||||
// For now, always notify the travelling passenger on every channel.
|
||||
['IN_APP', 'EMAIL', 'SMS'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Payment succeeded → one combined "payment successful, here is your ticket" notification.
|
||||
* Email carries the full ticket (HTML + QR); SMS is a short pointer to view it. The shallow
|
||||
* event payload is re-fetched with the relations needed to render the ticket.
|
||||
*/
|
||||
@OnEvent('payment.succeeded')
|
||||
async onPaymentSucceeded(payload: any) {
|
||||
await this.send(
|
||||
'payment.succeeded',
|
||||
payload.booking.passengerId,
|
||||
{
|
||||
bookingRef: payload.booking.bookingRef,
|
||||
category: 'PAYMENT',
|
||||
deepLink: `edr://tickets/${payload.booking.bookingRef}`,
|
||||
const passengerId = payload.booking.passengerId;
|
||||
const bookingId = payload.booking.id;
|
||||
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
},
|
||||
});
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } });
|
||||
|
||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||
const amount = this.formatAmount(booking ?? payload.booking);
|
||||
const currency = (booking ?? payload.booking).displayCurrency ?? 'ETB';
|
||||
const ticketUrl = `${process.env.PORTAL_URL ?? 'http://localhost:5174'}/booking/confirmation?ref=${ref}`;
|
||||
|
||||
// IN_APP — always created.
|
||||
await this.createInAppNotification(
|
||||
passengerId,
|
||||
'Payment successful',
|
||||
`Your payment of ${amount} ${currency} for booking ${ref} was successful. Your ticket is ready.`,
|
||||
{ category: 'PAYMENT', deepLink: `edr://tickets/${ref}` },
|
||||
);
|
||||
|
||||
// Ticket not ready (generation failed/raced) — fall back to a payment-only confirmation.
|
||||
if (!ticket || !booking) {
|
||||
this.logger.warn(`payment.succeeded: ticket not ready for booking ${ref}; sending payment-only confirmation`);
|
||||
const text = `EDR: Payment of ${amount} ${currency} received for booking ${ref}. Your ticket is being prepared.`;
|
||||
await this.deliverEmail(passengerId, `Payment received — ${ref}`, text);
|
||||
await this.deliverSms(passengerId, text);
|
||||
return;
|
||||
}
|
||||
|
||||
// SMS — short pointer (no HTML/QR over SMS).
|
||||
await this.deliverSms(
|
||||
passengerId,
|
||||
`EDR: Booking ${ref} confirmed, ${amount} ${currency} paid. Show ref ${ref} at the gate or view your ticket: ${ticketUrl}`,
|
||||
);
|
||||
|
||||
// EMAIL — rich HTML ticket with plain-text fallback.
|
||||
await this.deliverEmail(
|
||||
passengerId,
|
||||
`Your EDR ticket — ${ref}`,
|
||||
this.buildTicketEmailText(booking, amount, currency, ticketUrl),
|
||||
this.buildTicketEmailHtml(booking, ticket, amount, currency, ticketUrl),
|
||||
);
|
||||
}
|
||||
|
||||
private async deliverEmail(recipient: string, subject: string, text: string, html?: string): Promise<void> {
|
||||
const to = await this.getRecipientAddress(recipient, 'EMAIL');
|
||||
if (!to) {
|
||||
this.logger.warn(`No EMAIL address for recipient: ${recipient}`);
|
||||
return;
|
||||
}
|
||||
await this.emailClient.sendEmail({ to, subject, text, html });
|
||||
}
|
||||
|
||||
private async deliverSms(recipient: string, message: string): Promise<void> {
|
||||
const to = await this.getRecipientAddress(recipient, 'SMS');
|
||||
if (!to) {
|
||||
this.logger.warn(`No SMS address for recipient: ${recipient}`);
|
||||
return;
|
||||
}
|
||||
await this.smsClient.sendSms({ to, message });
|
||||
}
|
||||
|
||||
private buildTicketEmailText(booking: any, amount: string, currency: string, url: string): string {
|
||||
const s = booking.schedule ?? {};
|
||||
const dep = s.departureAt ? new Date(s.departureAt).toLocaleString('en-GB') : 'TBD';
|
||||
const passengers = (booking.seats ?? []).map((bs: any) => bs.passengerName).filter(Boolean).join(', ');
|
||||
return [
|
||||
`Booking ${booking.bookingRef} confirmed.`,
|
||||
`${s.originStation?.name ?? ''} -> ${s.destinationStation?.name ?? ''}`,
|
||||
`Train: ${s.train?.name ?? s.train?.number ?? ''}`,
|
||||
`Departs: ${dep}`,
|
||||
passengers ? `Passengers: ${passengers}` : '',
|
||||
`Total paid: ${amount} ${currency}`,
|
||||
`View your ticket: ${url}`,
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private buildTicketEmailHtml(booking: any, ticket: any, amount: string, currency: string, url: string): string {
|
||||
const s = booking.schedule ?? {};
|
||||
const fmt = (d: any) =>
|
||||
d ? new Date(d).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }) : 'TBD';
|
||||
const seatRows = (booking.seats ?? [])
|
||||
.map((bs: any) => {
|
||||
const coach = bs.seat?.coach?.number ?? '-';
|
||||
const seatNo = bs.seat?.seatNumber ?? '-';
|
||||
const cls = bs.seat?.coach?.coachType?.name ?? '-';
|
||||
return `<tr>
|
||||
<td style="padding:8px;border-bottom:1px solid #eee;">${bs.passengerName ?? ''}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #eee;">${coach}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #eee;">${seatNo}</td>
|
||||
<td style="padding:8px;border-bottom:1px solid #eee;">${cls}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
|
||||
<body style="margin:0;font-family:Arial,Helvetica,sans-serif;color:#333;background:#f4f4f4;">
|
||||
<div style="max-width:600px;margin:0 auto;background:#fff;">
|
||||
<div style="background:#0066cc;color:#fff;padding:24px;text-align:center;">
|
||||
<h2 style="margin:0;">Ethio-Djibouti Railway</h2>
|
||||
<p style="margin:8px 0 0;">Payment successful — your ticket is ready</p>
|
||||
</div>
|
||||
<div style="padding:24px;">
|
||||
<p>Booking reference: <strong>${booking.bookingRef}</strong></p>
|
||||
<table style="width:100%;border-collapse:collapse;margin:16px 0;">
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">From</td>
|
||||
<td style="padding:8px 0;text-align:right;"><strong>${s.originStation?.name ?? ''}</strong> (${s.originStation?.code ?? ''})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">To</td>
|
||||
<td style="padding:8px 0;text-align:right;"><strong>${s.destinationStation?.name ?? ''}</strong> (${s.destinationStation?.code ?? ''})</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">Train</td>
|
||||
<td style="padding:8px 0;text-align:right;">${s.train?.name ?? s.train?.number ?? ''}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">Departs</td>
|
||||
<td style="padding:8px 0;text-align:right;">${fmt(s.departureAt)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 0;color:#666;">Arrives</td>
|
||||
<td style="padding:8px 0;text-align:right;">${fmt(s.arrivalAt)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3 style="margin:16px 0 8px;">Passengers</h3>
|
||||
<table style="width:100%;border-collapse:collapse;">
|
||||
<tr style="text-align:left;color:#666;">
|
||||
<th style="padding:8px;border-bottom:2px solid #eee;">Name</th>
|
||||
<th style="padding:8px;border-bottom:2px solid #eee;">Coach</th>
|
||||
<th style="padding:8px;border-bottom:2px solid #eee;">Seat</th>
|
||||
<th style="padding:8px;border-bottom:2px solid #eee;">Class</th>
|
||||
</tr>
|
||||
${seatRows}
|
||||
</table>
|
||||
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<p style="color:#666;margin:0 0 8px;">Show this QR code at the gate</p>
|
||||
<img src="${ticket.qrPayload}" alt="Ticket QR code" width="180" height="180" style="border:1px solid #eee;padding:8px;background:#fff;" />
|
||||
</div>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;border-top:2px solid #eee;margin-top:16px;">
|
||||
<tr>
|
||||
<td style="padding:12px 0;font-size:16px;"><strong>Total paid</strong></td>
|
||||
<td style="padding:12px 0;font-size:16px;text-align:right;"><strong>${amount} ${currency}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="text-align:center;margin:24px 0;">
|
||||
<a href="${url}" style="background:#0066cc;color:#fff;text-decoration:none;padding:12px 28px;border-radius:4px;display:inline-block;">View ticket</a>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:center;padding:20px;color:#999;font-size:12px;">
|
||||
<p style="margin:0;">© Ethio-Djibouti Railway. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
@OnEvent('payment.failed')
|
||||
async onPaymentFailed(payload: any) {
|
||||
const booking = payload.booking;
|
||||
await this.send(
|
||||
'payment.failed',
|
||||
booking.passengerId,
|
||||
{
|
||||
bookingRef: booking.bookingRef,
|
||||
category: 'PAYMENT',
|
||||
deepLink: `edr://bookings/${booking.bookingRef}`,
|
||||
},
|
||||
['IN_APP', 'EMAIL', 'SMS'],
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('booking.cancelled')
|
||||
async onBookingCancelled(payload: any) {
|
||||
const booking = payload.booking;
|
||||
await this.send(
|
||||
'booking.cancelled',
|
||||
booking.passengerId,
|
||||
{
|
||||
bookingRef: booking.bookingRef,
|
||||
// refundAmount is computed in ETB minor units in BookingsService.cancel().
|
||||
refundAmount: ((payload.refundAmount ?? 0) / 100).toFixed(2),
|
||||
currency: 'ETB',
|
||||
category: 'BOOKING',
|
||||
deepLink: `edr://bookings/${booking.bookingRef}`,
|
||||
},
|
||||
['IN_APP', 'EMAIL', 'SMS'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a booking's payable amount from minor units into a major-unit string.
|
||||
* Money is stored as integer minor units (e.g. 59600 santim) to avoid floating-point
|
||||
* drift; we divide by 100 only here, at the display edge. e.g. 59600 -> "596.00".
|
||||
*/
|
||||
private formatAmount(booking: any): string {
|
||||
const minor = booking.displayTotalMinor ?? booking.totalMinor ?? 0;
|
||||
return (minor / 100).toFixed(2);
|
||||
}
|
||||
}
|
||||
@@ -30,21 +30,39 @@ export class SmsClientService implements OnApplicationBootstrap {
|
||||
});
|
||||
}
|
||||
|
||||
async sendSms(dto: SingleMessageDto) {
|
||||
if (!this.enabled) return {};
|
||||
async sendSms(dto: SingleMessageDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped SMS`);
|
||||
return { queued: false };
|
||||
}
|
||||
this.smsClient.emit("send-sms", {
|
||||
...dto,
|
||||
to: dto.to,
|
||||
text: dto.message,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
return {};
|
||||
// Fire-and-forget enqueue: confirms hand-off to RabbitMQ, NOT delivery.
|
||||
this.logger.log(
|
||||
`SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='send-sms'`,
|
||||
);
|
||||
// Recipient + content are PII — debug only.
|
||||
this.logger.debug(`SMS payload to=${dto.to} text="${dto.message}"`);
|
||||
return { queued: true };
|
||||
}
|
||||
|
||||
async sendBulkMessages(dto: BulkMessagesDto) {
|
||||
if (!this.enabled) return {};
|
||||
async sendBulkMessages(dto: BulkMessagesDto): Promise<{ queued: boolean }> {
|
||||
if (!this.enabled) {
|
||||
this.logger.warn(`RABBITMQ disabled — skipped BULK SMS (${dto.messages?.length ?? 0} messages)`);
|
||||
return { queued: false };
|
||||
}
|
||||
const messages = (dto.messages ?? []).map((m) => ({ to: m.to, text: m.message, from: m.from }));
|
||||
this.smsClient.emit("ozeking-bulk-sms", {
|
||||
...dto,
|
||||
messages,
|
||||
appKey: "IFHCRS-LICENSE-MANAGEMENT",
|
||||
});
|
||||
return {};
|
||||
this.logger.log(
|
||||
`BULK SMS queued to RabbitMQ [${process.env.SMS_QUEUE ?? "sms_queue"}] pattern='ozeking-bulk-sms' count=${messages.length}`,
|
||||
);
|
||||
this.logger.debug(`BULK SMS payload messages=${JSON.stringify(messages)}`);
|
||||
return { queued: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,9 @@ export class PaymentsService {
|
||||
referenceType: PaymentReferenceType.BOOKING,
|
||||
referenceId: booking.id,
|
||||
orderRef: booking.bookingRef,
|
||||
amountMinor: booking.totalMinor,
|
||||
// Send the REAL (major) price, not minor units. The payment API no longer divides by 100
|
||||
// (freight already passes the real price), so the providers charge this value as-is.
|
||||
amountMinor: booking.totalMinor / 100,
|
||||
currency: booking.currency,
|
||||
provider: method as unknown as ProviderMethod,
|
||||
platform: dto.platform,
|
||||
@@ -620,6 +622,12 @@ export class PaymentsService {
|
||||
failureMessage: event.failureMessage,
|
||||
});
|
||||
}
|
||||
const failedBooking = await this.prisma.booking.findUnique({
|
||||
where: { id: event.referenceId },
|
||||
});
|
||||
if (failedBooking) {
|
||||
this.eventEmitter.emit("payment.failed", { booking: failedBooking });
|
||||
}
|
||||
return { processed: true };
|
||||
}
|
||||
|
||||
@@ -634,11 +642,15 @@ export class PaymentsService {
|
||||
return { processed: false, reason: "booking-not-found" };
|
||||
}
|
||||
|
||||
if (booking.totalMinor !== event.amountMinor) {
|
||||
// The event carries the REAL (major) price the provider charged (passenger now sends
|
||||
// booking.totalMinor/100 on initiate), so convert it back to minor units before comparing
|
||||
// with booking.totalMinor (which is in minor units).
|
||||
const eventAmountMinor = Math.round(event.amountMinor * 100);
|
||||
if (booking.totalMinor !== eventAmountMinor) {
|
||||
// Refuse to confirm: a 4xx makes the relay retry and eventually flag the row FAILED,
|
||||
// which is the alertable signal for an asserted-vs-paid amount divergence.
|
||||
this.logger.error(
|
||||
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor}`,
|
||||
`mark-paid: amount mismatch for booking ${booking.id}: booking=${booking.totalMinor} event=${event.amountMinor} (=${eventAmountMinor} minor)`,
|
||||
);
|
||||
throw new BadRequestException(
|
||||
"Event amount does not match booking total",
|
||||
|
||||
@@ -12,34 +12,22 @@ export class SchedulesController {
|
||||
|
||||
@Post('bulk-generate')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Bulk generate repetitive schedules',
|
||||
description: 'Creates multiple schedules automatically by repeating every X days for the next Y days. Example: repeat every 2 days for 30 days = 15 schedules.',
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedules generated successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid parameters or route not found' })
|
||||
@ApiOperation({ summary: 'Bulk generate repetitive schedules' })
|
||||
bulkGenerateSchedules(@Body() dto: BulkCreateSchedulesDto) {
|
||||
return this.service.bulkGenerateSchedules(dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Create a train schedule from a route template',
|
||||
description: `Creates a schedule by referencing a Route (routeId).\nStops are automatically copied from the route's RouteStop definitions.\nYou supply the actual planned arrival/departure times per stop sequence.\nOrigin and destination are derived from the first and last route stop — no need to specify them manually.`,
|
||||
})
|
||||
@ApiResponse({ status: 201, description: 'Schedule created with stops copied from route template' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid times, inactive route, or missing planned times for some stops' })
|
||||
@ApiResponse({ status: 404, description: 'Train or route not found' })
|
||||
@ApiOperation({ summary: 'Create a train schedule from a route template' })
|
||||
createSchedule(@Body() dto: CreateScheduleDto) { return this.service.createSchedule(dto); }
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List schedules with optional filters' })
|
||||
@ApiQuery({ name: 'date', required: false, example: '2026-06-15', description: 'Departure date (YYYY-MM-DD). Returns all schedules departing on this calendar day.' })
|
||||
@ApiQuery({ name: 'routeId', required: false, description: 'Filter by route UUID' })
|
||||
@ApiQuery({ name: 'trainId', required: false, description: 'Filter by train UUID' })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TripStatus, description: 'Filter by schedule status' })
|
||||
@ApiResponse({ status: 200, description: 'Array of schedules ordered by departureAt, each with train, origin/destination, stops, and booking/assignment counts' })
|
||||
@ApiQuery({ name: 'date', required: false })
|
||||
@ApiQuery({ name: 'routeId', required: false })
|
||||
@ApiQuery({ name: 'trainId', required: false })
|
||||
@ApiQuery({ name: 'status', required: false, enum: TripStatus })
|
||||
listSchedules(
|
||||
@Query('date') date?: string,
|
||||
@Query('routeId') routeId?: string,
|
||||
@@ -57,57 +45,63 @@ export class SchedulesController {
|
||||
@ApiResponse({ status: 201, description: 'Fare rule created' })
|
||||
createFareRule(@Body() dto: CreateFareRuleDto) { return this.service.createFareRule(dto); }
|
||||
|
||||
@Patch('fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule updated' })
|
||||
updateFareRule(@Param('id') id: string, @Body() dto: Partial<CreateFareRuleDto>) {
|
||||
return this.service.updateFareRule(id, dto);
|
||||
}
|
||||
|
||||
@Delete('fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'FareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Fare rule deleted' })
|
||||
deleteFareRule(@Param('id') id: string) { return this.service.deleteFareRule(id); }
|
||||
|
||||
@Post('segment-fares')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Create a segment fare rule (stop-to-stop pricing on a route)' })
|
||||
@ApiResponse({ status: 201, description: 'Segment fare rule created' })
|
||||
@ApiOperation({ summary: 'Create a segment fare rule' })
|
||||
createSegmentFareRule(@Body() dto: any) { return this.service.createSegmentFareRule(dto); }
|
||||
|
||||
@Get('routes/:routeId/segment-fares')
|
||||
@ApiOperation({ summary: 'List all segment fare rules for a route' })
|
||||
@ApiParam({ name: 'routeId', description: 'Route UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of segment fare rules' })
|
||||
getSegmentFares(@Param('routeId') routeId: string) { return this.service.getSegmentFares(routeId); }
|
||||
|
||||
@Patch('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Segment fare rule updated' })
|
||||
updateSegmentFareRule(@Param('id') id: string, @Body() dto: any) { return this.service.updateSegmentFareRule(id, dto); }
|
||||
|
||||
@Delete('segment-fares/:id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a segment fare rule' })
|
||||
@ApiParam({ name: 'id', description: 'SegmentFareRule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Segment fare rule deleted' })
|
||||
deleteSegmentFareRule(@Param('id') id: string) { return this.service.deleteSegmentFareRule(id); }
|
||||
|
||||
// ===== PARAMETRIZED ROUTES (generic :id routes come AFTER specific routes) =====
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get schedule with train, coaches, seats, and stop timeline' })
|
||||
@ApiOperation({ summary: 'Get schedule detail' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Full schedule detail including route stops with station info' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getSchedule(@Param('id') id: string) { return this.service.getSchedule(id); }
|
||||
|
||||
@Patch(':id')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update a schedule (partial update - times, status, coaches)' })
|
||||
@ApiOperation({ summary: 'Update a schedule (partial)' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateSchedule(@Param('id') id: string, @Body() dto: UpdateScheduleDto) {
|
||||
return this.service.updateSchedulePartial(id, dto);
|
||||
}
|
||||
|
||||
@Patch(':id/status')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update schedule status (SCHEDULED → BOARDING → EN_ROUTE → ARRIVED)' })
|
||||
@ApiOperation({ summary: 'Update schedule status' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Status updated' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
updateStatus(@Param('id') id: string, @Body() dto: UpdateScheduleStatusDto) {
|
||||
return this.service.updateScheduleStatus(id, dto);
|
||||
}
|
||||
@@ -116,26 +110,18 @@ export class SchedulesController {
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Delete a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Schedule deleted' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
deleteSchedule(@Param('id') id: string) {
|
||||
return this.service.deleteSchedule(id);
|
||||
}
|
||||
deleteSchedule(@Param('id') id: string) { return this.service.deleteSchedule(id); }
|
||||
|
||||
@Get(':id/stops')
|
||||
@ApiOperation({ summary: 'List all stops for a schedule ordered by sequence' })
|
||||
@ApiOperation({ summary: 'List all stops for a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Ordered stop list with station details and planned/actual times' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getStops(@Param('id') id: string) { return this.service.getStops(id); }
|
||||
|
||||
@Patch(':id/stops/:sequence')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Update planned times or live status of a specific stop' })
|
||||
@ApiOperation({ summary: 'Update a stop time' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'sequence', description: 'Stop sequence number' })
|
||||
@ApiResponse({ status: 200, description: 'Stop updated' })
|
||||
@ApiResponse({ status: 404, description: 'Stop not found on schedule' })
|
||||
updateStop(
|
||||
@Param('id') id: string,
|
||||
@Param('sequence', ParseIntPipe) sequence: number,
|
||||
@@ -145,19 +131,26 @@ export class SchedulesController {
|
||||
@Get(':scheduleId/fares/stored')
|
||||
@ApiOperation({ summary: 'Get stored fare rules for a schedule' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of stored fare rules with seat class info' })
|
||||
getStoredFares(@Param('scheduleId') scheduleId: string) {
|
||||
return this.service.getFareRules(scheduleId);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get fare for a schedule and seat class from the fare engine' })
|
||||
@Get(':scheduleId/fares/all')
|
||||
@ApiOperation({ summary: 'Get fares for all active seat classes from the fare engine' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'seatClassId', required: true, description: 'SeatClass UUID' })
|
||||
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency (Ethiopian→ETB, Djiboutian→DJF, other→USD)' })
|
||||
@ApiResponse({ status: 200, description: 'Live fare breakdown from fare engine' })
|
||||
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule or seat class not found' })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
getAllFares(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
) {
|
||||
return this.service.getAllFaresFromEngine(scheduleId, nationality);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares')
|
||||
@ApiOperation({ summary: 'Get fare for a specific seat class from the fare engine' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'seatClassId', required: true })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
getFare(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query('seatClassId') seatClassId: string,
|
||||
@@ -166,42 +159,15 @@ export class SchedulesController {
|
||||
return this.service.getFareFromEngine(scheduleId, seatClassId, nationality);
|
||||
}
|
||||
|
||||
@Get(':scheduleId/fares/all')
|
||||
@ApiOperation({ summary: 'Get fares for all active seat classes on a schedule' })
|
||||
@ApiParam({ name: 'scheduleId', description: 'TrainSchedule UUID' })
|
||||
@ApiQuery({ name: 'nationality', required: false, description: 'Passenger nationality — determines billing currency' })
|
||||
@ApiResponse({ status: 200, description: 'Array of fare breakdowns for every active seat class, ordered by price ascending' })
|
||||
@ApiResponse({ status: 400, description: 'Schedule has no route or missing distanceKm on stops' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
getAllFares(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
) {
|
||||
return this.service.getAllFaresFromEngine(scheduleId, nationality);
|
||||
}
|
||||
|
||||
@Post(':id/fares/sync')
|
||||
@ApiOperation({
|
||||
summary: 'Sync fares from fare engine',
|
||||
description: 'Recalculates fares for all active seat classes using the fare engine (km × ratePerKm + tax) and upserts them as FareRule records scoped to this schedule. Previous active rules are expired.',
|
||||
})
|
||||
@ApiOperation({ summary: 'Sync fares from fare engine' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Fares synced — returns count of synced rules and any errors' })
|
||||
@ApiResponse({ status: 400, description: 'Schedule has no associated route or missing distanceKm on stops' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule not found' })
|
||||
syncFares(@Param('id') id: string) {
|
||||
return this.service.syncFaresFromEngine(id);
|
||||
}
|
||||
syncFares(@Param('id') id: string) { return this.service.syncFaresFromEngine(id); }
|
||||
|
||||
@Post(':id/coaches')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Assign coaches to a schedule',
|
||||
description: 'Assigns selected coaches to a schedule with their position numbers. Replaces any existing coach assignments.'
|
||||
})
|
||||
@ApiOperation({ summary: 'Assign coaches to a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 201, description: 'Coaches assigned successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Schedule or coach not found' })
|
||||
assignCoaches(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: { coaches: Array<{ coachId: string; positionNumber: number }> },
|
||||
@@ -212,21 +178,14 @@ export class SchedulesController {
|
||||
@Get(':id/coaches')
|
||||
@ApiOperation({ summary: 'Get assigned coaches for a schedule' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiResponse({ status: 200, description: 'List of assigned coaches with seat details' })
|
||||
getAssignedCoaches(@Param('id') id: string) {
|
||||
return this.service.getAssignedCoaches(id);
|
||||
}
|
||||
getAssignedCoaches(@Param('id') id: string) { return this.service.getAssignedCoaches(id); }
|
||||
|
||||
@Delete(':id/coaches/:coachId')
|
||||
@UseGuards(JwtGuard) @ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({ summary: 'Remove a coach assignment from a schedule' })
|
||||
@ApiOperation({ summary: 'Remove a coach assignment' })
|
||||
@ApiParam({ name: 'id', description: 'TrainSchedule UUID' })
|
||||
@ApiParam({ name: 'coachId', description: 'Coach UUID' })
|
||||
@ApiResponse({ status: 200, description: 'Coach assignment removed' })
|
||||
removeCoachAssignment(
|
||||
@Param('id') id: string,
|
||||
@Param('coachId') coachId: string,
|
||||
) {
|
||||
removeCoachAssignment(@Param('id') id: string, @Param('coachId') coachId: string) {
|
||||
return this.service.removeCoachAssignment(id, coachId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ export class SchedulesService {
|
||||
const errors: string[] = [];
|
||||
const scheduleIds: string[] = [];
|
||||
|
||||
// Validate route and get stops for plannedTimes generation
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
@@ -45,7 +44,6 @@ export class SchedulesService {
|
||||
const schedule = await this.createSchedule(createDto);
|
||||
scheduleIds.push(schedule.id);
|
||||
|
||||
// Assign coaches if provided
|
||||
if (dto.coachIds && dto.coachIds.length > 0) {
|
||||
await this.assignCoaches(
|
||||
schedule.id,
|
||||
@@ -58,15 +56,10 @@ export class SchedulesService {
|
||||
errors.push(`Failed to create schedule for ${currentDate.toISOString()}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// Move to next repetition
|
||||
currentDate = new Date(currentDate.getTime() + dto.repeatEveryDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
return {
|
||||
schedulesCreated: scheduleCount,
|
||||
errors,
|
||||
scheduleIds,
|
||||
};
|
||||
return { schedulesCreated: scheduleCount, errors, scheduleIds };
|
||||
}
|
||||
|
||||
async listSchedules(dto: ListSchedulesDto) {
|
||||
@@ -104,7 +97,6 @@ export class SchedulesService {
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
// Validate route exists and has stops
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id: dto.routeId },
|
||||
include: { stops: { orderBy: { sequence: 'asc' } } },
|
||||
@@ -113,21 +105,13 @@ export class SchedulesService {
|
||||
if (!route.active) throw new BadRequestException('Route is not active');
|
||||
if (route.stops.length < 2) throw new BadRequestException('Route must have at least 2 stops');
|
||||
|
||||
// Check for duplicate schedule with same train, route, and date
|
||||
const depDate = new Date(dep);
|
||||
depDate.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(depDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
|
||||
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
||||
where: {
|
||||
trainId: dto.trainId,
|
||||
routeId: dto.routeId,
|
||||
departureAt: {
|
||||
gte: depDate,
|
||||
lt: nextDay,
|
||||
},
|
||||
},
|
||||
where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } },
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
@@ -136,7 +120,6 @@ export class SchedulesService {
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-generate plannedTimes if not provided or empty
|
||||
let plannedTimes = dto.plannedTimes;
|
||||
if (!plannedTimes || plannedTimes.length === 0) {
|
||||
const totalDuration = arr.getTime() - dep.getTime();
|
||||
@@ -144,7 +127,6 @@ export class SchedulesService {
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
@@ -154,7 +136,6 @@ export class SchedulesService {
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -163,14 +144,12 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate all route stop sequences are covered by plannedTimes
|
||||
const providedSeqs = new Set(plannedTimes.map(t => t.sequence));
|
||||
const missingSeqs = route.stops.map(s => s.sequence).filter(seq => !providedSeqs.has(seq));
|
||||
if (missingSeqs.length > 0) {
|
||||
throw new BadRequestException(`Missing planned times for stop sequences: ${missingSeqs.join(', ')}`);
|
||||
}
|
||||
|
||||
// Derive origin and destination from first and last route stop
|
||||
const firstStop = route.stops[0];
|
||||
const lastStop = route.stops[route.stops.length - 1];
|
||||
|
||||
@@ -188,9 +167,7 @@ export class SchedulesService {
|
||||
include: { train: true, originStation: true, destinationStation: true },
|
||||
});
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, schedule.id, plannedTimesMap);
|
||||
|
||||
return this.getSchedule(schedule.id);
|
||||
@@ -230,10 +207,7 @@ export class SchedulesService {
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveEffectiveStatuses(
|
||||
scheduleId: string,
|
||||
seatIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
private async resolveEffectiveStatuses(scheduleId: string, seatIds: string[]): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
|
||||
@@ -304,7 +278,6 @@ export class SchedulesService {
|
||||
|
||||
plannedTimes = route.stops.map((stop, index) => {
|
||||
let stopTime: Date;
|
||||
|
||||
if (index === 0) {
|
||||
stopTime = dep;
|
||||
} else if (index === route.stops.length - 1) {
|
||||
@@ -314,7 +287,6 @@ export class SchedulesService {
|
||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
||||
}
|
||||
|
||||
return {
|
||||
sequence: stop.sequence,
|
||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
||||
@@ -323,9 +295,7 @@ export class SchedulesService {
|
||||
});
|
||||
}
|
||||
|
||||
const plannedTimesMap = Object.fromEntries(
|
||||
plannedTimes.map(t => [t.sequence, t]),
|
||||
);
|
||||
const plannedTimesMap = Object.fromEntries(plannedTimes.map(t => [t.sequence, t]));
|
||||
await this.routesService.applyRouteToSchedule(dto.routeId, id, plannedTimesMap);
|
||||
|
||||
return this.getSchedule(id);
|
||||
@@ -376,9 +346,35 @@ export class SchedulesService {
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateFareRule(id: string, dto: Partial<CreateFareRuleDto>) {
|
||||
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Fare rule not found');
|
||||
|
||||
const { validFrom, validUntil, scheduleId, nationality, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.fareRule.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
...(scheduleId !== undefined && { tripId: scheduleId }),
|
||||
...(nationality !== undefined && { nationality }),
|
||||
...(validFrom && { validFrom: new Date(validFrom) }),
|
||||
...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }),
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteFareRule(id: string) {
|
||||
const existing = await this.prisma.fareRule.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Fare rule not found');
|
||||
await this.prisma.fareRule.delete({ where: { id } });
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
createSegmentFareRule(dto: any) {
|
||||
const { validFrom, validUntil, passengerCategory, ...rest } = dto;
|
||||
return this.prisma.segmentFareRule.create({
|
||||
@@ -419,7 +415,6 @@ export class SchedulesService {
|
||||
async getFareRules(scheduleId?: string) {
|
||||
const where: any = {};
|
||||
if (scheduleId) where.tripId = scheduleId;
|
||||
|
||||
return this.prisma.fareRule.findMany({
|
||||
where,
|
||||
include: { seatClass: true },
|
||||
@@ -439,11 +434,10 @@ export class SchedulesService {
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
if (!schedule.routeId) throw new BadRequestException('Schedule has no associated route');
|
||||
|
||||
return await this.fareEngine.calculateAllForSchedule(scheduleId, nationality);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(
|
||||
error instanceof Error ? error.message : 'Failed to calculate fares for schedule'
|
||||
error instanceof Error ? error.message : 'Failed to calculate fares for schedule',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -483,20 +477,13 @@ export class SchedulesService {
|
||||
return { synced, errors };
|
||||
}
|
||||
|
||||
async assignCoaches(
|
||||
scheduleId: string,
|
||||
coaches: Array<{ coachId: string; positionNumber: number }>,
|
||||
) {
|
||||
async assignCoaches(scheduleId: string, coaches: Array<{ coachId: string; positionNumber: number }>) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const coachIds = coaches.map(c => c.coachId);
|
||||
const existingCoaches = await this.prisma.coach.findMany({
|
||||
where: { id: { in: coachIds } },
|
||||
});
|
||||
if (existingCoaches.length !== coachIds.length) {
|
||||
throw new NotFoundException('One or more coaches not found');
|
||||
}
|
||||
const existingCoaches = await this.prisma.coach.findMany({ where: { id: { in: coachIds } } });
|
||||
if (existingCoaches.length !== coachIds.length) throw new NotFoundException('One or more coaches not found');
|
||||
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId } });
|
||||
|
||||
@@ -508,20 +495,13 @@ export class SchedulesService {
|
||||
}));
|
||||
|
||||
await this.prisma.coachAssignment.createMany({ data });
|
||||
|
||||
return { message: 'Coaches assigned successfully', count: coaches.length };
|
||||
}
|
||||
|
||||
async getAssignedCoaches(scheduleId: string) {
|
||||
return this.prisma.coachAssignment.findMany({
|
||||
where: { scheduleId },
|
||||
include: {
|
||||
coach: {
|
||||
include: {
|
||||
seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { coach: { include: { seats: { orderBy: [{ row: 'asc' }, { col: 'asc' }] } } } },
|
||||
orderBy: { positionNumber: 'asc' },
|
||||
});
|
||||
}
|
||||
@@ -535,30 +515,22 @@ export class SchedulesService {
|
||||
if (dto.departureAt || dto.arrivalAt) {
|
||||
const dep = dto.departureAt ? new Date(dto.departureAt) : new Date(schedule.departureAt);
|
||||
const arr = dto.arrivalAt ? new Date(dto.arrivalAt) : new Date(schedule.arrivalAt);
|
||||
|
||||
if (arr <= dep) throw new BadRequestException('Arrival time must be after departure time');
|
||||
|
||||
updateData.departureAt = dep;
|
||||
updateData.arrivalAt = arr;
|
||||
updateData.durationMinutes = Math.round((arr.getTime() - dep.getTime()) / 60_000);
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
updateData.status = dto.status;
|
||||
}
|
||||
if (dto.status) updateData.status = dto.status;
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.prisma.trainSchedule.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
await this.prisma.trainSchedule.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
if (dto.coaches !== undefined) {
|
||||
if (dto.coaches.length > 0) {
|
||||
await this.assignCoaches(id, dto.coaches);
|
||||
} else {
|
||||
// Remove all coach assignments when empty array is sent
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
}
|
||||
}
|
||||
@@ -567,12 +539,9 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
async removeCoachAssignment(scheduleId: string, coachId: string) {
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({
|
||||
where: { scheduleId, coachId },
|
||||
});
|
||||
const assignment = await this.prisma.coachAssignment.findFirst({ where: { scheduleId, coachId } });
|
||||
if (!assignment) throw new NotFoundException('Coach assignment not found');
|
||||
|
||||
await this.prisma.coachAssignment.delete({ where: { id: assignment.id } });
|
||||
return { message: 'Coach assignment removed' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -441,6 +441,7 @@ export class SearchService {
|
||||
destinationStationId,
|
||||
seatClassId: sc.id,
|
||||
nationality,
|
||||
scheduleId: schedule.id,
|
||||
});
|
||||
return {
|
||||
seatClassName: fare.seatClassName,
|
||||
@@ -499,11 +500,12 @@ export class SearchService {
|
||||
coachTypeId: string;
|
||||
coachTypeName: string;
|
||||
coachTypeCode: string;
|
||||
coachId: string;
|
||||
classes: Array<{ name: string; baseFareMinor: number }>;
|
||||
}>> {
|
||||
const coachTypeMap = new Map<
|
||||
string,
|
||||
{ coachType: any; classNames: Set<string> }
|
||||
{ coachType: any; classNames: Set<string>; coachId: string }
|
||||
>();
|
||||
|
||||
for (const assignment of schedule.coachAssignments) {
|
||||
@@ -514,6 +516,7 @@ export class SearchService {
|
||||
coachTypeMap.set(coachType.id, {
|
||||
coachType,
|
||||
classNames: new Set(),
|
||||
coachId: assignment.coach.id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -522,7 +525,7 @@ export class SearchService {
|
||||
}
|
||||
|
||||
const result = [];
|
||||
for (const [, { coachType, classNames }] of coachTypeMap) {
|
||||
for (const [, { coachType, classNames, coachId }] of coachTypeMap) {
|
||||
const classes = Array.from(classNames)
|
||||
.map((className) => {
|
||||
const fareInfo = faresByClass.find((f) => f.seatClassName === className);
|
||||
@@ -536,6 +539,7 @@ export class SearchService {
|
||||
coachTypeId: coachType.id,
|
||||
coachTypeName: coachType.name,
|
||||
coachTypeCode: coachType.code,
|
||||
coachId,
|
||||
classes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -281,7 +281,7 @@ export class TicketsService {
|
||||
if (resolvedLeg !== 'LEG1' && resolvedLeg !== 'LEG2') {
|
||||
throw new BadRequestException('For TRANSIT bookings supply leg=LEG1 or leg=LEG2');
|
||||
}
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||
const alreadyValidated = logs.some(l => l.leg === resolvedLeg);
|
||||
if (alreadyValidated) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
@@ -333,7 +333,7 @@ export class TicketsService {
|
||||
if (!validLegs.includes(resolvedLeg)) {
|
||||
throw new BadRequestException(`For ROUND_TRIP_TRANSIT supply leg=${validLegs.join('|')}`);
|
||||
}
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
const logs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||
if (logs.some(l => l.leg === resolvedLeg)) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: `${resolvedLeg}_ALREADY_USED` } as any });
|
||||
throw new BadRequestException(`${resolvedLeg} already validated`);
|
||||
@@ -435,7 +435,7 @@ export class TicketsService {
|
||||
booking.bookingType === 'TRANSIT' ||
|
||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isMultiLeg && offlineLeg) {
|
||||
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } });
|
||||
const existingLogs = await this.prisma.gateValidationLog.findMany({ where: { ticketId: ticket.id, status: 'APPROVED' } }) as any[];
|
||||
if (existingLogs.some(l => l.leg === offlineLeg)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user