mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/edr-platform into freight/feature/vehicle_2
This commit is contained in:
@@ -41,6 +41,7 @@ DEFAULT_PASSWORD=password@tria
|
||||
# Freight org + staff (bookings / rule-engine IAM)
|
||||
SEED_EDR_ORG=true
|
||||
SEED_FREIGHT_STAFF=true
|
||||
SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO=false
|
||||
|
||||
# MinIO (used by @tria-plc/iamapi-common for file storage)
|
||||
MINIO_ENDPOINT=localhost
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
"seed:freight-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-freight-demo.ts",
|
||||
"seed:warehouse-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-warehouse-demo.ts",
|
||||
"seed:export-djibouti-interchange-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-export-djibouti-interchange-demo.ts",
|
||||
"seed:import-djibouti-demo": "ts-node -r tsconfig-paths/register src/scripts/seed-import-djibouti-demo.ts",
|
||||
"seed:approved-first-lastmile-demo-bookings": "ts-node -r tsconfig-paths/register src/scripts/seed-approved-first-lastmile-demo-bookings.ts",
|
||||
"seed:negad-indode-arrived-train": "ts-node -r tsconfig-paths/register src/scripts/seed-negad-indode-arrived-train.ts",
|
||||
"seed:file-upload-settings": "ts-node -r tsconfig-paths/register src/scripts/seed-file-upload-settings.ts",
|
||||
"seed:fleet-wagons": "bash ../../../docs/new/seeds/seed-fleet-wagons.sh",
|
||||
"iam:typeorm:cli": "cross-env MIGRATIONS_DIR=node_modules/@tria-plc/iamapi-common/dist/db/migrations/*.{ts,js} ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js -d ./node_modules/@tria-plc/api-common/dist/modules/typeorm/typeorm.config.js",
|
||||
|
||||
@@ -52,8 +52,10 @@ 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 { ExportDjiboutiInterchangeDemoSeeder } from "./seed/export-djibouti-interchange-demo.seeder";
|
||||
import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-key-migration.seeder";
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
|
||||
//New Trains, Wagons, Container and Cargo management modules
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
@@ -67,6 +69,7 @@ import { DriversModule } from './modules/drivers/drivers.module';
|
||||
import { FirstMileModule } from './modules/first-mile/first-mile.module';
|
||||
import { LastMileModule } from './modules/last-mile/last-mile.module';
|
||||
import { InterchangeDocumentsModule } from './modules/interchange-documents/interchange-documents.module';
|
||||
import { ImportOperationsModule } from './modules/import-operations/import-operations.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -130,6 +133,7 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
FirstMileModule,
|
||||
LastMileModule,
|
||||
InterchangeDocumentsModule,
|
||||
ImportOperationsModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
@@ -145,6 +149,8 @@ import { InterchangeDocumentsModule } from './modules/interchange-documents/inte
|
||||
Batch7TestDataSeeder,
|
||||
Batch8TestDataSeeder,
|
||||
WarehouseDemoSeeder,
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -161,6 +167,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly batch7TestDataSeeder: Batch7TestDataSeeder,
|
||||
private readonly batch8TestDataSeeder: Batch8TestDataSeeder,
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
) { }
|
||||
@@ -179,6 +186,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.batch7TestDataSeeder.run();
|
||||
await this.batch8TestDataSeeder.run();
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.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,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateImportDjiboutiOperations1822000000000 implements MigrationInterface {
|
||||
name = 'CreateImportDjiboutiOperations1822000000000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'import_djibouti_operations',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'train_schedule_id', type: 'uuid', isUnique: true },
|
||||
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'gatepass_granted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'ready_for_loading_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'loaded_on_train_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'departed_from_djibouti_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'load_list_generated_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
await queryRunner.createIndex(
|
||||
'freight.import_djibouti_operations',
|
||||
new TableIndex({
|
||||
name: 'idx_import_djibouti_operations_schedule',
|
||||
columnNames: ['train_schedule_id'],
|
||||
}),
|
||||
);
|
||||
|
||||
await queryRunner.createForeignKey(
|
||||
'freight.import_djibouti_operations',
|
||||
new TableForeignKey({
|
||||
columnNames: ['train_schedule_id'],
|
||||
referencedTableName: 'train_schedules',
|
||||
referencedSchema: 'freight',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.import_djibouti_operations', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm';
|
||||
|
||||
export class CreateImportOperationsTables1823000000000 implements MigrationInterface {
|
||||
name = 'CreateImportOperationsTables1823000000000';
|
||||
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'djibouti_import_incidents',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid' },
|
||||
{ name: 'container_number', type: 'varchar', length: '80', isNullable: true },
|
||||
{ name: 'cargo_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'station', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'incident_type', type: 'varchar', length: '40' },
|
||||
{ name: 'description', type: 'text' },
|
||||
{ name: 'photos', type: 'jsonb', default: "'[]'::jsonb" },
|
||||
{ name: 'reported_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'reported_at', type: 'timestamptz' },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_container', columnNames: ['container_number'] }));
|
||||
await queryRunner.createIndex('freight.djibouti_import_incidents', new TableIndex({ name: 'idx_djibouti_incidents_type', columnNames: ['incident_type'] }));
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'import_customs_finalizations',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'booking_id', type: 'uuid', isUnique: true },
|
||||
{ name: 'documents', type: 'jsonb', default: "'{}'::jsonb" },
|
||||
{ name: 'declaration_serial_number', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'duties_taxes_notified_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'duties_taxes_paid_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'customs_risk', type: 'varchar', length: '12', isNullable: true },
|
||||
{ name: 'import_release_permitted_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'completed_at', type: 'timestamptz', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'notes', type: 'text', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.import_customs_finalizations', new TableIndex({ name: 'idx_import_customs_risk', columnNames: ['customs_risk'] }));
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
schema: 'freight',
|
||||
name: 'empty_container_returns',
|
||||
columns: [
|
||||
{ name: 'id', type: 'uuid', isPrimary: true, generationStrategy: 'uuid', default: 'uuid_generate_v4()' },
|
||||
{ name: 'container_number', type: 'varchar', length: '80' },
|
||||
{ name: 'booking_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'customer_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'return_date', type: 'timestamptz' },
|
||||
{ name: 'facility', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'yard', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'zone', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'condition', type: 'text', isNullable: true },
|
||||
{ name: 'handover_note', type: 'text', isNullable: true },
|
||||
{ name: 'status', type: 'varchar', length: '40', default: "'RETURNED'" },
|
||||
{ name: 'wagon_allocation_reference', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'performed_by', type: 'varchar', length: '120', isNullable: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
|
||||
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_container', columnNames: ['container_number'] }));
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_booking', columnNames: ['booking_id'] }));
|
||||
await queryRunner.createIndex('freight.empty_container_returns', new TableIndex({ name: 'idx_empty_returns_status', columnNames: ['status'] }));
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.dropTable('freight.empty_container_returns', true);
|
||||
await queryRunner.dropTable('freight.import_customs_finalizations', true);
|
||||
await queryRunner.dropTable('freight.djibouti_import_incidents', true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsArray, IsDateString, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
import { DJIBOUTI_INCIDENT_TYPES, type DjiboutiIncidentType } from '../entities/djibouti-incident.entity';
|
||||
import {
|
||||
EMPTY_CONTAINER_RETURN_STATUSES,
|
||||
type EmptyContainerReturnStatus,
|
||||
} from '../entities/empty-container-return.entity';
|
||||
import {
|
||||
IMPORT_CUSTOMS_RISK_LEVELS,
|
||||
type ImportCustomsDocumentType,
|
||||
type ImportCustomsRiskLevel,
|
||||
} from '../entities/import-customs-finalization.entity';
|
||||
|
||||
export const IMPORT_CUSTOMS_DOCUMENT_TYPES = [
|
||||
'IM4',
|
||||
'IM5',
|
||||
'T1_CLOSURE_PROOF',
|
||||
'TRANSIT_PERMIT_SCREENSHOT',
|
||||
'CUSTOMER_PAYMENT_SLIP',
|
||||
'IMPORT_RELEASE_PERMIT',
|
||||
] as const;
|
||||
|
||||
export class CreateDjiboutiIncidentDto {
|
||||
@ApiProperty({ format: 'uuid' })
|
||||
@IsUUID()
|
||||
bookingId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
cargoId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
facility?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
station?: string;
|
||||
|
||||
@ApiProperty({ enum: DJIBOUTI_INCIDENT_TYPES })
|
||||
@IsIn(DJIBOUTI_INCIDENT_TYPES)
|
||||
incidentType!: DjiboutiIncidentType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
description!: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [String] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
photos?: string[];
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reportedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
reportedAt?: string;
|
||||
}
|
||||
|
||||
export class UploadImportCustomsDocumentDto {
|
||||
@ApiProperty({ enum: IMPORT_CUSTOMS_DOCUMENT_TYPES })
|
||||
@IsIn(IMPORT_CUSTOMS_DOCUMENT_TYPES)
|
||||
documentType!: ImportCustomsDocumentType;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
fileId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class RecordDeclarationDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
declarationSerialNumber!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class AssignCustomsRiskDto {
|
||||
@ApiProperty({ enum: IMPORT_CUSTOMS_RISK_LEVELS })
|
||||
@IsIn(IMPORT_CUSTOMS_RISK_LEVELS)
|
||||
risk!: ImportCustomsRiskLevel;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class ImportOperationActionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CreateEmptyContainerReturnDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
containerNumber!: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional({ format: 'uuid' })
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
returnDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
facility?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
yard?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
zone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
condition?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
handoverNote?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class UpdateEmptyContainerReturnStatusDto extends ImportOperationActionDto {
|
||||
@ApiProperty({ enum: EMPTY_CONTAINER_RETURN_STATUSES })
|
||||
@IsIn(EMPTY_CONTAINER_RETURN_STATUSES)
|
||||
status!: EmptyContainerReturnStatus;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
wagonAllocationReference?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
handoverNote?: string;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const DJIBOUTI_INCIDENT_TYPES = [
|
||||
'SEAL_BROKEN',
|
||||
'CONTAINER_OPENED',
|
||||
'CONTAINER_DAMAGED',
|
||||
'FLUID_LEAKING',
|
||||
'QUANTITY_MISMATCH',
|
||||
'WEIGHT_MISMATCH',
|
||||
'OTHER',
|
||||
] as const;
|
||||
|
||||
export type DjiboutiIncidentType = (typeof DJIBOUTI_INCIDENT_TYPES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'djibouti_import_incidents' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['containerNumber'])
|
||||
@Index(['incidentType'])
|
||||
export class DjiboutiIncident extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 80, nullable: true })
|
||||
containerNumber?: string | null;
|
||||
|
||||
@Column({ name: 'cargo_id', type: 'uuid', nullable: true })
|
||||
cargoId?: string | null;
|
||||
|
||||
@Column({ name: 'facility', type: 'varchar', length: 120, nullable: true })
|
||||
facility?: string | null;
|
||||
|
||||
@Column({ name: 'station', type: 'varchar', length: 120, nullable: true })
|
||||
station?: string | null;
|
||||
|
||||
@Column({ name: 'incident_type', type: 'varchar', length: 40 })
|
||||
incidentType!: DjiboutiIncidentType;
|
||||
|
||||
@Column({ name: 'description', type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Column({ name: 'photos', type: 'jsonb', default: () => "'[]'::jsonb" })
|
||||
photos!: string[];
|
||||
|
||||
@Column({ name: 'reported_by', type: 'varchar', length: 120, nullable: true })
|
||||
reportedBy?: string | null;
|
||||
|
||||
@Column({ name: 'reported_at', type: 'timestamptz' })
|
||||
reportedAt!: Date;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const EMPTY_CONTAINER_RETURN_STATUSES = [
|
||||
'RETURNED',
|
||||
'ASSIGNED_STORAGE',
|
||||
'DOCUMENTATION_CLEARED',
|
||||
'WAGON_ALLOCATED',
|
||||
'TRANSPORTED_TO_DJIBOUTI',
|
||||
'HANDOVER_ISSUED',
|
||||
'COMPLETED',
|
||||
] as const;
|
||||
|
||||
export type EmptyContainerReturnStatus = (typeof EMPTY_CONTAINER_RETURN_STATUSES)[number];
|
||||
|
||||
@Entity({ schema: 'freight', name: 'empty_container_returns' })
|
||||
@Index(['containerNumber'])
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
export class EmptyContainerReturn extends BaseEntity {
|
||||
@Column({ name: 'container_number', type: 'varchar', length: 80 })
|
||||
containerNumber!: string;
|
||||
|
||||
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
|
||||
bookingId?: string | null;
|
||||
|
||||
@Column({ name: 'customer_id', type: 'uuid', nullable: true })
|
||||
customerId?: string | null;
|
||||
|
||||
@Column({ name: 'return_date', type: 'timestamptz' })
|
||||
returnDate!: Date;
|
||||
|
||||
@Column({ name: 'facility', type: 'varchar', length: 120, nullable: true })
|
||||
facility?: string | null;
|
||||
|
||||
@Column({ name: 'yard', type: 'varchar', length: 120, nullable: true })
|
||||
yard?: string | null;
|
||||
|
||||
@Column({ name: 'zone', type: 'varchar', length: 120, nullable: true })
|
||||
zone?: string | null;
|
||||
|
||||
@Column({ name: 'condition', type: 'text', nullable: true })
|
||||
condition?: string | null;
|
||||
|
||||
@Column({ name: 'handover_note', type: 'text', nullable: true })
|
||||
handoverNote?: string | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 40, default: 'RETURNED' })
|
||||
status!: EmptyContainerReturnStatus;
|
||||
|
||||
@Column({ name: 'wagon_allocation_reference', type: 'varchar', length: 120, nullable: true })
|
||||
wagonAllocationReference?: string | null;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index } from 'typeorm';
|
||||
|
||||
export const IMPORT_CUSTOMS_RISK_LEVELS = ['GREEN', 'YELLOW', 'BLUE', 'RED'] as const;
|
||||
export type ImportCustomsRiskLevel = (typeof IMPORT_CUSTOMS_RISK_LEVELS)[number];
|
||||
|
||||
export type ImportCustomsDocumentType =
|
||||
| 'IM4'
|
||||
| 'IM5'
|
||||
| 'T1_CLOSURE_PROOF'
|
||||
| 'TRANSIT_PERMIT_SCREENSHOT'
|
||||
| 'CUSTOMER_PAYMENT_SLIP'
|
||||
| 'IMPORT_RELEASE_PERMIT';
|
||||
|
||||
@Entity({ schema: 'freight', name: 'import_customs_finalizations' })
|
||||
@Index(['bookingId'], { unique: true })
|
||||
@Index(['customsRisk'])
|
||||
export class ImportCustomsFinalization extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||
documents!: Partial<Record<ImportCustomsDocumentType, string>>;
|
||||
|
||||
@Column({ name: 'declaration_serial_number', type: 'varchar', length: 120, nullable: true })
|
||||
declarationSerialNumber?: string | null;
|
||||
|
||||
@Column({ name: 'duties_taxes_notified_at', type: 'timestamptz', nullable: true })
|
||||
dutiesTaxesNotifiedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'duties_taxes_paid_at', type: 'timestamptz', nullable: true })
|
||||
dutiesTaxesPaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'customs_risk', type: 'varchar', length: 12, nullable: true })
|
||||
customsRisk?: ImportCustomsRiskLevel | null;
|
||||
|
||||
@Column({ name: 'import_release_permitted_at', type: 'timestamptz', nullable: true })
|
||||
importReleasePermittedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
|
||||
completedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
AssignCustomsRiskDto,
|
||||
CreateDjiboutiIncidentDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
RecordDeclarationDto,
|
||||
UpdateEmptyContainerReturnStatusDto,
|
||||
UploadImportCustomsDocumentDto,
|
||||
} from './dto/import-operations.dto';
|
||||
import { ImportOperationsService } from './import-operations.service';
|
||||
|
||||
@ApiTags('import-operations')
|
||||
@ApiBearerAuth()
|
||||
@Controller('import-operations')
|
||||
export class ImportOperationsController {
|
||||
constructor(private readonly service: ImportOperationsService) {}
|
||||
|
||||
@Get('djibouti-incidents')
|
||||
@ApiOperation({ summary: 'Batch 8: list Djibouti import incidents' })
|
||||
listIncidents(@Query('bookingId') bookingId?: string) {
|
||||
return this.service.listIncidents(bookingId);
|
||||
}
|
||||
|
||||
@Post('djibouti-incidents')
|
||||
@ApiOperation({ summary: 'Batch 8: report a Djibouti import incident / exception' })
|
||||
createIncident(@Body() dto: CreateDjiboutiIncidentDto) {
|
||||
return this.service.createIncident(dto);
|
||||
}
|
||||
|
||||
@Get('customs/:bookingId')
|
||||
@ApiOperation({ summary: 'Batch 12: import customs finalization state' })
|
||||
getCustoms(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
|
||||
return this.service.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/documents')
|
||||
@ApiOperation({ summary: 'Batch 12: upload IM4/IM5/T1/permit/payment-slip documents' })
|
||||
uploadCustomsDocument(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: UploadImportCustomsDocumentDto,
|
||||
) {
|
||||
return this.service.uploadCustomsDocument(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/declaration')
|
||||
@ApiOperation({ summary: 'Batch 12: record declaration serial number' })
|
||||
recordDeclaration(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: RecordDeclarationDto,
|
||||
) {
|
||||
return this.service.recordDeclaration(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/notify-duties-taxes')
|
||||
@ApiOperation({ summary: 'Batch 12: notify duties and taxes' })
|
||||
notifyDutiesTaxes(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ImportOperationActionDto,
|
||||
) {
|
||||
return this.service.notifyDutiesTaxes(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/duties-taxes-paid')
|
||||
@ApiOperation({ summary: 'Batch 12: mark duties and taxes paid' })
|
||||
markDutiesTaxesPaid(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ImportOperationActionDto,
|
||||
) {
|
||||
return this.service.markDutiesTaxesPaid(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/risk')
|
||||
@ApiOperation({ summary: 'Batch 12: assign customs risk' })
|
||||
assignRisk(@Param('bookingId', ParseUUIDPipe) bookingId: string, @Body() dto: AssignCustomsRiskDto) {
|
||||
return this.service.assignRisk(bookingId, dto);
|
||||
}
|
||||
|
||||
@Post('customs/:bookingId/release-permitted')
|
||||
@ApiOperation({ summary: 'Batch 12: mark import release permitted' })
|
||||
markReleasePermitted(
|
||||
@Param('bookingId', ParseUUIDPipe) bookingId: string,
|
||||
@Body() dto: ImportOperationActionDto,
|
||||
) {
|
||||
return this.service.markReleasePermitted(bookingId, dto);
|
||||
}
|
||||
|
||||
@Get('empty-container-returns')
|
||||
@ApiOperation({ summary: 'Batch 16: list empty container returns' })
|
||||
listEmptyReturns() {
|
||||
return this.service.listEmptyReturns();
|
||||
}
|
||||
|
||||
@Post('empty-container-returns')
|
||||
@ApiOperation({ summary: 'Batch 16: create an empty container return record' })
|
||||
createEmptyReturn(@Body() dto: CreateEmptyContainerReturnDto) {
|
||||
return this.service.createEmptyReturn(dto);
|
||||
}
|
||||
|
||||
@Post('empty-container-returns/:id/status')
|
||||
@ApiOperation({ summary: 'Batch 16: advance empty container return workflow' })
|
||||
updateEmptyReturnStatus(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateEmptyContainerReturnStatusDto,
|
||||
) {
|
||||
return this.service.updateEmptyReturnStatus(id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { DjiboutiIncident } from './entities/djibouti-incident.entity';
|
||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||
import { ImportCustomsFinalization } from './entities/import-customs-finalization.entity';
|
||||
import { ImportOperationsController } from './import-operations.controller';
|
||||
import { ImportOperationsService } from './import-operations.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
DjiboutiIncident,
|
||||
ImportCustomsFinalization,
|
||||
EmptyContainerReturn,
|
||||
]),
|
||||
],
|
||||
controllers: [ImportOperationsController],
|
||||
providers: [ImportOperationsService],
|
||||
exports: [ImportOperationsService],
|
||||
})
|
||||
export class ImportOperationsModule {}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
CreateDjiboutiIncidentDto,
|
||||
CreateEmptyContainerReturnDto,
|
||||
ImportOperationActionDto,
|
||||
RecordDeclarationDto,
|
||||
AssignCustomsRiskDto,
|
||||
UpdateEmptyContainerReturnStatusDto,
|
||||
UploadImportCustomsDocumentDto,
|
||||
} from './dto/import-operations.dto';
|
||||
import {
|
||||
DjiboutiIncident,
|
||||
type DjiboutiIncidentType,
|
||||
} from './entities/djibouti-incident.entity';
|
||||
import { EmptyContainerReturn } from './entities/empty-container-return.entity';
|
||||
import {
|
||||
ImportCustomsFinalization,
|
||||
type ImportCustomsDocumentType,
|
||||
} from './entities/import-customs-finalization.entity';
|
||||
|
||||
const DAMAGE_INCIDENTS: DjiboutiIncidentType[] = [
|
||||
'SEAL_BROKEN',
|
||||
'CONTAINER_OPENED',
|
||||
'CONTAINER_DAMAGED',
|
||||
'FLUID_LEAKING',
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ImportOperationsService {
|
||||
constructor(
|
||||
@InjectRepository(DjiboutiIncident)
|
||||
private readonly incidents: Repository<DjiboutiIncident>,
|
||||
@InjectRepository(ImportCustomsFinalization)
|
||||
private readonly customs: Repository<ImportCustomsFinalization>,
|
||||
@InjectRepository(EmptyContainerReturn)
|
||||
private readonly emptyReturns: Repository<EmptyContainerReturn>,
|
||||
) {}
|
||||
|
||||
listIncidents(bookingId?: string) {
|
||||
return this.incidents.find({
|
||||
where: bookingId ? { bookingId } : {},
|
||||
order: { reportedAt: 'DESC', createdAt: 'DESC' } as never,
|
||||
});
|
||||
}
|
||||
|
||||
async createIncident(dto: CreateDjiboutiIncidentDto) {
|
||||
const photos = dto.photos ?? [];
|
||||
if (DAMAGE_INCIDENTS.includes(dto.incidentType) && photos.length === 0) {
|
||||
throw new BadRequestException('Photos are required for damage-related Djibouti incidents');
|
||||
}
|
||||
|
||||
const incident = await this.incidents.save(
|
||||
this.incidents.create({
|
||||
bookingId: dto.bookingId,
|
||||
containerNumber: dto.containerNumber ?? null,
|
||||
cargoId: dto.cargoId ?? null,
|
||||
facility: dto.facility ?? null,
|
||||
station: dto.station ?? null,
|
||||
incidentType: dto.incidentType,
|
||||
description: dto.description,
|
||||
photos,
|
||||
reportedBy: dto.reportedBy ?? null,
|
||||
reportedAt: dto.reportedAt ? new Date(dto.reportedAt) : new Date(),
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Djibouti incident ${incident.incidentType} for booking ${incident.bookingId}; notify Global Logistics Ethiopia and customer.`,
|
||||
);
|
||||
console.log(
|
||||
`[MOVEMENT] Attach incident ${incident.id} to booking ${incident.bookingId} movement history.`,
|
||||
);
|
||||
return incident;
|
||||
}
|
||||
|
||||
async getCustoms(bookingId: string) {
|
||||
return this.getOrCreateCustoms(bookingId);
|
||||
}
|
||||
|
||||
async uploadCustomsDocument(bookingId: string, dto: UploadImportCustomsDocumentDto) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
const documents = { ...(row.documents ?? {}), [dto.documentType]: dto.fileId };
|
||||
await this.customs.update(row.id, {
|
||||
documents,
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
});
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async recordDeclaration(bookingId: string, dto: RecordDeclarationDto) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
await this.customs.update(row.id, {
|
||||
declarationSerialNumber: dto.declarationSerialNumber,
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
});
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async notifyDutiesTaxes(bookingId: string, dto: ImportOperationActionDto = {}) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
await this.customs.update(row.id, {
|
||||
dutiesTaxesNotifiedAt: row.dutiesTaxesNotifiedAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
notes: dto.notes ?? row.notes ?? null,
|
||||
});
|
||||
console.log(`[NOTIFY] Duties and taxes notification sent for booking ${bookingId}.`);
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async markDutiesTaxesPaid(bookingId: string, dto: ImportOperationActionDto = {}) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
this.assertDocument(row, 'CUSTOMER_PAYMENT_SLIP', 'Customer payment slip is required before marking duties and taxes paid');
|
||||
await this.customs.update(row.id, {
|
||||
dutiesTaxesPaidAt: row.dutiesTaxesPaidAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
notes: dto.notes ?? row.notes ?? null,
|
||||
});
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async assignRisk(bookingId: string, dto: AssignCustomsRiskDto) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
await this.customs.update(row.id, {
|
||||
customsRisk: dto.risk,
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
});
|
||||
console.log(`[NOTIFY] Customs risk ${dto.risk} assigned for booking ${bookingId}; notify customer.`);
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
async markReleasePermitted(bookingId: string, dto: ImportOperationActionDto = {}) {
|
||||
const row = await this.getOrCreateCustoms(bookingId);
|
||||
this.assertReleaseReady(row);
|
||||
await this.customs.update(row.id, {
|
||||
importReleasePermittedAt: row.importReleasePermittedAt ?? new Date(),
|
||||
completedAt: row.completedAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
notes: dto.notes ?? row.notes ?? null,
|
||||
});
|
||||
console.log(`[NOTIFY] Import release permitted for booking ${bookingId}; notify customer.`);
|
||||
return this.getCustoms(bookingId);
|
||||
}
|
||||
|
||||
listEmptyReturns() {
|
||||
return this.emptyReturns.find({ order: { createdAt: 'DESC' } as never });
|
||||
}
|
||||
|
||||
async createEmptyReturn(dto: CreateEmptyContainerReturnDto) {
|
||||
return this.emptyReturns.save(
|
||||
this.emptyReturns.create({
|
||||
containerNumber: dto.containerNumber,
|
||||
bookingId: dto.bookingId ?? null,
|
||||
customerId: dto.customerId ?? null,
|
||||
returnDate: dto.returnDate ? new Date(dto.returnDate) : new Date(),
|
||||
facility: dto.facility ?? null,
|
||||
yard: dto.yard ?? null,
|
||||
zone: dto.zone ?? null,
|
||||
condition: dto.condition ?? null,
|
||||
handoverNote: dto.handoverNote ?? null,
|
||||
performedBy: dto.performedBy ?? null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async updateEmptyReturnStatus(id: string, dto: UpdateEmptyContainerReturnStatusDto) {
|
||||
const row = await this.emptyReturns.findOne({ where: { id } });
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Empty container return ${id} not found`);
|
||||
}
|
||||
await this.emptyReturns.update(id, {
|
||||
status: dto.status,
|
||||
wagonAllocationReference: dto.wagonAllocationReference ?? row.wagonAllocationReference ?? null,
|
||||
handoverNote: dto.handoverNote ?? row.handoverNote ?? null,
|
||||
performedBy: dto.performedBy ?? row.performedBy ?? null,
|
||||
});
|
||||
return this.emptyReturns.findOneOrFail({ where: { id } });
|
||||
}
|
||||
|
||||
private async getOrCreateCustoms(bookingId: string) {
|
||||
const existing = await this.customs.findOne({ where: { bookingId } });
|
||||
if (existing) return existing;
|
||||
return this.customs.save(this.customs.create({ bookingId, documents: {} }));
|
||||
}
|
||||
|
||||
private assertDocument(
|
||||
row: ImportCustomsFinalization,
|
||||
type: ImportCustomsDocumentType,
|
||||
message: string,
|
||||
) {
|
||||
if (!row.documents?.[type]) {
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private assertReleaseReady(row: ImportCustomsFinalization) {
|
||||
this.assertDocument(row, 'T1_CLOSURE_PROOF', 'T1 closure proof is required before import release');
|
||||
this.assertDocument(row, 'IMPORT_RELEASE_PERMIT', 'Import release permit upload is required before release is permitted');
|
||||
if (!row.declarationSerialNumber?.trim()) {
|
||||
throw new BadRequestException('Declaration serial number is required before import release');
|
||||
}
|
||||
if (!row.customsRisk) {
|
||||
throw new BadRequestException('Customs risk must be assigned before import release');
|
||||
}
|
||||
if (!row.dutiesTaxesPaidAt) {
|
||||
throw new BadRequestException('Duties and taxes must be paid before import release');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -265,12 +265,12 @@ export class InterchangeDocumentsService {
|
||||
)
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
'CONTAINER' AS "itemType",
|
||||
'CONTAINER'::varchar AS "itemType",
|
||||
COALESCE(c.booking_container_id, bc.id) AS "bookingContainerId",
|
||||
NULL AS "bookingCargoId",
|
||||
NULL::uuid AS "bookingCargoId",
|
||||
COALESCE(c.container_number, bc.container_number) AS "containerNumber",
|
||||
c.seal_number AS "sealNumber",
|
||||
NULL AS "cargoId",
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
COALESCE(bc.total_vgm_tons, c.max_gross_weight, a.cargo_total_weight_vgm) AS "weight",
|
||||
@@ -288,12 +288,12 @@ export class InterchangeDocumentsService {
|
||||
UNION ALL
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
'CONTAINER' AS "itemType",
|
||||
'CONTAINER'::varchar AS "itemType",
|
||||
bc.id AS "bookingContainerId",
|
||||
NULL AS "bookingCargoId",
|
||||
NULL::uuid AS "bookingCargoId",
|
||||
bc.container_number AS "containerNumber",
|
||||
NULL AS "sealNumber",
|
||||
NULL AS "cargoId",
|
||||
NULL::varchar AS "sealNumber",
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
COALESCE(bc.total_vgm_tons, a.cargo_total_weight_vgm) AS "weight",
|
||||
@@ -314,11 +314,11 @@ export class InterchangeDocumentsService {
|
||||
UNION ALL
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
'CARGO' AS "itemType",
|
||||
NULL AS "bookingContainerId",
|
||||
'CARGO'::varchar AS "itemType",
|
||||
NULL::uuid AS "bookingContainerId",
|
||||
cg.id AS "bookingCargoId",
|
||||
NULL AS "containerNumber",
|
||||
NULL AS "sealNumber",
|
||||
NULL::varchar AS "containerNumber",
|
||||
NULL::varchar AS "sealNumber",
|
||||
cg.id AS "cargoId",
|
||||
COALESCE(cgt.cargo_type_name, a.booking_cargo_type) AS "cargoType",
|
||||
COALESCE(cg.description, a.cargo_free_text) AS "cargoDescription",
|
||||
@@ -337,12 +337,12 @@ export class InterchangeDocumentsService {
|
||||
UNION ALL
|
||||
SELECT a.booking_id AS "bookingId",
|
||||
a.reference AS "bookingReference",
|
||||
CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END AS "itemType",
|
||||
NULL AS "bookingContainerId",
|
||||
NULL AS "bookingCargoId",
|
||||
NULL AS "containerNumber",
|
||||
NULL AS "sealNumber",
|
||||
NULL AS "cargoId",
|
||||
(CASE WHEN a.booking_cargo_type ILIKE '%container%' THEN 'CONTAINER' ELSE 'CARGO' END)::varchar AS "itemType",
|
||||
NULL::uuid AS "bookingContainerId",
|
||||
NULL::uuid AS "bookingCargoId",
|
||||
NULL::varchar AS "containerNumber",
|
||||
NULL::varchar AS "sealNumber",
|
||||
NULL::uuid AS "cargoId",
|
||||
a.booking_cargo_type AS "cargoType",
|
||||
a.cargo_free_text AS "cargoDescription",
|
||||
a.cargo_total_weight_vgm AS "weight",
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [
|
||||
'DELIVERY_ORDER',
|
||||
'PORT_INVOICE',
|
||||
'DJIBOUTI_T1',
|
||||
'ETHIOPIA_T1',
|
||||
'RAILWAY_BILL',
|
||||
] as const;
|
||||
|
||||
export type ImportDjiboutiDocumentType = (typeof IMPORT_DJIBOUTI_DOCUMENT_TYPES)[number];
|
||||
|
||||
export class UploadImportDjiboutiDocumentDto {
|
||||
@ApiProperty({ enum: IMPORT_DJIBOUTI_DOCUMENT_TYPES })
|
||||
@IsIn(IMPORT_DJIBOUTI_DOCUMENT_TYPES)
|
||||
documentType!: ImportDjiboutiDocumentType;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
fileUrl?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export class ImportDjiboutiActionDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
|
||||
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| 'DELIVERY_ORDER'
|
||||
| 'PORT_INVOICE'
|
||||
| 'DJIBOUTI_T1'
|
||||
| 'ETHIOPIA_T1'
|
||||
| 'RAILWAY_BILL';
|
||||
|
||||
export interface ImportDjiboutiDocumentRecord {
|
||||
fileId?: string | null;
|
||||
fileUrl?: string | null;
|
||||
reference?: string | null;
|
||||
uploadedAt: string;
|
||||
uploadedBy?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
@Entity({ schema: 'freight', name: 'import_djibouti_operations' })
|
||||
@Index(['trainScheduleId'], { unique: true })
|
||||
export class ImportDjiboutiOperation extends BaseEntity {
|
||||
@Column({ name: 'train_schedule_id', type: 'uuid' })
|
||||
trainScheduleId!: string;
|
||||
|
||||
@OneToOne(() => TrainSchedule)
|
||||
@JoinColumn({ name: 'train_schedule_id' })
|
||||
trainSchedule?: TrainSchedule;
|
||||
|
||||
@Column({ name: 'documents', type: 'jsonb', default: () => "'{}'::jsonb" })
|
||||
documents!: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||
|
||||
@Column({ name: 'gatepass_granted_at', type: 'timestamptz', nullable: true })
|
||||
gatepassGrantedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'ready_for_loading_at', type: 'timestamptz', nullable: true })
|
||||
readyForLoadingAt?: Date | null;
|
||||
|
||||
@Column({ name: 'loaded_on_train_at', type: 'timestamptz', nullable: true })
|
||||
loadedOnTrainAt?: Date | null;
|
||||
|
||||
@Column({ name: 'departed_from_djibouti_at', type: 'timestamptz', nullable: true })
|
||||
departedFromDjiboutiAt?: Date | null;
|
||||
|
||||
@Column({ name: 'load_list_generated_at', type: 'timestamptz', nullable: true })
|
||||
loadListGeneratedAt?: Date | null;
|
||||
|
||||
@Column({ name: 'performed_by', type: 'varchar', length: 120, nullable: true })
|
||||
performedBy?: string | null;
|
||||
|
||||
@Column({ name: 'notes', type: 'text', nullable: true })
|
||||
notes?: string | null;
|
||||
}
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
} from "@nestjs/common";
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
import type { Response } from "express";
|
||||
import type { AuthUserPayload } from "../../common/resolve-auth-user-id";
|
||||
import { resolveAuthUserId } from "../../common/resolve-auth-user-id";
|
||||
|
||||
@@ -30,6 +32,10 @@ import { PreviewBulkTrainScheduleDto } from "./dto/preview-bulk-train-schedule.d
|
||||
import { PreviewContainerTrainScheduleDto } from "./dto/preview-container-train-schedule.dto";
|
||||
import { PreviewTrainScheduleDto } from "./dto/preview-train-schedule.dto";
|
||||
import { RecordCheckpointDto } from "./dto/record-checkpoint.dto";
|
||||
import {
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from "./dto/import-djibouti-operation.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";
|
||||
@@ -304,6 +310,101 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.dispatchSchedule(id);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/import-djibouti")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Batch 7 import Djibouti gatepass/loading status" })
|
||||
getImportDjiboutiOperation(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.trainSchedulingService.getImportDjiboutiOperation(id);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/documents")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Upload/check an import Djibouti-side document" })
|
||||
uploadImportDjiboutiDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UploadImportDjiboutiDocumentDto,
|
||||
) {
|
||||
return this.trainSchedulingService.uploadImportDjiboutiDocument(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/gatepass-granted")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Mark import Djibouti gatepass permission granted" })
|
||||
grantImportDjiboutiGatepass(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.grantImportDjiboutiGatepass(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/ready-for-loading")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Mark import train ready for loading at Djibouti" })
|
||||
markImportReadyForLoading(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.markImportReadyForLoading(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/loaded-on-train")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Confirm import cargo loaded on train at Djibouti" })
|
||||
confirmImportLoadedOnTrain(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.confirmImportLoadedOnTrain(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/depart")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Depart loaded import train from Djibouti" })
|
||||
departImportFromDjibouti(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.departImportFromDjibouti(id, dto);
|
||||
}
|
||||
|
||||
@Post("schedules/:id/import-djibouti/load-list")
|
||||
@TrainSchedulingManage()
|
||||
@ApiOperation({ summary: "Generate import load list / marshalling document summary" })
|
||||
generateImportLoadList(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: ImportDjiboutiActionDto,
|
||||
) {
|
||||
return this.trainSchedulingService.generateImportLoadList(id, dto);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/import-djibouti/load-list/document")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Download printable import load list / marshalling PDF" })
|
||||
async importLoadListDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.trainSchedulingService.importLoadListDocument(id);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
@Get("schedules/:id/export/load-list/document")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Download printable export marshalling / load list PDF" })
|
||||
async exportLoadListDocument(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { filename, buffer } = await this.trainSchedulingService.exportLoadListDocument(id);
|
||||
res.setHeader("Content-Type", "application/pdf");
|
||||
res.setHeader("Content-Disposition", `inline; filename="${filename}"`);
|
||||
res.setHeader("Content-Length", buffer.length);
|
||||
return res.send(buffer);
|
||||
}
|
||||
|
||||
// ---- batch / booking-window staff actions ----
|
||||
|
||||
@Post("schedules/:id/run-batch")
|
||||
|
||||
@@ -17,6 +17,7 @@ import { WagonTypesModule } from '../wagon-types/wagon-types.module';
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WarehousesModule } from '../warehouses/warehouses.module';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { ImportDjiboutiOperation } from './entities/import-djibouti-operation.entity';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import { TrainCheckpointEventsRepository } from './train-checkpoint-events.repository';
|
||||
import { TrainSchedulingController } from './train-scheduling.controller';
|
||||
@@ -38,6 +39,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
Container,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainCheckpointEvent,
|
||||
ImportDjiboutiOperation,
|
||||
]),
|
||||
forwardRef(() => BookingsModule),
|
||||
NotificationsModule,
|
||||
|
||||
@@ -151,6 +151,9 @@ describe('TrainSchedulingService', () => {
|
||||
autoUnloadArrivedBookings: jest.fn(),
|
||||
autoUnloadExportAtDjibouti: jest.fn(),
|
||||
} as never,
|
||||
{
|
||||
htmlToPdfBuffer: jest.fn(),
|
||||
} as never,
|
||||
);
|
||||
|
||||
const defaultFleetWagons = [
|
||||
|
||||
@@ -49,6 +49,15 @@ import { PreviewBulkTrainScheduleDto } from './dto/preview-bulk-train-schedule.d
|
||||
import { PreviewContainerTrainScheduleDto } from './dto/preview-container-train-schedule.dto';
|
||||
import { PreviewTrainScheduleDto } from './dto/preview-train-schedule.dto';
|
||||
import { TrainSchedulingGlobalRules } from './entities/train-scheduling-global-rules.entity';
|
||||
import {
|
||||
ImportDjiboutiOperation,
|
||||
type ImportDjiboutiDocumentType,
|
||||
} from './entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from './dto/import-djibouti-operation.dto';
|
||||
import { UpdateTrainSchedulingGlobalRulesDto } from './dto/update-train-scheduling-global-rules.dto';
|
||||
import {
|
||||
buildCappedWagonPlan,
|
||||
@@ -97,6 +106,7 @@ import { RecordCheckpointDto } from './dto/record-checkpoint.dto';
|
||||
import { RouteMilestone } from '../routes/entities/route-milestone.entity';
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
import { WarehouseInventoryService } from '../warehouses/warehouse-inventory.service';
|
||||
import { WarehouseReleaseDocumentService } from '../warehouses/warehouse-release-document.service';
|
||||
import {
|
||||
autoFillPlacements,
|
||||
findMissingContainerNumberIssues,
|
||||
@@ -170,6 +180,7 @@ export class TrainSchedulingService {
|
||||
private readonly trainCheckpointEventsRepository: TrainCheckpointEventsRepository,
|
||||
private readonly trainCompositionRemovalLogRepository: TrainCompositionRemovalLogRepository,
|
||||
private readonly warehouseInventoryService: WarehouseInventoryService,
|
||||
private readonly pdfDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly configService?: ConfigService,
|
||||
) {}
|
||||
|
||||
@@ -767,6 +778,7 @@ export class TrainSchedulingService {
|
||||
if (schedule.status !== TrainScheduleStatusEnum.Scheduled) {
|
||||
throw new BadRequestException('Only SCHEDULED trains can be dispatched');
|
||||
}
|
||||
await this.assertImportDjiboutiMayDepart(schedule);
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
@@ -806,9 +818,555 @@ export class TrainSchedulingService {
|
||||
.execute();
|
||||
});
|
||||
|
||||
if (this.isImportDjiboutiSchedule(schedule)) {
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(schedule.id);
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? now,
|
||||
});
|
||||
console.log(
|
||||
`[NOTIFY] Import train ${schedule.trainNumber ?? schedule.id} departed Djibouti; notify Ethiopian operations, Global Logistics Ethiopia, Marketing/BD, and customer.`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.getTrainScheduleById(scheduleId);
|
||||
}
|
||||
|
||||
async getImportDjiboutiOperation(scheduleId: string) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
return this.mapImportDjiboutiOperation(schedule, operation);
|
||||
}
|
||||
|
||||
async uploadImportDjiboutiDocument(
|
||||
scheduleId: string,
|
||||
dto: UploadImportDjiboutiDocumentDto,
|
||||
) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const documents = {
|
||||
...(operation.documents ?? {}),
|
||||
[dto.documentType]: {
|
||||
fileId: dto.fileId ?? null,
|
||||
fileUrl: dto.fileUrl ?? null,
|
||||
reference: dto.reference ?? null,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
uploadedBy: dto.performedBy ?? null,
|
||||
notes: dto.notes ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
documents,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const missing = this.missingImportDjiboutiDocuments(operation);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`,
|
||||
);
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async markImportReadyForLoading(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async confirmImportLoadedOnTrain(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? new Date(),
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async departImportFromDjibouti(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
if (!operation.loadedOnTrainAt) {
|
||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
||||
}
|
||||
|
||||
if (schedule.status === TrainScheduleStatusEnum.Scheduled) {
|
||||
await this.dispatchSchedule(schedule.id);
|
||||
} else if (schedule.status !== TrainScheduleStatusEnum.Dispatched) {
|
||||
throw new BadRequestException('Only SCHEDULED or DISPATCHED import trains can be departed from Djibouti');
|
||||
}
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? new Date(),
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
|
||||
async generateImportLoadList(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const generatedAt = operation.loadListGeneratedAt ?? new Date();
|
||||
|
||||
await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
|
||||
loadListGeneratedAt: generatedAt,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt: generatedAt.toISOString(),
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
route: schedule.route?.name ?? null,
|
||||
origin: schedule.originStation?.label ?? schedule.originStation?.code ?? null,
|
||||
destination: schedule.destinationStation?.label ?? schedule.destinationStation?.code ?? null,
|
||||
totalBookings: schedule.scheduleBookings?.length ?? 0,
|
||||
wagons: (schedule.trainSet?.wagons ?? []).map((wagon) => ({
|
||||
sequenceNo: wagon.sequenceNo,
|
||||
wagonNumber: wagon.physicalWagon?.wagonNumber ?? null,
|
||||
allocations: (wagon.allocations ?? []).map((allocation) => ({
|
||||
bookingId: allocation.bookingId,
|
||||
bookingReference: allocation.booking?.reference ?? null,
|
||||
loadType: allocation.loadType ?? null,
|
||||
allocatedWeightTons: Number(allocation.allocatedWeightTons) || 0,
|
||||
containerNumbers: (allocation.containerItems ?? [])
|
||||
.map((item) => item.containerNumber)
|
||||
.filter(Boolean),
|
||||
})),
|
||||
})),
|
||||
operation: await this.getImportDjiboutiOperation(schedule.id),
|
||||
};
|
||||
}
|
||||
|
||||
async importLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const loadList = await this.generateImportLoadList(scheduleId, {
|
||||
performedBy: 'DOCUMENT_GENERATION',
|
||||
});
|
||||
const html = this.buildImportLoadListHtml(loadList);
|
||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
||||
const reference = loadList.trainNumber ?? loadList.trainScheduleId;
|
||||
return {
|
||||
filename: `import-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
async exportLoadListDocument(scheduleId: string): Promise<{ filename: string; buffer: Buffer }> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isExportSchedule(schedule)) {
|
||||
throw new BadRequestException('Export marshalling document applies only to EXPORT schedules');
|
||||
}
|
||||
|
||||
const html = this.buildExportLoadListHtml(schedule);
|
||||
const buffer = await this.pdfDocuments.htmlToPdfBuffer(html);
|
||||
const reference = schedule.trainNumber ?? schedule.id;
|
||||
return {
|
||||
filename: `export-marshalling-${this.safeDocumentName(reference)}.pdf`,
|
||||
buffer,
|
||||
};
|
||||
}
|
||||
|
||||
private buildExportLoadListHtml(schedule: TrainSchedule): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleDateString('en-GB') : '-');
|
||||
const time = (value: unknown) => (value ? new Date(value as string | Date).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '-');
|
||||
const bookingById = new Map((schedule.scheduleBookings ?? []).map((link) => [link.bookingId, link.booking]));
|
||||
const rows = (schedule.trainSet?.wagons ?? [])
|
||||
.flatMap((wagon) =>
|
||||
(wagon.allocations ?? []).map((allocation) => {
|
||||
const booking = allocation.booking ?? bookingById.get(allocation.bookingId);
|
||||
const company = booking?.company as Record<string, unknown> | null | undefined;
|
||||
const cargoType = (booking as unknown as { cargoType?: { name?: string; code?: string } } | undefined)?.cargoType;
|
||||
const containerItems = allocation.containerItems ?? [];
|
||||
const firstContainer = containerItems[0];
|
||||
const containerNumbers = containerItems.map((item) => item.containerNumber).filter(Boolean).join(', ');
|
||||
const sealNumbers = containerItems.map((item) => item.sealNumber).filter(Boolean).join(', ');
|
||||
const chassisNumbers = containerItems.map((item) => item.chassisNumber).filter(Boolean).join(', ');
|
||||
return `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.physicalWagon?.wagonNumber)}</td>
|
||||
<td>${esc(wagon.wagonType?.code ?? wagon.wagonType?.name)}</td>
|
||||
<td class="num">${esc(Number(wagon.lengthMeters || 0).toFixed(3))}</td>
|
||||
<td class="num">${esc(Number(wagon.physicalWagon?.tareWeight ?? 0).toFixed(2))}</td>
|
||||
<td class="num">${esc(Number(wagon.capacityTons || 0).toFixed(3))}</td>
|
||||
<td>${esc(company?.name ?? company?.legalName ?? company?.tradeName ?? booking?.companyId)}</td>
|
||||
<td>${esc(booking?.companyId)}</td>
|
||||
<td>${esc(cargoType?.name ?? cargoType?.code ?? allocation.loadType)}</td>
|
||||
<td>${esc(containerNumbers || firstContainer?.containerNumber)}</td>
|
||||
<td>${esc(chassisNumbers)}</td>
|
||||
<td>${esc(sealNumbers)}</td>
|
||||
</tr>`;
|
||||
}),
|
||||
)
|
||||
.join('');
|
||||
const totalWeight = (schedule.trainSet?.wagons ?? []).reduce(
|
||||
(sum, wagon) =>
|
||||
sum + (wagon.allocations ?? []).reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Export Marshalling Document</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 10mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.top { display: flex; justify-content: space-between; border-bottom: 3px solid #0f766e; padding-bottom: 10px; gap: 24px; }
|
||||
.brand { font-size: 11px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 6px 0 0; font-size: 25px; line-height: 1.05; }
|
||||
.meta { text-align: right; font-size: 11px; color: #475569; min-width: 210px; }
|
||||
.meta strong { display: block; margin-top: 4px; color: #0f172a; font-size: 15px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin: 14px 0; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 8px; min-height: 50px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 9px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.tile strong { font-size: 11px; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 5px 6px; font-size: 9.5px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.notice { margin-top: 10px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 8px 10px; font-size: 10px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-top: 34px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 7px; font-size: 9px; color: #475569; min-height: 34px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Export Marshalling Document / Load List</h1>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Train / Schedule
|
||||
<strong>${esc(schedule.trainNumber ?? schedule.id)}</strong>
|
||||
Generated: ${esc(new Date().toLocaleString('en-GB'))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Train ID</span><strong>${esc(schedule.trainNumber ?? schedule.id)}</strong></div>
|
||||
<div class="tile"><span>Departure date</span><strong>${esc(date(schedule.scheduledDepartureDate))}</strong></div>
|
||||
<div class="tile"><span>Departure time</span><strong>${esc(time(schedule.scheduledDepartureDate))}</strong></div>
|
||||
<div class="tile"><span>Departure station</span><strong>${esc(schedule.originStation?.label ?? schedule.originStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Arrival station</span><strong>${esc(schedule.destinationStation?.label ?? schedule.destinationStation?.code)}</strong></div>
|
||||
<div class="tile"><span>Total loaded weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Prepared person</span><strong>${esc(schedule.preparedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Check person</span><strong>${esc(schedule.checkedByUserId)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(schedule.trainSet?.wagons?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Bookings</span><strong>${esc(schedule.scheduleBookings?.length ?? 0)}</strong></div>
|
||||
<div class="tile"><span>Status</span><strong>${esc(schedule.status)}</strong></div>
|
||||
<div class="tile"><span>Direction</span><strong>${esc(schedule.direction)}</strong></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seq</th>
|
||||
<th>Wagon No</th>
|
||||
<th>Wagon Type</th>
|
||||
<th class="num">Equated Length</th>
|
||||
<th class="num">Tare Weight</th>
|
||||
<th class="num">Load Capacity</th>
|
||||
<th>Customer Name</th>
|
||||
<th>Customer ID</th>
|
||||
<th>Cargo Type</th>
|
||||
<th>Container No</th>
|
||||
<th>Chassis No</th>
|
||||
<th>Seal No</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows || '<tr><td colspan="12">No wagon allocations found for this export train.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
Loading and dispatch staff must verify wagon identity, seal number, container number,
|
||||
cargo type, and customer booking against the physical consist before departure.
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Prepared person / date</div>
|
||||
<div class="line">Check person / date</div>
|
||||
<div class="line">Operations authorization / date</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private isExportSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
return direction === 'EXPORT';
|
||||
}
|
||||
|
||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
const date = (value: unknown) => (value ? new Date(value as string | Date).toLocaleString('en-GB') : '-');
|
||||
const status = loadList.operation.status;
|
||||
const totalAllocations = loadList.wagons.reduce((sum, wagon) => sum + wagon.allocations.length, 0);
|
||||
const totalWeight = loadList.wagons.reduce(
|
||||
(sum, wagon) =>
|
||||
sum + wagon.allocations.reduce((wagonSum, allocation) => wagonSum + Number(allocation.allocatedWeightTons || 0), 0),
|
||||
0,
|
||||
);
|
||||
const allocationRows = loadList.wagons
|
||||
.flatMap((wagon) =>
|
||||
wagon.allocations.map(
|
||||
(allocation) => `<tr>
|
||||
<td>${esc(wagon.sequenceNo)}</td>
|
||||
<td>${esc(wagon.wagonNumber)}</td>
|
||||
<td>${esc(allocation.bookingReference ?? allocation.bookingId)}</td>
|
||||
<td>${esc(allocation.loadType)}</td>
|
||||
<td>${esc(allocation.containerNumbers.length ? allocation.containerNumbers.join(', ') : '-')}</td>
|
||||
<td class="num">${esc(Number(allocation.allocatedWeightTons || 0).toFixed(3))}</td>
|
||||
</tr>`,
|
||||
),
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Import Load List / Marshalling Document</title>
|
||||
<style>
|
||||
@page { size: A4; margin: 14mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; color: #0f172a; font-family: Arial, sans-serif; }
|
||||
.doc { min-height: 100vh; position: relative; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 3px solid #0f766e; padding-bottom: 14px; }
|
||||
.brand { font-size: 12px; color: #475569; text-transform: uppercase; letter-spacing: .08em; font-weight: 700; }
|
||||
h1 { margin: 8px 0 0; font-size: 28px; line-height: 1.05; }
|
||||
.subtitle { margin-top: 6px; color: #64748b; font-size: 12px; }
|
||||
.meta { text-align: right; font-size: 12px; color: #475569; min-width: 190px; }
|
||||
.meta strong { display: block; margin-top: 5px; color: #0f172a; font-size: 16px; }
|
||||
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 18px; }
|
||||
.tile { border: 1px solid #cbd5e1; padding: 10px; min-height: 58px; }
|
||||
.tile span { display: block; color: #64748b; font-size: 10px; text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
|
||||
.tile strong { font-size: 13px; }
|
||||
.status { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; margin-top: 14px; }
|
||||
.step { border: 1px solid #cbd5e1; padding: 8px; font-size: 10px; text-align: center; min-height: 48px; }
|
||||
.done { background: #ecfdf5; border-color: #22c55e; color: #14532d; font-weight: 700; }
|
||||
.pending { background: #f8fafc; color: #64748b; }
|
||||
h2 { margin: 22px 0 8px; font-size: 14px; color: #0f766e; text-transform: uppercase; letter-spacing: .06em; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { background: #f8fafc; color: #475569; text-align: left; }
|
||||
th, td { border: 1px solid #cbd5e1; padding: 7px 8px; font-size: 11px; vertical-align: top; }
|
||||
.num { text-align: right; }
|
||||
.notice { margin-top: 16px; border-left: 4px solid #0f766e; background: #f0fdfa; padding: 10px 12px; font-size: 11px; color: #134e4a; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 22px; margin-top: 44px; }
|
||||
.line { border-top: 1px solid #334155; padding-top: 8px; font-size: 10px; color: #475569; min-height: 42px; }
|
||||
.footer { position: fixed; left: 0; right: 0; bottom: 0; color: #64748b; font-size: 9px; border-top: 1px solid #e2e8f0; padding-top: 6px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="doc">
|
||||
<div class="top">
|
||||
<div>
|
||||
<div class="brand">Ethio-Djibouti Railway S.C.</div>
|
||||
<h1>Import Load List /<br />Marshalling Document</h1>
|
||||
<div class="subtitle">Djibouti-side gatepass, loading, and departure manifest</div>
|
||||
</div>
|
||||
<div class="meta">
|
||||
Train / Schedule
|
||||
<strong>${esc(loadList.trainNumber ?? loadList.trainScheduleId)}</strong>
|
||||
Generated: ${esc(date(loadList.generatedAt))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="tile"><span>Route</span><strong>${esc(loadList.route)}</strong></div>
|
||||
<div class="tile"><span>Origin</span><strong>${esc(loadList.origin)}</strong></div>
|
||||
<div class="tile"><span>Destination</span><strong>${esc(loadList.destination)}</strong></div>
|
||||
<div class="tile"><span>Total bookings</span><strong>${esc(loadList.totalBookings)}</strong></div>
|
||||
<div class="tile"><span>Wagons</span><strong>${esc(loadList.wagons.length)}</strong></div>
|
||||
<div class="tile"><span>Allocations</span><strong>${esc(totalAllocations)}</strong></div>
|
||||
<div class="tile"><span>Total weight</span><strong>${esc(totalWeight.toFixed(3))} T</strong></div>
|
||||
<div class="tile"><span>Gatepass granted</span><strong>${esc(date(loadList.operation.gatepassGrantedAt))}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="status">
|
||||
<div class="step ${status.documentsComplete ? 'done' : 'pending'}">Documents</div>
|
||||
<div class="step ${status.gatepassGranted ? 'done' : 'pending'}">Gatepass</div>
|
||||
<div class="step ${status.readyForLoading ? 'done' : 'pending'}">Ready</div>
|
||||
<div class="step ${status.loadedOnTrain ? 'done' : 'pending'}">Loaded</div>
|
||||
<div class="step ${status.departedFromDjibouti ? 'done' : 'pending'}">Departed</div>
|
||||
<div class="step ${status.loadListGenerated ? 'done' : 'pending'}">Document</div>
|
||||
</div>
|
||||
|
||||
<h2>Wagon Marshalling Allocation</h2>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Seq</th>
|
||||
<th>Wagon</th>
|
||||
<th>Booking</th>
|
||||
<th>Load</th>
|
||||
<th>Container numbers</th>
|
||||
<th class="num">Weight T</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${allocationRows || '<tr><td colspan="6">No wagon allocations found for this train.</td></tr>'}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="notice">
|
||||
Gate and loading staff must verify this document against the granted gatepass,
|
||||
railway bill, T1 documents, wagon placement, container numbers, and physical train consist before departure.
|
||||
</div>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="line">Prepared by Djibouti operations</div>
|
||||
<div class="line">Train loading supervisor</div>
|
||||
<div class="line">EDR operations authorization</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
System generated marshalling document. Schedule ID: ${esc(loadList.trainScheduleId)}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private safeDocumentName(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
||||
}
|
||||
|
||||
private async assertImportDjiboutiMayDepart(schedule: TrainSchedule): Promise<void> {
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) return;
|
||||
const operation = await this.dataSource.getRepository(ImportDjiboutiOperation).findOne({
|
||||
where: { trainScheduleId: schedule.id },
|
||||
});
|
||||
this.assertImportDjiboutiGatepassGranted(operation);
|
||||
if (!operation?.loadedOnTrainAt) {
|
||||
throw new BadRequestException('Import train cannot depart Djibouti before loading is confirmed');
|
||||
}
|
||||
}
|
||||
|
||||
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
|
||||
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||||
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
|
||||
(schedule.originStation && schedule.destinationStation
|
||||
? deriveScheduleDirection(schedule.originStation, schedule.destinationStation)
|
||||
: null);
|
||||
return (
|
||||
direction === 'IMPORT' &&
|
||||
this.isDjiboutiPortDestination(
|
||||
`${schedule.originStation?.code ?? ''} ${schedule.originStation?.label ?? ''}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
|
||||
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
if (existing) return existing;
|
||||
return repo.save(repo.create({ trainScheduleId: scheduleId, documents: {} }));
|
||||
}
|
||||
|
||||
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
|
||||
const documents = operation?.documents ?? {};
|
||||
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]);
|
||||
}
|
||||
|
||||
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
|
||||
if (!operation?.gatepassGrantedAt) {
|
||||
throw new BadRequestException('Import loading is blocked until Djibouti gatepass is granted');
|
||||
}
|
||||
}
|
||||
|
||||
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
|
||||
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
|
||||
return {
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
direction: schedule.direction ?? null,
|
||||
status: {
|
||||
documentsComplete: missingDocuments.length === 0,
|
||||
missingDocuments,
|
||||
gatepassGranted: Boolean(operation.gatepassGrantedAt),
|
||||
readyForLoading: Boolean(operation.readyForLoadingAt),
|
||||
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
|
||||
departedFromDjibouti: Boolean(operation.departedFromDjiboutiAt),
|
||||
loadListGenerated: Boolean(operation.loadListGeneratedAt),
|
||||
},
|
||||
documents: operation.documents ?? {},
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? null,
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
|
||||
loadListGeneratedAt: operation.loadListGeneratedAt ?? null,
|
||||
performedBy: operation.performedBy ?? null,
|
||||
notes: operation.notes ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a fixed train number on dispatch. The number is drawn from the pool
|
||||
* for the train's dominant cargo type (container vs bulk) and trade direction
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
|
||||
export class TruckEntranceDto {
|
||||
@ApiPropertyOptional()
|
||||
@@ -22,6 +24,11 @@ export class TruckEntranceDto {
|
||||
@IsString()
|
||||
tin?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerPhone?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
truckPlateNumber!: string;
|
||||
@@ -174,8 +181,11 @@ export class BulkReceiveDto {
|
||||
@IsUUID('all', { each: true })
|
||||
bookingIds!: string[];
|
||||
|
||||
@ApiProperty({ type: TruckEntranceDto })
|
||||
truckEntrance!: TruckEntranceDto;
|
||||
@ApiPropertyOptional({ type: TruckEntranceDto })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => TruckEntranceDto)
|
||||
truckEntrance?: TruckEntranceDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -33,4 +33,14 @@ export class PayInvoiceBodyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reference?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Pickup driver name to notify after payment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverName?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Pickup driver phone to notify after payment' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
|
||||
import { ValidateNested } from 'class-validator';
|
||||
import { TruckEntranceDto } from './bulk-receive.dto';
|
||||
|
||||
export class ReceiveWarehouseInventoryDto {
|
||||
@@ -57,6 +59,8 @@ export class ReceiveWarehouseInventoryDto {
|
||||
notes?: string;
|
||||
|
||||
@ApiProperty({ type: TruckEntranceDto })
|
||||
@ValidateNested()
|
||||
@Type(() => TruckEntranceDto)
|
||||
truckEntrance!: TruckEntranceDto;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsDateString, IsOptional, IsString } from 'class-validator';
|
||||
import { IsDateString, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
/** Records a DO / release order being sent to the customer for import pickup. */
|
||||
export class ReleaseOrderDto {
|
||||
@@ -17,4 +17,77 @@ export class ReleaseOrderDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
performedBy?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bookingId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customerId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
truckPlateNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
trailerPlateNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverName?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverLicense?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
driverPhone?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
truckType?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
containerNumber?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
gateInTime?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
tareWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
grossWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
netWeight?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
gateOutTime?: string;
|
||||
}
|
||||
|
||||
@@ -329,7 +329,7 @@ export class SchedulingReadFacade {
|
||||
}
|
||||
if (filter.destination) {
|
||||
params.push(`%${filter.destination}%`);
|
||||
where.push(`(dy.code ILIKE $${params.length} OR dy.name ILIKE $${params.length})`);
|
||||
where.push(`(dy.code ILIKE $${params.length} OR dy.label ILIKE $${params.length})`);
|
||||
}
|
||||
if (filter.dateFrom) {
|
||||
params.push(filter.dateFrom);
|
||||
@@ -351,7 +351,7 @@ export class SchedulingReadFacade {
|
||||
ts.train_number AS "trainNumber",
|
||||
oy.code AS "origin",
|
||||
dy.code AS "destination",
|
||||
dy.name AS "destinationName",
|
||||
dy.label AS "destinationName",
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
ts.scheduled_departure_date AS "departureTime",
|
||||
|
||||
@@ -86,6 +86,12 @@ export class WarehouseInventoryController {
|
||||
return this.inventoryService.readyToLoadExport();
|
||||
}
|
||||
|
||||
@Get('received-export')
|
||||
@ApiOperation({ summary: 'EXPORT inventory that has been received and is awaiting inspection' })
|
||||
receivedExport() {
|
||||
return this.inventoryService.receivedExport();
|
||||
}
|
||||
|
||||
@Get('loaded-export')
|
||||
@ApiOperation({ summary: 'EXPORT inventory that is LOADED and queued for dispatch' })
|
||||
loadedExport() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
|
||||
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
|
||||
@@ -6,6 +6,7 @@ import { Cargo } from '../cargoes/entities/cargoes.entity';
|
||||
import { InterchangeDocumentsService } from '../interchange-documents/interchange-documents.service';
|
||||
import type { InterchangeDocument } from '../interchange-documents/entities/interchange-document.entity';
|
||||
import { LastMileService } from '../last-mile/last-mile.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { BulkInspectDto } from './dto/bulk-inspect.dto';
|
||||
import { BulkReceiveDto, TruckEntranceDto } from './dto/bulk-receive.dto';
|
||||
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
|
||||
@@ -191,6 +192,13 @@ export interface EligibleBookingRow {
|
||||
reference: string;
|
||||
customerId: string | null;
|
||||
customer: string | null;
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
containerNumber: string | null;
|
||||
containerQuantity: number | null;
|
||||
containerPackagingType: string | null;
|
||||
cargoDescription: string | null;
|
||||
lastMileRequested: boolean;
|
||||
direction: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
@@ -205,6 +213,10 @@ export interface EligibleBookingRow {
|
||||
firstMileVehicleId: string | null;
|
||||
firstMileTruckPlateNumber: string | null;
|
||||
firstMileTrailerPlateNumber: string | null;
|
||||
firstMileDriverName: string | null;
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
@@ -289,6 +301,8 @@ export interface ImportUnloadedRow {
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInventoryService {
|
||||
private readonly logger = new Logger(WarehouseInventoryService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly inventoryRepository: WarehouseInventoryRepository,
|
||||
@@ -301,6 +315,7 @@ export class WarehouseInventoryService {
|
||||
private readonly releaseDocuments: WarehouseReleaseDocumentService,
|
||||
private readonly interchangeDocuments: InterchangeDocumentsService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -644,12 +659,20 @@ export class WarehouseInventoryService {
|
||||
b.reference AS "reference",
|
||||
b.company_id AS "customerId",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
(NULLIF(TRIM(COALESCE(b.last_mile_delivery_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_last_mile, false)) AS "lastMileRequested",
|
||||
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",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.status AS "status",
|
||||
@@ -659,7 +682,14 @@ export class WarehouseInventoryService {
|
||||
fm.status AS "firstMileStatus",
|
||||
fm.vehicle_id AS "firstMileVehicleId",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber"
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
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
|
||||
@@ -667,6 +697,22 @@ export class WarehouseInventoryService {
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN freight.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
SUM(booking_container.quantity)::int AS container_quantity,
|
||||
CASE
|
||||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||||
ELSE 'OTHER_CONTAINER'
|
||||
END AS container_packaging_type
|
||||
FROM freight.booking_container booking_container
|
||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||
) bc ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
@@ -675,6 +721,7 @@ export class WarehouseInventoryService {
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.deleted_at IS NULL
|
||||
AND b.payment_status = 'PAID'
|
||||
AND inv.id IS NULL
|
||||
@@ -695,7 +742,6 @@ export class WarehouseInventoryService {
|
||||
/** 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: [] };
|
||||
this.assertTruckEntrance(dto.truckEntrance);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await this.validateLocation(manager, {
|
||||
@@ -711,23 +757,61 @@ export class WarehouseInventoryService {
|
||||
};
|
||||
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
|
||||
`SELECT b.reference AS "reference",
|
||||
b.payment_status AS "paymentStatus",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
oy.country AS "originCountry", dy.country AS "destinationCountry",
|
||||
(NULLIF(TRIM(COALESCE(b.first_mile_pickup_address, '')), '') IS NOT NULL
|
||||
OR COALESCE(st.includes_first_mile, false)) AS "hasFirstMile",
|
||||
fm.id AS "firstMileRequestId",
|
||||
fm.status AS "firstMileStatus"
|
||||
fm.status AS "firstMileStatus",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
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.service_types st ON st.id = b.service_type_id
|
||||
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
SUM(booking_container.quantity)::int AS container_quantity,
|
||||
CASE
|
||||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||||
ELSE 'OTHER_CONTAINER'
|
||||
END AS container_packaging_type
|
||||
FROM freight.booking_container booking_container
|
||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||
) bc ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.id, first_mile.status, first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||
ORDER BY first_mile.created_at DESC
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
@@ -758,11 +842,17 @@ export class WarehouseInventoryService {
|
||||
|
||||
const now = new Date();
|
||||
const grnNumber = this.generateGrnNumber(dto.direction, bookingId, now);
|
||||
const truckEntrance = dto.truckEntrance
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, booking)
|
||||
: undefined;
|
||||
if (dto.direction === 'EXPORT') {
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
}
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
notes: `Bulk received (${dto.direction})`,
|
||||
truckEntrance: dto.truckEntrance,
|
||||
truckEntrance,
|
||||
});
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
@@ -770,7 +860,7 @@ export class WarehouseInventoryService {
|
||||
yardId: dto.yardId,
|
||||
zoneId: dto.zoneId,
|
||||
bookingId,
|
||||
quantity: 1,
|
||||
quantity: Number(booking.containerQuantity) || 1,
|
||||
weight: Number(booking.weight) || 0,
|
||||
status: 'RECEIVED',
|
||||
arrivedAt: now,
|
||||
@@ -783,12 +873,23 @@ export class WarehouseInventoryService {
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${dto.truckEntrance.truckPlateNumber}`,
|
||||
description: truckEntrance?.truckPlateNumber
|
||||
? `GRN ${grnNumber}: bulk received ${dto.direction} booking via truck ${truckEntrance.truckPlateNumber}`
|
||||
: `GRN ${grnNumber}: bulk received ${dto.direction} booking`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await this.notifyOwnerInventoryReceived({
|
||||
phone: truckEntrance?.customerPhone ?? booking.customerPhone,
|
||||
ownerName: truckEntrance?.ownerName ?? booking.customer,
|
||||
bookingReference: truckEntrance?.edrDigitalBookingId ?? booking.reference,
|
||||
grnNumber,
|
||||
direction: dto.direction,
|
||||
warehouseId: dto.warehouseId,
|
||||
});
|
||||
|
||||
result.receivedCount += 1;
|
||||
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id, grnNumber });
|
||||
}
|
||||
@@ -887,6 +988,11 @@ export class WarehouseInventoryService {
|
||||
return this.exportInventoryByStatus('READY_FOR_LOADING', true);
|
||||
}
|
||||
|
||||
/** EXPORT inventory received at the facility and awaiting inspection. */
|
||||
async receivedExport(): Promise<ReadyToLoadRow[]> {
|
||||
return this.exportInventoryByStatus('RECEIVED');
|
||||
}
|
||||
|
||||
/** EXPORT inventory that has been loaded onto a wagon and is queued for dispatch (LOADED). */
|
||||
async loadedExport(): Promise<ReadyToLoadRow[]> {
|
||||
return this.exportInventoryByStatus('LOADED');
|
||||
@@ -1166,7 +1272,7 @@ export class WarehouseInventoryService {
|
||||
oy.country AS "originCountry",
|
||||
dy.country AS "destinationCountry",
|
||||
dy.code AS "destinationCode",
|
||||
dy.name AS "destinationName"
|
||||
dy.label AS "destinationName"
|
||||
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
|
||||
@@ -1307,6 +1413,20 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
const currentStatus = item.inventoryStatus ?? item.bookingStatus;
|
||||
if (currentStatus === 'UNLOADED_AT_DJIBOUTI_PORT') {
|
||||
seenInventory.add(item.inventoryId);
|
||||
result.unloadedCount += 1;
|
||||
result.results.push({
|
||||
bookingId: item.bookingId,
|
||||
itemType: item.itemType,
|
||||
itemId: item.itemId,
|
||||
inventoryId: item.inventoryId,
|
||||
containerNumber: item.containerNumber,
|
||||
status: 'UNLOADED_AT_DJIBOUTI_PORT',
|
||||
message: 'Already unloaded at Djibouti Port',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!currentStatus || !this.EXPORT_DJIBOUTI_UNLOAD_ELIGIBLE_STATUSES.includes(currentStatus)) {
|
||||
skip(`Status ${currentStatus ?? 'UNKNOWN'} is not eligible for Djibouti export unloading`);
|
||||
continue;
|
||||
@@ -1483,18 +1603,29 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
|
||||
this.assertTruckEntrance(dto.truckEntrance);
|
||||
const weight = Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
const containerCount = dto.containerId ? Math.round(Number(dto.quantity) || 0) : 0;
|
||||
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
|
||||
|
||||
const id = await this.dataSource.transaction(async (manager) => {
|
||||
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
|
||||
|
||||
if (dto.bookingId) {
|
||||
await this.assertBookingExists(manager, dto.bookingId);
|
||||
const bookingSource = dto.bookingId
|
||||
? await this.getBookingTruckEntranceSource(manager, dto.bookingId)
|
||||
: null;
|
||||
if (dto.bookingId && !bookingSource?.reference) {
|
||||
throw new NotFoundException(`Booking ${dto.bookingId} not found`);
|
||||
}
|
||||
const quantity = bookingSource
|
||||
? Number(bookingSource.containerQuantity) || 1
|
||||
: Number(dto.quantity) || 0;
|
||||
const weight = bookingSource
|
||||
? Number(bookingSource.weight) || 0
|
||||
: Number(dto.weight) || 0;
|
||||
const volume = Number(dto.volume) || 0;
|
||||
const containerCount = dto.containerId ? Math.round(quantity) : 0;
|
||||
const truckEntrance = dto.bookingId
|
||||
? this.mergeSystemTruckEntrance(dto.truckEntrance, bookingSource ?? {})
|
||||
: dto.truckEntrance;
|
||||
this.assertTruckEntrance(truckEntrance);
|
||||
|
||||
this.assertCapacity('Warehouse', warehouse, weight, volume, containerCount);
|
||||
this.assertCapacity('Yard', yard, weight, volume, containerCount);
|
||||
@@ -1505,7 +1636,7 @@ export class WarehouseInventoryService {
|
||||
const receiveNote = this.buildReceiveNote({
|
||||
grnNumber,
|
||||
notes: dto.notes?.trim() || 'Single booking received',
|
||||
truckEntrance: dto.truckEntrance,
|
||||
truckEntrance,
|
||||
});
|
||||
const saved = await manager.getRepository(WarehouseInventory).save(
|
||||
manager.getRepository(WarehouseInventory).create({
|
||||
@@ -1516,7 +1647,7 @@ export class WarehouseInventoryService {
|
||||
cargoId: dto.cargoId ?? null,
|
||||
containerId: dto.containerId ?? null,
|
||||
goodsId: dto.goodsId ?? null,
|
||||
quantity: Number(dto.quantity) || 0,
|
||||
quantity,
|
||||
weight,
|
||||
volume: dto.volume ?? null,
|
||||
status: 'RECEIVED',
|
||||
@@ -1529,14 +1660,23 @@ export class WarehouseInventoryService {
|
||||
|
||||
await this.activityLog.record(
|
||||
{
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${dto.truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
activityType: 'INVENTORY_RECEIVED',
|
||||
inventoryId: saved.id,
|
||||
warehouseId: dto.warehouseId,
|
||||
description: `GRN ${grnNumber}: received ${weight}kg via truck ${truckEntrance.truckPlateNumber}`,
|
||||
performedBy: dto.performedBy,
|
||||
},
|
||||
manager,
|
||||
);
|
||||
|
||||
await this.notifyOwnerInventoryReceived({
|
||||
phone: truckEntrance.customerPhone,
|
||||
ownerName: truckEntrance.ownerName,
|
||||
bookingReference: truckEntrance.edrDigitalBookingId ?? dto.bookingId,
|
||||
grnNumber,
|
||||
direction: bookingDirection,
|
||||
warehouseId: dto.warehouseId,
|
||||
});
|
||||
|
||||
return saved.id;
|
||||
});
|
||||
@@ -1776,11 +1916,13 @@ export class WarehouseInventoryService {
|
||||
|
||||
const releaseDate = dto.releaseDate ? new Date(dto.releaseDate) : new Date();
|
||||
const reference = dto.reference?.trim() || null;
|
||||
const exitInspectionNote = this.buildExitInspectionNote(dto);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(WarehouseInventory).update(id, {
|
||||
releaseDate,
|
||||
releaseOrderReference: reference,
|
||||
notes: [item.notes?.trim(), exitInspectionNote].filter(Boolean).join('\n\n'),
|
||||
});
|
||||
await this.activityLog.record(
|
||||
{
|
||||
@@ -1807,6 +1949,7 @@ export class WarehouseInventoryService {
|
||||
inv.quantity,
|
||||
inv.weight,
|
||||
inv.status,
|
||||
inv.notes,
|
||||
b.id AS "bookingId",
|
||||
b.reference AS "bookingReference",
|
||||
b.status AS "bookingStatus",
|
||||
@@ -1867,6 +2010,7 @@ export class WarehouseInventoryService {
|
||||
zone: [row?.zoneName, row?.zoneCode].filter(Boolean).join(' / ') || null,
|
||||
inventoryStatus: row?.status ?? null,
|
||||
clearanceStatus: 'CLEARED FOR WAREHOUSE EXIT',
|
||||
exitInspectionSummary: this.extractExitInspectionNote(row?.notes),
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -2372,6 +2516,7 @@ export class WarehouseInventoryService {
|
||||
zone: string | null;
|
||||
inventoryStatus: string | null;
|
||||
clearanceStatus: string;
|
||||
exitInspectionSummary?: string | null;
|
||||
}): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
@@ -2402,6 +2547,7 @@ export class WarehouseInventoryService {
|
||||
['Zone', data.zone],
|
||||
['Inventory Status', data.inventoryStatus],
|
||||
['Clearance Status', data.clearanceStatus],
|
||||
...(data.exitInspectionSummary ? [['Exit Inspection', data.exitInspectionSummary] as [string, string]] : []),
|
||||
];
|
||||
|
||||
return `<!doctype html>
|
||||
@@ -2492,16 +2638,6 @@ export class WarehouseInventoryService {
|
||||
return { warehouse, yard, zone };
|
||||
}
|
||||
|
||||
private async assertBookingExists(manager: EntityManager, bookingId: string): Promise<void> {
|
||||
const rows = await manager.query(
|
||||
'SELECT id FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
|
||||
[bookingId],
|
||||
);
|
||||
if (!rows || rows.length === 0) {
|
||||
throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
private appendNote(existing: string | null | undefined, note: string): string {
|
||||
const trimmed = existing?.trim();
|
||||
return trimmed ? `${trimmed}\n${note}` : note;
|
||||
@@ -2522,51 +2658,265 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
}
|
||||
|
||||
private mergeSystemTruckEntrance(
|
||||
submitted: TruckEntranceDto,
|
||||
booking: {
|
||||
reference?: string | null;
|
||||
customer?: string | null;
|
||||
customerTin?: string | null;
|
||||
customerPhone?: string | null;
|
||||
containerNumber?: string | null;
|
||||
containerQuantity?: number | string | null;
|
||||
containerPackagingType?: string | null;
|
||||
cargoDescription?: string | null;
|
||||
weight?: number | string | null;
|
||||
firstMileTruckPlateNumber?: string | null;
|
||||
firstMileTrailerPlateNumber?: string | null;
|
||||
firstMileDriverName?: string | null;
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
},
|
||||
): TruckEntranceDto {
|
||||
return {
|
||||
...submitted,
|
||||
ownerName: booking.customer?.trim() || submitted.ownerName,
|
||||
edrDigitalBookingId: booking.reference?.trim() || submitted.edrDigitalBookingId,
|
||||
tin: booking.customerTin?.trim() || submitted.tin,
|
||||
customerPhone: booking.customerPhone?.trim() || submitted.customerPhone,
|
||||
assignedEquipmentNumber: booking.containerNumber?.trim() || submitted.assignedEquipmentNumber,
|
||||
itemDescription: booking.cargoDescription?.trim() || submitted.itemDescription,
|
||||
packagingType: booking.containerPackagingType?.trim() || submitted.packagingType,
|
||||
unitCount:
|
||||
booking.containerQuantity !== undefined && booking.containerQuantity !== null
|
||||
? Number(booking.containerQuantity)
|
||||
: submitted.unitCount,
|
||||
grossWeightKg:
|
||||
booking.weight !== undefined && booking.weight !== null
|
||||
? Number(booking.weight)
|
||||
: submitted.grossWeightKg,
|
||||
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
|
||||
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
|
||||
driverName: booking.firstMileDriverName?.trim() || submitted.driverName,
|
||||
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
|
||||
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
|
||||
truckType: booking.firstMileTruckType?.trim() || submitted.truckType,
|
||||
};
|
||||
}
|
||||
|
||||
private async getBookingTruckEntranceSource(
|
||||
manager: EntityManager,
|
||||
bookingId: string,
|
||||
): Promise<{
|
||||
reference?: string | null;
|
||||
customer?: string | null;
|
||||
customerTin?: string | null;
|
||||
customerPhone?: string | null;
|
||||
containerNumber?: string | null;
|
||||
containerQuantity?: number | string | null;
|
||||
containerPackagingType?: string | null;
|
||||
cargoDescription?: string | null;
|
||||
weight?: number | string | null;
|
||||
firstMileTruckPlateNumber?: string | null;
|
||||
firstMileTrailerPlateNumber?: string | null;
|
||||
firstMileDriverName?: string | null;
|
||||
firstMileDriverPhone?: string | null;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
}> {
|
||||
const [booking] = await manager.query(
|
||||
`SELECT b.reference AS "reference",
|
||||
company.name AS "customer",
|
||||
company.tin AS "customerTin",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
b.cargo_total_weight_vgm AS "weight",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text) AS "cargoDescription",
|
||||
bc.container_numbers AS "containerNumber",
|
||||
bc.container_quantity AS "containerQuantity",
|
||||
bc.container_packaging_type AS "containerPackagingType",
|
||||
v.plate_number AS "firstMileTruckPlateNumber",
|
||||
v.trailer_plate_no AS "firstMileTrailerPlateNumber",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(driver.first_name, ''), ' ', COALESCE(driver.last_name, ''))), ''),
|
||||
v.assigned_driver_name
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
driver.license_number AS "firstMileDriverLicenseNumber",
|
||||
v.vehicle_type AS "firstMileTruckType"
|
||||
FROM freight.bookings b
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = b.cargo_type_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT string_agg(NULLIF(booking_container.container_number, ''), ', ' ORDER BY booking_container.container_number) AS container_numbers,
|
||||
SUM(booking_container.quantity)::int AS container_quantity,
|
||||
CASE
|
||||
WHEN COUNT(booking_container.id) = 0 THEN NULL
|
||||
WHEN bool_or(container_type.is_reefer) THEN 'REEFER_CONTAINER'
|
||||
WHEN bool_or(container_type.is_open_top) THEN 'OPEN_TOP_CONTAINER'
|
||||
WHEN MIN(container_type.size_ft) = 20 THEN 'CONTAINER_20FT'
|
||||
WHEN MIN(container_type.size_ft) = 40 THEN 'CONTAINER_40FT'
|
||||
WHEN MIN(container_type.size_ft) = 45 THEN 'CONTAINER_45FT'
|
||||
ELSE 'OTHER_CONTAINER'
|
||||
END AS container_packaging_type
|
||||
FROM freight.booking_container booking_container
|
||||
LEFT JOIN freight.container_types container_type ON container_type.id = booking_container.container_type_id
|
||||
WHERE booking_container.booking_id = b.id AND booking_container.deleted_at IS NULL
|
||||
) bc ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT first_mile.vehicle_id
|
||||
FROM freight.first_mile first_mile
|
||||
WHERE first_mile.booking_id = b.id AND first_mile.deleted_at IS NULL
|
||||
ORDER BY first_mile.created_at DESC
|
||||
LIMIT 1
|
||||
) fm ON true
|
||||
LEFT JOIN freight.vehicles v ON v.id = fm.vehicle_id
|
||||
LEFT JOIN freight.drivers driver ON driver.id = v.assigned_driver_id
|
||||
WHERE b.id = $1 AND b.deleted_at IS NULL
|
||||
LIMIT 1`,
|
||||
[bookingId],
|
||||
);
|
||||
return booking ?? {};
|
||||
}
|
||||
|
||||
private async notifyOwnerInventoryReceived(params: {
|
||||
phone?: string | null;
|
||||
ownerName?: string | null;
|
||||
bookingReference?: string | null;
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
warehouseId?: string | null;
|
||||
}): Promise<void> {
|
||||
const phone = params.phone?.trim();
|
||||
if (!phone) return;
|
||||
|
||||
const ownerName = params.ownerName?.trim() || 'Customer';
|
||||
const bookingReference = params.bookingReference?.trim();
|
||||
const message =
|
||||
`Dear ${ownerName}, your cargo has been received by EDR warehouse. ` +
|
||||
(bookingReference ? `Booking: ${bookingReference}. ` : '') +
|
||||
`GRN: ${params.grnNumber}. ` +
|
||||
(params.direction ? `Direction: ${params.direction}. ` : '') +
|
||||
`Thank you.`;
|
||||
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (error) {
|
||||
// Receiving inventory must not be rolled back because an SMS provider is unavailable.
|
||||
this.logger.error(`Failed to notify owner for GRN ${params.grnNumber}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private generateGrnNumber(direction: string, referenceId: string, date: Date): string {
|
||||
const stamp = date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
const suffix = referenceId.replace(/-/g, '').slice(0, 8).toUpperCase();
|
||||
return `GRN-${direction.toUpperCase()}-${stamp}-${suffix}`;
|
||||
}
|
||||
|
||||
private buildExitInspectionNote(dto: ReleaseOrderDto): string | null {
|
||||
const hasExitInspection =
|
||||
Boolean(dto.truckPlateNumber?.trim()) ||
|
||||
Boolean(dto.trailerPlateNumber?.trim()) ||
|
||||
Boolean(dto.driverName?.trim()) ||
|
||||
Boolean(dto.driverLicense?.trim()) ||
|
||||
Boolean(dto.driverPhone?.trim()) ||
|
||||
Boolean(dto.truckType?.trim()) ||
|
||||
Boolean(dto.containerNumber?.trim()) ||
|
||||
dto.tareWeight !== undefined ||
|
||||
dto.grossWeight !== undefined ||
|
||||
dto.netWeight !== undefined ||
|
||||
Boolean(dto.gateInTime) ||
|
||||
Boolean(dto.gateOutTime);
|
||||
|
||||
if (!hasExitInspection) return null;
|
||||
|
||||
if (!dto.truckPlateNumber?.trim()) {
|
||||
throw new BadRequestException('Truck plate number is required for exit inspection');
|
||||
}
|
||||
if (!dto.driverName?.trim()) {
|
||||
throw new BadRequestException('Driver name is required for exit inspection');
|
||||
}
|
||||
if (dto.tareWeight === undefined || dto.grossWeight === undefined) {
|
||||
throw new BadRequestException('Tare weight and gross weight are required for exit inspection');
|
||||
}
|
||||
|
||||
const tareWeight = Number(dto.tareWeight);
|
||||
const grossWeight = Number(dto.grossWeight);
|
||||
const computedNetWeight = Number((grossWeight - tareWeight).toFixed(3));
|
||||
const submittedNetWeight = dto.netWeight === undefined ? computedNetWeight : Number(dto.netWeight);
|
||||
|
||||
if (Math.abs(submittedNetWeight - computedNetWeight) > 0.001) {
|
||||
throw new BadRequestException('Weight mismatch: net weight must equal gross weight minus tare weight.');
|
||||
}
|
||||
|
||||
const rows = [
|
||||
'[Exit Inspection]',
|
||||
dto.bookingId?.trim() ? `Booking ID: ${dto.bookingId.trim()}` : null,
|
||||
dto.customerId?.trim() ? `Customer ID: ${dto.customerId.trim()}` : null,
|
||||
`Truck Plate: ${dto.truckPlateNumber.trim()}`,
|
||||
dto.trailerPlateNumber?.trim() ? `Trailer Plate: ${dto.trailerPlateNumber.trim()}` : null,
|
||||
`Driver: ${dto.driverName.trim()}`,
|
||||
dto.driverLicense?.trim() ? `Driver License: ${dto.driverLicense.trim()}` : null,
|
||||
dto.driverPhone?.trim() ? `Driver Phone: ${dto.driverPhone.trim()}` : null,
|
||||
dto.truckType?.trim() ? `Truck Type: ${dto.truckType.trim()}` : null,
|
||||
dto.containerNumber?.trim() ? `Container Number: ${dto.containerNumber.trim()}` : null,
|
||||
dto.gateInTime ? `Gate In Time: ${dto.gateInTime}` : null,
|
||||
`Tare Weight: ${tareWeight} kg`,
|
||||
`Gross Weight: ${grossWeight} kg`,
|
||||
`Net Weight: ${computedNetWeight} kg`,
|
||||
dto.gateOutTime ? `Gate Out Time: ${dto.gateOutTime}` : null,
|
||||
];
|
||||
|
||||
return rows.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
private extractExitInspectionNote(notes?: string | null): string | null {
|
||||
if (!notes) return null;
|
||||
const marker = '[Exit Inspection]';
|
||||
const index = notes.lastIndexOf(marker);
|
||||
if (index < 0) return null;
|
||||
return notes.slice(index + marker.length).trim() || null;
|
||||
}
|
||||
|
||||
private buildReceiveNote(input: {
|
||||
grnNumber: string;
|
||||
direction?: string | null;
|
||||
notes?: string | null;
|
||||
truckEntrance: TruckEntranceDto;
|
||||
truckEntrance?: TruckEntranceDto;
|
||||
}): string {
|
||||
const truck = input.truckEntrance;
|
||||
const rows = [
|
||||
`GRN Number: ${input.grnNumber}`,
|
||||
input.direction ? `Direction: ${input.direction}` : null,
|
||||
truck.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
|
||||
truck.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
||||
truck.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
||||
truck.tin ? `TIN: ${truck.tin}` : null,
|
||||
`Truck Plate: ${truck.truckPlateNumber}`,
|
||||
truck.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||||
truck.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||||
truck.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
|
||||
truck.truckType ? `Truck Type: ${truck.truckType}` : null,
|
||||
`Driver: ${truck.driverName}`,
|
||||
`Driver Phone: ${truck.driverPhone}`,
|
||||
truck.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
`Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg`,
|
||||
truck.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
truck.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||
truck.itemCode ? `Item Code: ${truck.itemCode}` : null,
|
||||
truck.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||
truck.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||
truck.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||
truck.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
||||
truck.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
||||
truck.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||
truck.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||
truck.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||
truck.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
|
||||
truck.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
|
||||
truck.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
|
||||
truck?.ownerName ? `Owner / Shipper: ${truck.ownerName}` : null,
|
||||
truck?.consigneeDetails ? `Consignee: ${truck.consigneeDetails}` : null,
|
||||
truck?.edrDigitalBookingId ? `EDR Digital Booking ID: ${truck.edrDigitalBookingId}` : null,
|
||||
truck?.tin ? `TIN: ${truck.tin}` : null,
|
||||
truck?.customerPhone ? `Customer Phone: ${truck.customerPhone}` : null,
|
||||
truck?.truckPlateNumber ? `Truck Plate: ${truck.truckPlateNumber}` : null,
|
||||
truck?.trailerPlateNumber ? `Trailer Plate: ${truck.trailerPlateNumber}` : null,
|
||||
truck?.assignedEquipmentNumber ? `Assigned Wagon / Container: ${truck.assignedEquipmentNumber}` : null,
|
||||
truck?.customsSealNumber ? `Customs Seal Number: ${truck.customsSealNumber}` : null,
|
||||
truck?.truckType ? `Truck Type: ${truck.truckType}` : null,
|
||||
truck?.driverName ? `Driver: ${truck.driverName}` : null,
|
||||
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
|
||||
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
|
||||
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
|
||||
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
|
||||
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
truck?.hsCodes ? `HS Codes: ${truck.hsCodes}` : null,
|
||||
truck?.itemCode ? `Item Code: ${truck.itemCode}` : null,
|
||||
truck?.itemDescription ? `Item Description: ${truck.itemDescription}` : null,
|
||||
truck?.packagingType ? `Packaging Type: ${truck.packagingType}` : null,
|
||||
truck?.unitCount !== undefined ? `Unit Count: ${Number(truck.unitCount)}` : null,
|
||||
truck?.grossWeightKg !== undefined ? `Gross Weight: ${Number(truck.grossWeightKg)} kg` : null,
|
||||
truck?.netWeightKg !== undefined ? `Net Weight: ${Number(truck.netWeightKg)} kg` : null,
|
||||
truck?.volumeDimensions ? `Volume / Dimensions: ${truck.volumeDimensions}` : null,
|
||||
truck?.conditionAtReceipt ? `Condition at Receipt: ${truck.conditionAtReceipt}` : null,
|
||||
truck?.damagedRejectedQuantity !== undefined ? `Damaged / Rejected Quantity: ${Number(truck.damagedRejectedQuantity)}` : null,
|
||||
truck?.warehouseCodeLocation ? `Warehouse Code / Location: ${truck.warehouseCodeLocation}` : null,
|
||||
truck?.driverSignatoryName ? `Driver Signatory: ${truck.driverSignatoryName}` : null,
|
||||
truck?.warehouseManagerName ? `EDR Warehouse Manager: ${truck.warehouseManagerName}` : null,
|
||||
input.notes?.trim() ? `Remarks: ${input.notes.trim()}` : null,
|
||||
];
|
||||
return rows.filter(Boolean).join('\n');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import {
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoiceStatus,
|
||||
@@ -22,6 +23,8 @@ export interface PayInvoiceDto {
|
||||
amount: number;
|
||||
method?: string;
|
||||
reference?: string;
|
||||
driverName?: string;
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
/** Invoices that still owe money and therefore block terminal release. */
|
||||
@@ -46,12 +49,15 @@ export type WarehouseFeeInvoiceWithDisplay = WarehouseFeeInvoice & Partial<Invoi
|
||||
|
||||
@Injectable()
|
||||
export class WarehouseInvoiceService {
|
||||
private readonly logger = new Logger(WarehouseInvoiceService.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly invoiceRepository: WarehouseFeeInvoiceRepository,
|
||||
private readonly itemRepository: WarehouseFeeInvoiceItemRepository,
|
||||
private readonly feeService: WarehouseFeeService,
|
||||
private readonly documents: WarehouseReleaseDocumentService,
|
||||
private readonly notifications: NotificationsService,
|
||||
) {}
|
||||
|
||||
// ── Generation ───────────────────────────────────────────────────────────
|
||||
@@ -150,7 +156,9 @@ export class WarehouseInvoiceService {
|
||||
await this.itemRepository.create({ invoiceId: invoice.id, ...it });
|
||||
}
|
||||
|
||||
return this.findById(invoice.id);
|
||||
const saved = await this.findById(invoice.id);
|
||||
await this.notifyWarehouseFeeIssued(saved);
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** WHF-YYYYMMDD-00001 — sequential per day. */
|
||||
@@ -246,7 +254,9 @@ export class WarehouseInvoiceService {
|
||||
paidAt: fullyPaid ? new Date() : invoice.paidAt ?? null,
|
||||
payments,
|
||||
});
|
||||
return updated as WarehouseFeeInvoice;
|
||||
const paidInvoice = updated as WarehouseFeeInvoice;
|
||||
await this.notifyWarehouseFeePayment(paidInvoice, dto);
|
||||
return paidInvoice;
|
||||
}
|
||||
|
||||
// ── Release blocking ──────────────────────────────────────────────────────
|
||||
@@ -332,6 +342,125 @@ export class WarehouseInvoiceService {
|
||||
};
|
||||
}
|
||||
|
||||
private async getInvoiceNotificationContacts(invoice: WarehouseFeeInvoice): Promise<{
|
||||
bookingReference: string | null;
|
||||
customerName: string | null;
|
||||
customerPhone: string | null;
|
||||
driverName: string | null;
|
||||
driverPhone: string | null;
|
||||
containerNumber: string | null;
|
||||
cargoDescription: string | null;
|
||||
}> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT b.reference AS "bookingReference",
|
||||
company.name AS "customerName",
|
||||
COALESCE(company.contact_person_phone, company.phone, company.general_manager_phone, company.etrade_phone) AS "customerPhone",
|
||||
COALESCE(
|
||||
NULLIF(TRIM(CONCAT(COALESCE(last_driver.first_name, ''), ' ', COALESCE(last_driver.last_name, ''))), ''),
|
||||
last_vehicle.assigned_driver_name,
|
||||
NULLIF(TRIM(CONCAT(COALESCE(first_driver.first_name, ''), ' ', COALESCE(first_driver.last_name, ''))), ''),
|
||||
first_vehicle.assigned_driver_name
|
||||
) AS "driverName",
|
||||
COALESCE(last_driver.phone_number, first_driver.phone_number) AS "driverPhone",
|
||||
COALESCE(container.container_number, booking_container.container_number) AS "containerNumber",
|
||||
COALESCE(cargo_type.cargo_type_name, b.cargo_free_text, cargo.description) AS "cargoDescription"
|
||||
FROM freight.warehouse_fee_invoices fee
|
||||
LEFT JOIN freight.warehouse_inventory inv ON inv.id = fee.inventory_id AND inv.deleted_at IS NULL
|
||||
LEFT JOIN freight.bookings b ON b.id = fee.booking_id AND b.deleted_at IS NULL
|
||||
LEFT JOIN freight.companies company ON company.id = COALESCE(fee.customer_id, b.company_id)
|
||||
LEFT JOIN freight.containers container ON container.id = inv.container_id AND container.deleted_at IS NULL
|
||||
LEFT JOIN freight.booking_container booking_container ON (
|
||||
booking_container.booking_id = b.id
|
||||
AND booking_container.deleted_at IS NULL
|
||||
)
|
||||
LEFT JOIN freight.cargoes cargo ON cargo.id = inv.cargo_id AND cargo.deleted_at IS NULL
|
||||
LEFT JOIN freight.cargo_types cargo_type ON cargo_type.id = COALESCE(cargo.cargo_type_id, b.cargo_type_id)
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT lm.vehicle_id
|
||||
FROM freight.last_mile lm
|
||||
WHERE lm.booking_id = b.id AND lm.deleted_at IS NULL
|
||||
ORDER BY lm.created_at DESC
|
||||
LIMIT 1
|
||||
) latest_last_mile ON true
|
||||
LEFT JOIN freight.vehicles last_vehicle ON last_vehicle.id = latest_last_mile.vehicle_id
|
||||
LEFT JOIN freight.drivers last_driver ON last_driver.id = last_vehicle.assigned_driver_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT fm.vehicle_id
|
||||
FROM freight.first_mile fm
|
||||
WHERE fm.booking_id = b.id AND fm.deleted_at IS NULL
|
||||
ORDER BY fm.created_at DESC
|
||||
LIMIT 1
|
||||
) latest_first_mile ON true
|
||||
LEFT JOIN freight.vehicles first_vehicle ON first_vehicle.id = latest_first_mile.vehicle_id
|
||||
LEFT JOIN freight.drivers first_driver ON first_driver.id = first_vehicle.assigned_driver_id
|
||||
WHERE fee.id = $1
|
||||
LIMIT 1`,
|
||||
[invoice.id],
|
||||
);
|
||||
|
||||
return {
|
||||
bookingReference: row?.bookingReference ?? null,
|
||||
customerName: row?.customerName ?? null,
|
||||
customerPhone: row?.customerPhone ?? null,
|
||||
driverName: row?.driverName ?? null,
|
||||
driverPhone: row?.driverPhone ?? null,
|
||||
containerNumber: row?.containerNumber ?? null,
|
||||
cargoDescription: row?.cargoDescription ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private async sendSms(recipient: string | null | undefined, message: string, context: string): Promise<void> {
|
||||
const phone = recipient?.trim();
|
||||
if (!phone) return;
|
||||
try {
|
||||
await this.notifications.directSend('sms', phone, message);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to send ${context} SMS to ${phone}: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeeIssued(invoice: WarehouseFeeInvoice): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||
const cargoText = cargo ? ` Cargo: ${cargo}.` : '';
|
||||
const message =
|
||||
`Dear ${customerName}, warehouse ${invoice.invoiceType.replace(/_/g, ' ').toLowerCase()} fee ` +
|
||||
`${invoice.invoiceNumber} is due.${bookingReference}${cargoText} Amount: ` +
|
||||
`${Number(invoice.totalAmount).toLocaleString()} ${invoice.currency}. Please pay before cargo pickup.`;
|
||||
|
||||
await this.sendSms(contacts.customerPhone, message, `warehouse fee invoice ${invoice.invoiceNumber}`);
|
||||
}
|
||||
|
||||
private async notifyWarehouseFeePayment(invoice: WarehouseFeeInvoice, dto: PayInvoiceDto): Promise<void> {
|
||||
const contacts = await this.getInvoiceNotificationContacts(invoice);
|
||||
const customerName = contacts.customerName?.trim() || 'Customer';
|
||||
const bookingReference = contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '';
|
||||
const statusText =
|
||||
invoice.status === 'PAID'
|
||||
? 'fully paid and ready for pickup release'
|
||||
: `partially paid. Balance: ${Number(invoice.balanceAmount).toLocaleString()} ${invoice.currency}`;
|
||||
const customerMessage =
|
||||
`Dear ${customerName}, payment of ${Number(dto.amount).toLocaleString()} ${invoice.currency} ` +
|
||||
`was recorded for warehouse fee ${invoice.invoiceNumber}.${bookingReference} Status: ${statusText}.`;
|
||||
|
||||
await this.sendSms(contacts.customerPhone, customerMessage, `warehouse fee payment ${invoice.invoiceNumber}`);
|
||||
|
||||
if (invoice.status !== 'PAID') return;
|
||||
|
||||
const driverPhone = dto.driverPhone?.trim() || contacts.driverPhone;
|
||||
const driverName = dto.driverName?.trim() || contacts.driverName || 'Driver';
|
||||
const cargo = contacts.containerNumber || contacts.cargoDescription;
|
||||
const driverMessage =
|
||||
`Dear ${driverName}, warehouse demurrage/storage fee ${invoice.invoiceNumber} is paid.` +
|
||||
(contacts.bookingReference ? ` Booking: ${contacts.bookingReference}.` : '') +
|
||||
(cargo ? ` Cargo: ${cargo}.` : '') +
|
||||
' Proceed with pickup after gate verification.';
|
||||
|
||||
await this.sendSms(driverPhone, driverMessage, `warehouse pickup driver ${invoice.invoiceNumber}`);
|
||||
}
|
||||
|
||||
private buildInvoiceDocumentHtml(
|
||||
invoice: WarehouseFeeInvoiceWithDisplay & { items: unknown[] },
|
||||
kind: 'INVOICE' | 'RECEIPT',
|
||||
|
||||
@@ -99,26 +99,51 @@ export class WarehouseReleaseDocumentService {
|
||||
}
|
||||
|
||||
private htmlToBasicPdfBuffer(html: string): Buffer {
|
||||
const text = this.htmlToPlainText(html);
|
||||
const lines = this.wrapLines(text, 86).slice(0, 52);
|
||||
const body = lines
|
||||
.map((line, index) => {
|
||||
const y = 770 - index * 12;
|
||||
const isTitle = index < 2 || /clearance|release order/i.test(line);
|
||||
const size = index === 0 ? 13 : isTitle ? 11 : 9.6;
|
||||
const font = isTitle ? 'F2' : 'F1';
|
||||
return this.textOp(line, 48, y, size, font);
|
||||
})
|
||||
.join('\n');
|
||||
const doc = this.extractReleaseDocument(html);
|
||||
const body: string[] = [
|
||||
this.lineOp(36, 810, 559, 810, '0 0 0', 2.2),
|
||||
this.textOp('ETHIO-DJIBOUTI RAILWAY S.C.', 36, 787, 9, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE GATE', 36, 764, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('CLEARANCE / RELEASE', 36, 742, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('ORDER', 36, 720, 24, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp('OFFICIAL WAREHOUSE RELEASE AND EXIT AUTHORIZATION', 36, 696, 8.5, 'F1', '0.25 0.34 0.45'),
|
||||
this.textOp('Document / Release No.', 424, 781, 8.5, 'F1', '0.15 0.22 0.32'),
|
||||
this.textOp(doc.reference, 504 - doc.reference.length * 2.2, 761, 15, 'F2', '0.02 0.08 0.16'),
|
||||
this.textOp(`Issued: ${doc.issuedAt}`, 424, 742, 8.5, 'F1', '0.15 0.22 0.32'),
|
||||
this.lineOp(36, 682, 559, 682, '0.08 0.32 0.18', 2),
|
||||
this.rectOp(36, 625, 410, 52, '0.95 1 0.96', '0.38 0.85 0.55', 0.8),
|
||||
this.lineOp(39, 625, 39, 677, '0.08 0.48 0.25', 2.2),
|
||||
...this.wrapLines(doc.notice, 68)
|
||||
.slice(0, 4)
|
||||
.map((line, index) => this.textOp(line, 52, 659 - index * 12, 9.2, 'F1')),
|
||||
this.textOp('RELEASE PARTICULARS', 36, 604, 10, 'F2', '0.08 0.32 0.18'),
|
||||
];
|
||||
|
||||
let y = 586;
|
||||
const rowHeight = 20;
|
||||
for (const [label, value] of doc.rows.slice(0, 14)) {
|
||||
body.push(this.rectOp(36, y - rowHeight + 3, 160, rowHeight, '0.97 0.98 0.99', '0.70 0.77 0.85', 0.6));
|
||||
body.push(this.rectOp(196, y - rowHeight + 3, 363, rowHeight, '1 1 1', '0.70 0.77 0.85', 0.6));
|
||||
body.push(this.textOp(label, 46, y - 10, 8.6, 'F2', '0.02 0.08 0.16'));
|
||||
body.push(this.textOp(value || '-', 206, y - 10, 8.6, 'F1', '0.02 0.08 0.16'));
|
||||
y -= rowHeight;
|
||||
}
|
||||
|
||||
body.push(this.textOp('AUTHORIZATION CLAUSE', 36, y - 10, 10, 'F2', '0.08 0.32 0.18'));
|
||||
body.push(this.rectOp(36, y - 76, 523, 48, '1 1 1', '0.70 0.77 0.85', 0.7));
|
||||
body.push(
|
||||
...this.wrapLines(doc.clause, 92)
|
||||
.slice(0, 4)
|
||||
.map((line, index) => this.textOp(line, 48, y - 45 - index * 10, 8.2, 'F1')),
|
||||
);
|
||||
|
||||
const stream = [
|
||||
this.lineOp(48, 752, 548, 752),
|
||||
body,
|
||||
this.circularSealOps(184, 154),
|
||||
this.lineOp(48, 92, 278, 92, '0 0 0'),
|
||||
this.textOp('Officer in charge name / signature / date', 48, 76, 9, 'F1'),
|
||||
this.lineOp(326, 92, 548, 92, '0 0 0'),
|
||||
this.textOp('Customer or driver name / signature / date', 326, 76, 9, 'F1'),
|
||||
this.textOp('OFFICIAL WAREHOUSE GATE CLEARANCE DOCUMENT', 145, 52, 9, 'F2', '0.08 0.32 0.18'),
|
||||
...body,
|
||||
this.lineOp(36, 60, 218, 60, '0 0 0', 1),
|
||||
this.textOp('Officer in charge name / signature / date', 36, 47, 7.4, 'F1'),
|
||||
this.circularSealOps(286, 62, 38),
|
||||
this.lineOp(341, 60, 559, 60, '0 0 0', 1),
|
||||
this.textOp('Customer or driver name / signature / date', 341, 47, 7.4, 'F1'),
|
||||
].join('\n');
|
||||
|
||||
const objects = [
|
||||
@@ -149,6 +174,31 @@ export class WarehouseReleaseDocumentService {
|
||||
return Buffer.from(pdf, 'latin1');
|
||||
}
|
||||
|
||||
private extractReleaseDocument(html: string): {
|
||||
reference: string;
|
||||
issuedAt: string;
|
||||
notice: string;
|
||||
clause: string;
|
||||
rows: Array<[string, string]>;
|
||||
} {
|
||||
const textFromHtml = (value: string) => this.htmlToPlainText(value).replace(/\n/g, ' ').trim();
|
||||
const reference = textFromHtml(html.match(/<strong>([\s\S]*?)<\/strong>/i)?.[1] ?? 'DO');
|
||||
const issuedAt = textFromHtml(html.match(/Issued:\s*([^<]+)/i)?.[1] ?? '-');
|
||||
const notice = textFromHtml(
|
||||
html.match(/<div class="notice">([\s\S]*?)<\/div>/i)?.[1] ??
|
||||
'This clearance document confirms that the listed booking/goods are authorized for warehouse exit, subject to gate identity verification and confirmation that no blocking warehouse fees remain unpaid.',
|
||||
);
|
||||
const clause = textFromHtml(
|
||||
html.match(/<div class="clause">([\s\S]*?)<\/div>/i)?.[1] ??
|
||||
'The warehouse officer and gate staff shall verify this document against the booking reference, customer or driver identity, cargo details, clearance status, and payment records before permitting exit from the warehouse premises.',
|
||||
);
|
||||
const rows: Array<[string, string]> = [];
|
||||
for (const match of html.matchAll(/<tr><th>([\s\S]*?)<\/th><td>([\s\S]*?)<\/td><\/tr>/gi)) {
|
||||
rows.push([textFromHtml(match[1]), textFromHtml(match[2])]);
|
||||
}
|
||||
return { reference, issuedAt, notice, clause, rows };
|
||||
}
|
||||
|
||||
private htmlToPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<script[\s\S]*?<\/script>/gi, '')
|
||||
@@ -203,24 +253,36 @@ export class WarehouseReleaseDocumentService {
|
||||
return `BT\n${color} rg\n/${font} ${size} Tf\n${x} ${y} Td\n(${this.escapePdfText(text)}) Tj\nET`;
|
||||
}
|
||||
|
||||
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18'): string {
|
||||
return `q\n${color} RG\n0.8 w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
private lineOp(x1: number, y1: number, x2: number, y2: number, color = '0.08 0.32 0.18', width = 0.8): string {
|
||||
return `q\n${color} RG\n${width} w\n${x1} ${y1} m\n${x2} ${y2} l\nS\nQ`;
|
||||
}
|
||||
|
||||
private circularSealOps(cx: number, cy: number): string {
|
||||
private rectOp(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
fillColor = '1 1 1',
|
||||
strokeColor = '0.08 0.32 0.18',
|
||||
lineWidth = 0.8,
|
||||
): string {
|
||||
return `q\n${fillColor} rg\n${strokeColor} RG\n${lineWidth} w\n${x} ${y} ${width} ${height} re\nB\nQ`;
|
||||
}
|
||||
|
||||
private circularSealOps(cx: number, cy: number, radius = 51): string {
|
||||
return [
|
||||
'q',
|
||||
'0.08 0.32 0.18 RG',
|
||||
'0.08 0.32 0.18 rg',
|
||||
'2.2 w',
|
||||
this.circlePath(cx, cy, 51),
|
||||
this.circlePath(cx, cy, radius),
|
||||
'S',
|
||||
'0.8 w',
|
||||
this.circlePath(cx, cy, 41),
|
||||
this.circlePath(cx, cy, radius - 10),
|
||||
'S',
|
||||
this.textOp('EDR', cx - 14, cy + 18, 14, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE', cx - 31, cy + 2, 9, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('CLEARED', cx - 27, cy - 16, 11, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('EDR', cx - 11, cy + 13, 11, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('WAREHOUSE', cx - 25, cy, 7.5, 'F2', '0.08 0.32 0.18'),
|
||||
this.textOp('CLEARED', cx - 21, cy - 13, 9, 'F2', '0.08 0.32 0.18'),
|
||||
'Q',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { FilesModule } from '../files/files.module';
|
||||
import { InterchangeDocumentsModule } from '../interchange-documents/interchange-documents.module';
|
||||
import { LastMileModule } from '../last-mile/last-mile.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
|
||||
import { WarehouseAllocationRule } from './entities/warehouse-allocation-rule.entity';
|
||||
import { WarehouseFeeInvoice } from './entities/warehouse-fee-invoice.entity';
|
||||
@@ -71,6 +72,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
FilesModule,
|
||||
InterchangeDocumentsModule,
|
||||
forwardRef(() => LastMileModule),
|
||||
NotificationsModule,
|
||||
ExchangeModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService): ExchangeOptions =>
|
||||
@@ -122,6 +124,7 @@ import { WarehousesService } from './warehouses.service';
|
||||
WarehouseAllocationService,
|
||||
WarehouseFeeService,
|
||||
WarehouseSchedulingAdapterService,
|
||||
WarehouseReleaseDocumentService,
|
||||
],
|
||||
})
|
||||
export class WarehousesModule {}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '../app.module';
|
||||
import { ApprovedFirstLastMileDemoBookingsSeeder } from '../seed/approved-first-lastmile-demo-bookings.seeder';
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
const seeder = app.get(ApprovedFirstLastMileDemoBookingsSeeder);
|
||||
await seeder.run();
|
||||
console.log('Approved first/last-mile demo bookings seeded.');
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Approved first/last-mile demo booking seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -21,8 +21,18 @@ import { WarehouseYard } from '../modules/warehouses/entities/warehouse-yard.ent
|
||||
import { WarehouseZone } from '../modules/warehouses/entities/warehouse-zone.entity';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
|
||||
const TRAIN_NUMBER = 'ICD-DEMO-EXP-DJ-01';
|
||||
const BOOKING_REFS = ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'];
|
||||
const DEMO_TRAINS = [
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-01',
|
||||
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
|
||||
arrivalOffsetHours: 1,
|
||||
},
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-02',
|
||||
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
|
||||
arrivalOffsetHours: 2,
|
||||
},
|
||||
];
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
@@ -44,13 +54,6 @@ async function main() {
|
||||
const scheduleRepo = dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
|
||||
if (existingSchedule) {
|
||||
console.log(`Export Djibouti interchange demo already seeded: ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
@@ -83,10 +86,6 @@ async function main() {
|
||||
throw new Error(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const departure = new Date(now - 6 * 60 * 60 * 1000);
|
||||
const arrival = new Date(now - 60 * 60 * 1000);
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
@@ -97,83 +96,106 @@ async function main() {
|
||||
}),
|
||||
));
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 700,
|
||||
totalLengthMeters: 360,
|
||||
wagonCount: 12,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
const now = Date.now();
|
||||
const seededSchedules: TrainSchedule[] = [];
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: TRAIN_NUMBER,
|
||||
}),
|
||||
);
|
||||
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
|
||||
if (existingSchedule) {
|
||||
console.log(`Export Djibouti interchange demo already seeded: ${demo.trainNumber}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
seededSchedules.push(existingSchedule);
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [index, reference] of BOOKING_REFS.entries()) {
|
||||
const weight = 5200 + index * 800;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: index % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType ? null : `Export Djibouti interchange demo cargo ${index + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
|
||||
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 700 + trainIndex * 80,
|
||||
totalLengthMeters: 360 + trainIndex * 20,
|
||||
wagonCount: 12 + trainIndex,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
warehouseId: warehouse!.id,
|
||||
yardId: warehouseYard!.id,
|
||||
zoneId: warehouseZone!.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: 'DISPATCHED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: new Date(now - 4 * 60 * 60 * 1000),
|
||||
inspectedAt: new Date(now - 3 * 60 * 60 * 1000),
|
||||
readyForLoadingAt: new Date(now - 2 * 60 * 60 * 1000),
|
||||
loadedAt: new Date(now - 90 * 60 * 1000),
|
||||
dispatchedAt: new Date(now - 70 * 60 * 1000),
|
||||
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: 'EXPORT',
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType
|
||||
? null
|
||||
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
warehouseId: warehouse!.id,
|
||||
yardId: warehouseYard!.id,
|
||||
zoneId: warehouseZone!.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: 'DISPATCHED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
|
||||
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
|
||||
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
|
||||
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
|
||||
dispatchedAt: departure,
|
||||
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
seededSchedules.push(schedule);
|
||||
}
|
||||
|
||||
console.log('Export Djibouti interchange demo seeded.');
|
||||
console.log(`Train number: ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
for (const schedule of seededSchedules) {
|
||||
console.log(`Train number: ${schedule.trainNumber}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
}
|
||||
console.log('Open Djibouti Unloading, click "Auto Unload Export Items", then check Interchange Documents.');
|
||||
} finally {
|
||||
await app.close();
|
||||
|
||||
219
apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts
Normal file
219
apps/edr-freight-api/src/scripts/seed-import-djibouti-demo.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '../app.module';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { ImportDjiboutiOperation } from '../modules/train-scheduling/entities/import-djibouti-operation.entity';
|
||||
import { CargoType } from '../modules/rule-engine/entities/cargo-type.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';
|
||||
|
||||
const DEMO_TRAINS = [
|
||||
{
|
||||
trainNumber: 'IMP-DJB-NAGAD-01',
|
||||
bookingRefs: ['IMP-DJB-NGD-001', 'IMP-DJB-NGD-002', 'IMP-DJB-NGD-003'],
|
||||
departureOffsetHours: 4,
|
||||
},
|
||||
{
|
||||
trainNumber: 'IMP-DJB-NAGAD-02',
|
||||
bookingRefs: ['IMP-DJB-NGD-004', 'IMP-DJB-NGD-005', 'IMP-DJB-NGD-006'],
|
||||
departureOffsetHours: 8,
|
||||
},
|
||||
];
|
||||
|
||||
function addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const app = await NestFactory.createApplicationContext(AppModule, {
|
||||
logger: ['error', 'warn', 'log'],
|
||||
});
|
||||
|
||||
try {
|
||||
const dataSource = app.get(DataSource);
|
||||
const yardRepo = dataSource.getRepository(Yard);
|
||||
const serviceTypeRepo = dataSource.getRepository(ServiceType);
|
||||
const cargoTypeRepo = dataSource.getRepository(CargoType);
|
||||
const locomotiveRepo = dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = dataSource.getRepository(TrainSet);
|
||||
const scheduleRepo = dataSource.getRepository(TrainSchedule);
|
||||
const bookingRepo = dataSource.getRepository(Booking);
|
||||
const scheduleBookingRepo = dataSource.getRepository(TrainScheduleBooking);
|
||||
const importOperationRepo = dataSource.getRepository(ImportDjiboutiOperation);
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'NAGAD' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const destinationYard =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'INDODE',
|
||||
label: 'Indode Dry Port',
|
||||
country: 'Ethiopia',
|
||||
isActive: true,
|
||||
displayOrder: 6,
|
||||
}),
|
||||
));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
|
||||
const missing = [
|
||||
!originYard ? 'NAGAD/Djibouti origin yard' : '',
|
||||
!destinationYard ? 'INDODE destination yard' : '',
|
||||
!serviceType ? 'service type' : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(`Cannot seed Djibouti-side import demo, missing: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-IMP-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'ICD-DEMO-IMP-LOCO',
|
||||
name: 'Djibouti Import Demo Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'IMPORT_READY',
|
||||
currentYardId: originYard!.id,
|
||||
}),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const seededSchedules: TrainSchedule[] = [];
|
||||
|
||||
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||
const existingSchedule = await scheduleRepo.findOne({
|
||||
where: { trainNumber: demo.trainNumber },
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
console.log(`Djibouti-side import demo already seeded: ${demo.trainNumber}`);
|
||||
console.log(`Schedule ID: ${existingSchedule.id}`);
|
||||
seededSchedules.push(existingSchedule);
|
||||
continue;
|
||||
}
|
||||
|
||||
const departure = addHours(now, demo.departureOffsetHours);
|
||||
const arrival = addHours(departure, 12);
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 900 + trainIndex * 120,
|
||||
totalLengthMeters: 430 + trainIndex * 25,
|
||||
wagonCount: 18 + trainIndex * 2,
|
||||
status: 'ASSIGNED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
status: 'SCHEDULED' as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: 'IMPORT',
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||
const weight = 6800 + trainIndex * 900 + bookingIndex * 750;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'PAID',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: departure,
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'IMPORT',
|
||||
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType
|
||||
? null
|
||||
: `Djibouti import demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
priorityScore: 80 - trainIndex * 5 - bookingIndex,
|
||||
trainScheduleId: schedule.id,
|
||||
schedulingStatus: 'SCHEDULED',
|
||||
scheduledAt: now,
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await importOperationRepo.save(
|
||||
importOperationRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
documents: {
|
||||
DELIVERY_ORDER: {
|
||||
reference: `DO-${demo.trainNumber}`,
|
||||
uploadedAt: now.toISOString(),
|
||||
uploadedBy: 'Demo Seeder',
|
||||
notes: 'Demo delivery order for Djibouti-side import flow',
|
||||
},
|
||||
RAILWAY_BILL: {
|
||||
reference: `RB-${demo.trainNumber}`,
|
||||
uploadedAt: now.toISOString(),
|
||||
uploadedBy: 'Demo Seeder',
|
||||
notes: 'Demo railway bill for Djibouti-side import flow',
|
||||
},
|
||||
},
|
||||
performedBy: 'Demo Seeder',
|
||||
notes: '[ICD-DEMO] Nagad to Indode import train for Djibouti-side flow',
|
||||
}),
|
||||
);
|
||||
|
||||
seededSchedules.push(schedule);
|
||||
}
|
||||
|
||||
console.log('Djibouti-side import demo seeded.');
|
||||
console.log(`Corridor: ${originYard!.code} -> ${destinationYard!.code}`);
|
||||
for (const schedule of seededSchedules) {
|
||||
console.log(`Train number: ${schedule.trainNumber}`);
|
||||
console.log(`Schedule ID: ${schedule.id}`);
|
||||
console.log(`Backoffice URL: /dashboard/operations/train-scheduling-v2/${schedule.id}`);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Djibouti-side import demo seed failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import 'reflect-metadata';
|
||||
import { config } from 'dotenv';
|
||||
import { resolve } from 'path';
|
||||
|
||||
config({ path: resolve(__dirname, '../../.env') });
|
||||
|
||||
import { AppDataSource } from '../data-source';
|
||||
import { Locomotive } from '../modules/locomotives/entities/locomotive.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
import { TrainSchedule } from '../modules/train-schedules/entities/train-schedule.entity';
|
||||
import { TrainSet } from '../modules/train-sets/entities/train-set.entity';
|
||||
|
||||
const TRAIN_NUMBER = 'NEGAD-INDODE-ARR-01';
|
||||
|
||||
function addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const dataSource = await AppDataSource.initialize();
|
||||
|
||||
try {
|
||||
await dataSource.transaction(async (manager) => {
|
||||
const yardRepo = manager.getRepository(Yard);
|
||||
const locomotiveRepo = manager.getRepository(Locomotive);
|
||||
const trainSetRepo = manager.getRepository(TrainSet);
|
||||
const scheduleRepo = manager.getRepository(TrainSchedule);
|
||||
|
||||
const negad =
|
||||
(await yardRepo.findOne({ where: { code: 'NEGAD' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'NEGAD',
|
||||
label: 'Negad',
|
||||
country: 'Djibouti',
|
||||
isActive: true,
|
||||
displayOrder: 5,
|
||||
}),
|
||||
));
|
||||
|
||||
const indode =
|
||||
(await yardRepo.findOne({ where: { code: 'INDODE' } })) ??
|
||||
(await yardRepo.save(
|
||||
yardRepo.create({
|
||||
code: 'INDODE',
|
||||
label: 'Indode Dry Port',
|
||||
country: 'Ethiopia',
|
||||
isActive: true,
|
||||
displayOrder: 6,
|
||||
}),
|
||||
));
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'NEGAD-INDODE-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'NEGAD-INDODE-LOCO',
|
||||
name: 'Negad to Indode Demo Locomotive',
|
||||
locomotiveType: 'DIESEL',
|
||||
maxPullWeightTons: 4200,
|
||||
maxTrainLengthMeters: 760,
|
||||
status: 'AVAILABLE',
|
||||
currentYardId: indode.id,
|
||||
}),
|
||||
));
|
||||
|
||||
const now = new Date();
|
||||
const departure = addHours(now, -12);
|
||||
const arrival = now;
|
||||
|
||||
let schedule = await scheduleRepo.findOne({ where: { trainNumber: TRAIN_NUMBER } });
|
||||
if (!schedule) {
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 960,
|
||||
totalLengthMeters: 420,
|
||||
wagonCount: 18,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
schedule = scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: negad.id,
|
||||
destinationStationId: indode.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualDepartureAt: departure,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: TRAIN_NUMBER,
|
||||
direction: 'IMPORT',
|
||||
maxWagons: 53,
|
||||
bookingWindowStatus: 'CLOSED',
|
||||
});
|
||||
} else {
|
||||
schedule.originStationId = negad.id;
|
||||
schedule.destinationStationId = indode.id;
|
||||
schedule.scheduledDepartureDate = departure;
|
||||
schedule.scheduledArrivalDate = arrival;
|
||||
schedule.actualDepartureAt = departure;
|
||||
schedule.actualArrivalAt = arrival;
|
||||
schedule.status = 'ARRIVED' as TrainSchedule['status'];
|
||||
schedule.direction = 'IMPORT';
|
||||
schedule.bookingWindowStatus = 'CLOSED';
|
||||
|
||||
if (schedule.trainSetId) {
|
||||
await trainSetRepo.update(schedule.trainSetId, { status: 'COMPLETED' });
|
||||
}
|
||||
}
|
||||
|
||||
const saved = await scheduleRepo.save(schedule);
|
||||
console.log(`Seeded ARRIVED train ${TRAIN_NUMBER}`);
|
||||
console.log(`Schedule ID: ${saved.id}`);
|
||||
console.log(`Route: ${negad.code} -> ${indode.code}`);
|
||||
});
|
||||
} finally {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Negad to Indode arrived train seed failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,376 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { DataSource, In } from 'typeorm';
|
||||
|
||||
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
|
||||
import { Booking } from '../modules/bookings/entities/booking.entity';
|
||||
import {
|
||||
Company,
|
||||
CompanyStatus,
|
||||
CompanyType,
|
||||
} from '../modules/companies/entities/company.entity';
|
||||
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
|
||||
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
|
||||
import { ContainerType } from '../modules/rule-engine/entities/container-type.entity';
|
||||
import { ServiceType } from '../modules/rule-engine/entities/service-type.entity';
|
||||
import { Yard } from '../modules/rule-engine/entities/yard.entity';
|
||||
|
||||
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_FIRST_LAST';
|
||||
const COMPANY_TIN = 'FLMDEMO001';
|
||||
const COMPANY_EMAIL = 'first-last-mile-demo@edr.local';
|
||||
|
||||
const YARDS = [
|
||||
{ code: 'DJIBOUTI', label: 'Djibouti', country: 'Djibouti', displayOrder: 1 },
|
||||
{ code: 'ADDIS_ABABA', label: 'Addis Ababa', country: 'Ethiopia', displayOrder: 2 },
|
||||
];
|
||||
|
||||
const CONTAINER_TYPES = [
|
||||
{ code: '20FT', label: '20FT', sizeFt: 20 },
|
||||
{ code: '40FT', label: '40FT', sizeFt: 40 },
|
||||
];
|
||||
|
||||
const DEMO_BOOKINGS = [
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-001',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 8,
|
||||
totalWeightTons: 224,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-01T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Doraleh Container Terminal, Djibouti',
|
||||
firstMilePickupLat: 11.5881,
|
||||
firstMilePickupLng: 43.1372,
|
||||
lastMileDeliveryAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.8808,
|
||||
lastMileDeliveryLng: 38.7876,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-002',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 12,
|
||||
totalWeightTons: 240,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-02T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'PK12 Dry Port, Djibouti',
|
||||
firstMilePickupLat: 11.5536,
|
||||
firstMilePickupLng: 43.1103,
|
||||
lastMileDeliveryAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.9137,
|
||||
lastMileDeliveryLng: 38.7815,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-003',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 6,
|
||||
totalWeightTons: 180,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-03T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Djibouti Free Zone Yard',
|
||||
firstMilePickupLat: 11.5947,
|
||||
firstMilePickupLng: 43.1471,
|
||||
lastMileDeliveryAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||
lastMileDeliveryLat: 8.9806,
|
||||
lastMileDeliveryLng: 38.8736,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-004',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 10,
|
||||
totalWeightTons: 210,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-04T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Doraleh Multipurpose Port, Djibouti',
|
||||
firstMilePickupLat: 11.6062,
|
||||
firstMilePickupLng: 43.1219,
|
||||
lastMileDeliveryAddress: 'Addis Ababa Freight Terminal',
|
||||
lastMileDeliveryLat: 9.0101,
|
||||
lastMileDeliveryLng: 38.7619,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-IMP-FLM-005',
|
||||
tradeDirection: 'IMPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 5,
|
||||
totalWeightTons: 150,
|
||||
originCode: 'DJIBOUTI',
|
||||
destinationCode: 'ADDIS_ABABA',
|
||||
scheduledDate: '2026-07-05T08:00:00.000Z',
|
||||
firstMilePickupAddress: 'Djibouti Port Gate 3',
|
||||
firstMilePickupLat: 11.5999,
|
||||
firstMilePickupLng: 43.1344,
|
||||
lastMileDeliveryAddress: 'Sebeta Distribution Center',
|
||||
lastMileDeliveryLat: 8.9169,
|
||||
lastMileDeliveryLng: 38.6177,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-001',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 7,
|
||||
totalWeightTons: 196,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-01T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Bole Lemi Industrial Park, Addis Ababa',
|
||||
firstMilePickupLat: 8.9806,
|
||||
firstMilePickupLng: 38.8736,
|
||||
lastMileDeliveryAddress: 'Doraleh Container Terminal, Djibouti',
|
||||
lastMileDeliveryLat: 11.5881,
|
||||
lastMileDeliveryLng: 43.1372,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-002',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 11,
|
||||
totalWeightTons: 220,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-02T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Akaki Industrial Zone, Addis Ababa',
|
||||
firstMilePickupLat: 8.8808,
|
||||
firstMilePickupLng: 38.7876,
|
||||
lastMileDeliveryAddress: 'Djibouti Free Zone Yard',
|
||||
lastMileDeliveryLat: 11.5947,
|
||||
lastMileDeliveryLng: 43.1471,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-003',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 4,
|
||||
totalWeightTons: 128,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-03T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Kality Logistics Hub, Addis Ababa',
|
||||
firstMilePickupLat: 8.9137,
|
||||
firstMilePickupLng: 38.7815,
|
||||
lastMileDeliveryAddress: 'Doraleh Multipurpose Port, Djibouti',
|
||||
lastMileDeliveryLat: 11.6062,
|
||||
lastMileDeliveryLng: 43.1219,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-004',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '20FT',
|
||||
quantity: 9,
|
||||
totalWeightTons: 180,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-04T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Addis Ababa Freight Terminal',
|
||||
firstMilePickupLat: 9.0101,
|
||||
firstMilePickupLng: 38.7619,
|
||||
lastMileDeliveryAddress: 'PK12 Dry Port, Djibouti',
|
||||
lastMileDeliveryLat: 11.5536,
|
||||
lastMileDeliveryLng: 43.1103,
|
||||
},
|
||||
{
|
||||
reference: 'DEMO-EXP-FLM-005',
|
||||
tradeDirection: 'EXPORT',
|
||||
containerCode: '40FT',
|
||||
quantity: 6,
|
||||
totalWeightTons: 168,
|
||||
originCode: 'ADDIS_ABABA',
|
||||
destinationCode: 'DJIBOUTI',
|
||||
scheduledDate: '2026-07-05T10:00:00.000Z',
|
||||
firstMilePickupAddress: 'Sebeta Distribution Center',
|
||||
firstMilePickupLat: 8.9169,
|
||||
firstMilePickupLng: 38.6177,
|
||||
lastMileDeliveryAddress: 'Djibouti Port Gate 3',
|
||||
lastMileDeliveryLat: 11.5999,
|
||||
lastMileDeliveryLng: 43.1344,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@Injectable()
|
||||
export class ApprovedFirstLastMileDemoBookingsSeeder {
|
||||
private readonly logger = new Logger(ApprovedFirstLastMileDemoBookingsSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run() {
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.getRepository(Yard).upsert(
|
||||
YARDS.map((yard) => ({ ...yard, isActive: true })),
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(ServiceType).upsert(
|
||||
{
|
||||
code: SERVICE_TYPE_CODE,
|
||||
serviceName: 'Rail Container with First and Last Mile',
|
||||
description: 'Demo service type for approved first/last-mile bookings',
|
||||
canBeBookedAlone: true,
|
||||
includesFirstMile: true,
|
||||
includesLastMile: true,
|
||||
includesCustoms: false,
|
||||
priorityBonusPoints: 0,
|
||||
isActive: true,
|
||||
displayOrder: 10,
|
||||
},
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(ContainerType).upsert(
|
||||
CONTAINER_TYPES.map((containerType, index) => ({
|
||||
...containerType,
|
||||
wagonsPerUnit: 1,
|
||||
isReefer: false,
|
||||
isOpenTop: false,
|
||||
isActive: true,
|
||||
displayOrder: index + 1,
|
||||
})),
|
||||
{ conflictPaths: { code: true } },
|
||||
);
|
||||
|
||||
await manager.getRepository(Company).upsert(
|
||||
{
|
||||
name: 'First and Last Mile Demo Customer',
|
||||
type: CompanyType.Customer,
|
||||
status: CompanyStatus.Active,
|
||||
tin: COMPANY_TIN,
|
||||
vatNumber: COMPANY_TIN,
|
||||
fanNumber: 'FLM0000000000001',
|
||||
country: 'Ethiopia',
|
||||
address: 'Addis Ababa',
|
||||
phone: '251900000101',
|
||||
email: COMPANY_EMAIL,
|
||||
website: null,
|
||||
contactPersonName: 'First Last Mile Demo',
|
||||
contactPersonPhone: '251900000101',
|
||||
generalManagerName: 'Demo Manager',
|
||||
generalManagerEmail: COMPANY_EMAIL,
|
||||
generalManagerPhone: '251900000101',
|
||||
},
|
||||
{ conflictPaths: { tin: true } },
|
||||
);
|
||||
|
||||
const [serviceType, company, yards, containerTypes] = await Promise.all([
|
||||
manager.getRepository(ServiceType).findOneByOrFail({ code: SERVICE_TYPE_CODE }),
|
||||
manager.getRepository(Company).findOneByOrFail({ tin: COMPANY_TIN }),
|
||||
manager.getRepository(Yard).find(),
|
||||
manager.getRepository(ContainerType).find(),
|
||||
]);
|
||||
|
||||
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
|
||||
const containerTypeByCode = new Map(
|
||||
containerTypes.map((containerType) => [containerType.code, containerType]),
|
||||
);
|
||||
|
||||
for (const demoBooking of DEMO_BOOKINGS) {
|
||||
const origin = yardByCode.get(demoBooking.originCode);
|
||||
const destination = yardByCode.get(demoBooking.destinationCode);
|
||||
const containerType = containerTypeByCode.get(demoBooking.containerCode);
|
||||
|
||||
if (!origin || !destination || !containerType) {
|
||||
throw new Error(`approved_first_last_mile_demo_dependency_missing:${demoBooking.reference}`);
|
||||
}
|
||||
|
||||
const wagonsRequired =
|
||||
Number(demoBooking.quantity) * Number(containerType.wagonsPerUnit ?? 1);
|
||||
const vgmPerUnitTons = demoBooking.totalWeightTons / demoBooking.quantity;
|
||||
|
||||
await manager.getRepository(Booking).upsert(
|
||||
{
|
||||
reference: demoBooking.reference,
|
||||
companyId: company.id,
|
||||
status: 'APPROVED',
|
||||
scheduledDate: new Date(demoBooking.scheduledDate),
|
||||
estimatedShipmentDate: new Date(demoBooking.scheduledDate),
|
||||
totalAmount: demoBooking.totalWeightTons * 25,
|
||||
paymentStatus: 'PENDING',
|
||||
contractType: 'NEW',
|
||||
serviceTypeId: serviceType.id,
|
||||
firstMilePickupAddress: demoBooking.firstMilePickupAddress,
|
||||
firstMilePickupLat: demoBooking.firstMilePickupLat,
|
||||
firstMilePickupLng: demoBooking.firstMilePickupLng,
|
||||
lastMileDeliveryAddress: demoBooking.lastMileDeliveryAddress,
|
||||
lastMileDeliveryLat: demoBooking.lastMileDeliveryLat,
|
||||
lastMileDeliveryLng: demoBooking.lastMileDeliveryLng,
|
||||
equipmentReturn: 'WITHOUT_RETURN',
|
||||
originYardId: origin.id,
|
||||
destinationYardId: destination.id,
|
||||
tradeDirection: demoBooking.tradeDirection,
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
cargoFreeText: 'Demo container cargo',
|
||||
shippingLineId: null,
|
||||
cargoTotalWeightVgm: demoBooking.totalWeightTons,
|
||||
isHazardous: false,
|
||||
isReefer: false,
|
||||
paymentCurrency: 'ETB',
|
||||
approvedByStaffAt: new Date(),
|
||||
priorityScore: 20,
|
||||
wagonsRequired,
|
||||
schedulingStatus: 'NOT_SCHEDULED',
|
||||
versionNumber: 1,
|
||||
},
|
||||
{ conflictPaths: { reference: true } },
|
||||
);
|
||||
|
||||
const booking = await manager.getRepository(Booking).findOneByOrFail({
|
||||
reference: demoBooking.reference,
|
||||
});
|
||||
|
||||
await manager.getRepository(BookingContainer).delete({ bookingId: booking.id });
|
||||
await manager.getRepository(BookingContainer).insert({
|
||||
id: randomUUID(),
|
||||
bookingId: booking.id,
|
||||
containerTypeId: containerType.id,
|
||||
quantity: demoBooking.quantity,
|
||||
vgmPerUnitTons,
|
||||
totalVgmTons: demoBooking.totalWeightTons,
|
||||
wagonsRequired,
|
||||
weightLimitRuleId: null,
|
||||
isOverweight: vgmPerUnitTons > 35,
|
||||
overweightExcessTons: vgmPerUnitTons > 35 ? vgmPerUnitTons - 35 : null,
|
||||
});
|
||||
}
|
||||
|
||||
const bookings = await manager.getRepository(Booking).find({
|
||||
where: { reference: In(DEMO_BOOKINGS.map((booking) => booking.reference)) },
|
||||
select: { id: true },
|
||||
});
|
||||
const bookingIds = bookings.map((booking) => booking.id);
|
||||
|
||||
await manager.getRepository(FirstMile).delete({ bookingId: In(bookingIds) });
|
||||
await manager.getRepository(LastMile).delete({ bookingId: In(bookingIds) });
|
||||
|
||||
await manager.getRepository(FirstMile).insert(
|
||||
bookingIds.map((bookingId) => ({
|
||||
bookingId,
|
||||
status: 'READY_TO_TRANSIT',
|
||||
advancedPayment: 0,
|
||||
remainingPayment: 0,
|
||||
estimatedKm: 18,
|
||||
exactKm: null,
|
||||
vehicleId: null,
|
||||
})),
|
||||
);
|
||||
|
||||
await manager.getRepository(LastMile).insert(
|
||||
bookingIds.map((bookingId) => ({
|
||||
bookingId,
|
||||
status: 'READY_TO_TRANSIT',
|
||||
advancedPayment: 0,
|
||||
remainingPayment: 0,
|
||||
estimatedKm: 22,
|
||||
exactKm: null,
|
||||
vehicleId: null,
|
||||
})),
|
||||
);
|
||||
});
|
||||
|
||||
this.logger.log('Seeded 5 import and 5 export approved bookings with first/last-mile legs');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
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 { CargoType } from '../modules/rule-engine/entities/cargo-type.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 { 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';
|
||||
import { Warehouse } from '../modules/warehouses/entities/warehouse.entity';
|
||||
|
||||
const SEED_FLAG = 'SEED_EXPORT_DJIBOUTI_INTERCHANGE_DEMO';
|
||||
|
||||
const DEMO_TRAINS = [
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-01',
|
||||
bookingRefs: ['ICD-DEMO-EXP-001', 'ICD-DEMO-EXP-002', 'ICD-DEMO-EXP-003'],
|
||||
arrivalOffsetHours: 1,
|
||||
},
|
||||
{
|
||||
trainNumber: 'ICD-DEMO-EXP-DJ-02',
|
||||
bookingRefs: ['ICD-DEMO-EXP-004', 'ICD-DEMO-EXP-005', 'ICD-DEMO-EXP-006'],
|
||||
arrivalOffsetHours: 2,
|
||||
},
|
||||
];
|
||||
|
||||
@Injectable()
|
||||
export class ExportDjiboutiInterchangeDemoSeeder {
|
||||
private readonly logger = new Logger(ExportDjiboutiInterchangeDemoSeeder.name);
|
||||
|
||||
constructor(private readonly dataSource: DataSource) {}
|
||||
|
||||
async run(): Promise<void> {
|
||||
if (process.env[SEED_FLAG]?.trim().toLowerCase() !== 'true') {
|
||||
this.logger.log(`Skipping export Djibouti interchange demo seed because ${SEED_FLAG} is not enabled`);
|
||||
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 warehouseYardRepo = this.dataSource.getRepository(WarehouseYard);
|
||||
const warehouseZoneRepo = this.dataSource.getRepository(WarehouseZone);
|
||||
const bookingRepo = this.dataSource.getRepository(Booking);
|
||||
const inventoryRepo = this.dataSource.getRepository(WarehouseInventory);
|
||||
const locomotiveRepo = this.dataSource.getRepository(Locomotive);
|
||||
const trainSetRepo = this.dataSource.getRepository(TrainSet);
|
||||
const scheduleRepo = this.dataSource.getRepository(TrainSchedule);
|
||||
const scheduleBookingRepo = this.dataSource.getRepository(TrainScheduleBooking);
|
||||
|
||||
const originYard =
|
||||
(await yardRepo.findOne({ where: { code: 'MOJO' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Ethiopia' } }));
|
||||
const destinationYard =
|
||||
(await yardRepo.findOne({ where: { code: 'DJIB_PORT' } })) ??
|
||||
(await yardRepo.findOne({ where: { code: 'DJIBOUTI' } })) ??
|
||||
(await yardRepo.findOne({ where: { country: 'Djibouti' } }));
|
||||
const serviceType =
|
||||
(await serviceTypeRepo.findOne({ where: { code: 'RAIL_CONTAINER' } })) ??
|
||||
(await serviceTypeRepo.findOne({ where: { isActive: true } }));
|
||||
const cargoType = await cargoTypeRepo.findOne({ where: { isActive: true } });
|
||||
const warehouse = await warehouseRepo.findOne({ where: { code: 'INDODE_OPEN' } });
|
||||
const warehouseYard = warehouse
|
||||
? await warehouseYardRepo.findOne({ where: { warehouseId: warehouse.id } })
|
||||
: null;
|
||||
const warehouseZone = warehouseYard
|
||||
? await warehouseZoneRepo.findOne({ where: { yardId: warehouseYard.id } })
|
||||
: null;
|
||||
|
||||
const missing = [
|
||||
!originYard ? 'Ethiopian origin yard' : '',
|
||||
!destinationYard ? 'Djibouti destination yard' : '',
|
||||
!serviceType ? 'service type' : '',
|
||||
!warehouse ? 'INDODE_OPEN warehouse' : '',
|
||||
!warehouseYard ? 'warehouse yard' : '',
|
||||
!warehouseZone ? 'warehouse zone' : '',
|
||||
].filter(Boolean);
|
||||
|
||||
if (missing.length) {
|
||||
this.logger.warn(`Cannot seed export Djibouti interchange demo, missing: ${missing.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const locomotive =
|
||||
(await locomotiveRepo.findOne({ where: { code: 'ICD-DEMO-LOCO' } })) ??
|
||||
(await locomotiveRepo.save(
|
||||
locomotiveRepo.create({
|
||||
code: 'ICD-DEMO-LOCO',
|
||||
name: 'Interchange Demo Locomotive',
|
||||
maxPullWeightTons: 4000,
|
||||
}),
|
||||
));
|
||||
|
||||
const now = Date.now();
|
||||
let seeded = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const [trainIndex, demo] of DEMO_TRAINS.entries()) {
|
||||
const existingSchedule = await scheduleRepo.findOne({ where: { trainNumber: demo.trainNumber } });
|
||||
if (existingSchedule) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const arrival = new Date(now - demo.arrivalOffsetHours * 60 * 60 * 1000);
|
||||
const departure = new Date(arrival.getTime() - 5 * 60 * 60 * 1000);
|
||||
|
||||
const trainSet = await trainSetRepo.save(
|
||||
trainSetRepo.create({
|
||||
locomotiveId: locomotive.id,
|
||||
totalWeightTons: 700 + trainIndex * 80,
|
||||
totalLengthMeters: 360 + trainIndex * 20,
|
||||
wagonCount: 12 + trainIndex,
|
||||
status: 'COMPLETED',
|
||||
}),
|
||||
);
|
||||
|
||||
const schedule = await scheduleRepo.save(
|
||||
scheduleRepo.create({
|
||||
trainSetId: trainSet.id,
|
||||
originStationId: originYard!.id,
|
||||
destinationStationId: destinationYard!.id,
|
||||
scheduledDepartureDate: departure,
|
||||
scheduledArrivalDate: arrival,
|
||||
actualArrivalAt: arrival,
|
||||
status: 'ARRIVED' as TrainSchedule['status'],
|
||||
trainNumber: demo.trainNumber,
|
||||
direction: 'EXPORT',
|
||||
}),
|
||||
);
|
||||
|
||||
for (const [bookingIndex, reference] of demo.bookingRefs.entries()) {
|
||||
const weight = 5200 + trainIndex * 600 + bookingIndex * 800;
|
||||
const booking = await bookingRepo.save(
|
||||
bookingRepo.create({
|
||||
reference,
|
||||
originYardId: originYard!.id,
|
||||
destinationYardId: destinationYard!.id,
|
||||
serviceTypeId: serviceType!.id,
|
||||
status: 'IN_TRANSIT',
|
||||
paymentStatus: 'PAID',
|
||||
scheduledDate: new Date(),
|
||||
contractType: 'SPOT',
|
||||
equipmentReturn: 'TERMINAL',
|
||||
paymentCurrency: 'ETB',
|
||||
totalAmount: 0,
|
||||
isGovernment: false,
|
||||
tradeDirection: 'EXPORT',
|
||||
freightType: bookingIndex % 2 === 0 ? 'CONTAINER' : 'BULK',
|
||||
cargoTypeId: cargoType?.id ?? null,
|
||||
cargoFreeText: cargoType
|
||||
? null
|
||||
: `Export Djibouti interchange demo cargo ${trainIndex + 1}-${bookingIndex + 1}`,
|
||||
cargoTotalWeightVgm: weight,
|
||||
}),
|
||||
);
|
||||
|
||||
await inventoryRepo.save(
|
||||
inventoryRepo.create({
|
||||
warehouseId: warehouse!.id,
|
||||
yardId: warehouseYard!.id,
|
||||
zoneId: warehouseZone!.id,
|
||||
bookingId: booking.id,
|
||||
quantity: 1,
|
||||
weight,
|
||||
status: 'DISPATCHED',
|
||||
inspectionStatus: 'PASSED',
|
||||
arrivedAt: new Date(departure.getTime() + 2 * 60 * 60 * 1000),
|
||||
inspectedAt: new Date(departure.getTime() + 3 * 60 * 60 * 1000),
|
||||
readyForLoadingAt: new Date(departure.getTime() + 4 * 60 * 60 * 1000),
|
||||
loadedAt: new Date(departure.getTime() + 4.5 * 60 * 60 * 1000),
|
||||
dispatchedAt: departure,
|
||||
notes: '[ICD-DEMO] Eligible for Djibouti export unload and interchange generation',
|
||||
}),
|
||||
);
|
||||
|
||||
await scheduleBookingRepo.save(
|
||||
scheduleBookingRepo.create({
|
||||
trainScheduleId: schedule.id,
|
||||
bookingId: booking.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
seeded += 1;
|
||||
}
|
||||
|
||||
this.logger.log(`Export Djibouti interchange demo seed complete: ${seeded} train(s) seeded, ${skipped} skipped`);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`ExportDjiboutiInterchangeDemoSeeder failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
import { API_BASE_URL } from '@/constants/apiConfig';
|
||||
|
||||
interface Cargo {
|
||||
id: string;
|
||||
|
||||
@@ -58,6 +58,7 @@ interface TruckEntranceFormState {
|
||||
consigneeDetails: string;
|
||||
edrDigitalBookingId: string;
|
||||
tin: string;
|
||||
customerPhone: string;
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber: string;
|
||||
assignedEquipmentNumber: string;
|
||||
@@ -85,11 +86,27 @@ interface TruckEntranceFormState {
|
||||
warehouseManagerName: string;
|
||||
}
|
||||
|
||||
interface LockedTruckEntranceFields {
|
||||
ownerName?: boolean;
|
||||
consigneeDetails?: boolean;
|
||||
tin?: boolean;
|
||||
edrDigitalBookingId?: boolean;
|
||||
customerPhone?: boolean;
|
||||
assignedEquipmentNumber?: boolean;
|
||||
itemDescription?: boolean;
|
||||
packagingType?: boolean;
|
||||
unitCount?: boolean;
|
||||
grossWeightKg?: boolean;
|
||||
}
|
||||
|
||||
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
|
||||
|
||||
const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
ownerName: '',
|
||||
consigneeDetails: '',
|
||||
edrDigitalBookingId: '',
|
||||
tin: '',
|
||||
customerPhone: '',
|
||||
truckPlateNumber: '',
|
||||
trailerPlateNumber: '',
|
||||
assignedEquipmentNumber: '',
|
||||
@@ -118,22 +135,13 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
});
|
||||
|
||||
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
|
||||
ownerName: form.ownerName.trim() || undefined,
|
||||
consigneeDetails: form.consigneeDetails.trim() || undefined,
|
||||
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
|
||||
tin: form.tin.trim() || undefined,
|
||||
truckPlateNumber: form.truckPlateNumber.trim(),
|
||||
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||
incoterms: form.incoterms.trim() || undefined,
|
||||
hsCodes: form.hsCodes.trim() || undefined,
|
||||
itemCode: form.itemCode.trim() || undefined,
|
||||
itemDescription: form.itemDescription.trim() || undefined,
|
||||
packagingType: form.packagingType.trim() || undefined,
|
||||
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
||||
grossWeightKg: form.grossWeightKg === '' ? undefined : Number(form.grossWeightKg),
|
||||
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
|
||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||
@@ -149,13 +157,130 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||
});
|
||||
|
||||
const commonNonEmptyValue = (values: Array<string | null | undefined>) => {
|
||||
const unique = [...new Set(values.map((value) => value?.trim()).filter(Boolean))] as string[];
|
||||
return unique.length === 1 ? unique[0] : '';
|
||||
};
|
||||
|
||||
const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
form: TruckEntranceFormState;
|
||||
lockedFields: LockedTruckEntranceFields;
|
||||
packagingFreightType: PackagingFreightType;
|
||||
} => {
|
||||
const ownerName = commonNonEmptyValue(bookings.map((booking) => booking.customer));
|
||||
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
|
||||
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
|
||||
const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer));
|
||||
const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber));
|
||||
const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo));
|
||||
const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType));
|
||||
const edrDigitalBookingId =
|
||||
bookings.length === 1
|
||||
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
|
||||
: commonNonEmptyValue(bookings.map((booking) => booking.reference));
|
||||
const firstMileBooking = bookings.length === 1 ? bookings[0] : null;
|
||||
const unitCount =
|
||||
bookings.length === 1 && bookings[0]?.containerQuantity != null
|
||||
? Number(bookings[0].containerQuantity)
|
||||
: '';
|
||||
const grossWeightKg =
|
||||
bookings.length === 1 && bookings[0]?.weight != null
|
||||
? Number(bookings[0].weight)
|
||||
: '';
|
||||
const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
|
||||
const packagingFreightType =
|
||||
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
|
||||
? 'CONTAINER'
|
||||
: freightTypes.length === 1 && freightTypes[0] === 'BULK'
|
||||
? 'BULK'
|
||||
: 'MIXED';
|
||||
|
||||
return {
|
||||
form: {
|
||||
...emptyTruckEntrance(),
|
||||
ownerName,
|
||||
consigneeDetails,
|
||||
tin,
|
||||
customerPhone,
|
||||
edrDigitalBookingId,
|
||||
assignedEquipmentNumber,
|
||||
itemDescription,
|
||||
packagingType,
|
||||
unitCount,
|
||||
grossWeightKg,
|
||||
truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '',
|
||||
driverName: firstMileBooking?.firstMileDriverName ?? '',
|
||||
driverPhone: firstMileBooking?.firstMileDriverPhone ?? '',
|
||||
driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '',
|
||||
truckType: firstMileBooking?.firstMileTruckType ?? '',
|
||||
},
|
||||
lockedFields: {
|
||||
ownerName: Boolean(ownerName),
|
||||
consigneeDetails: Boolean(consigneeDetails),
|
||||
tin: Boolean(tin),
|
||||
edrDigitalBookingId: Boolean(edrDigitalBookingId),
|
||||
customerPhone: Boolean(customerPhone),
|
||||
assignedEquipmentNumber: Boolean(assignedEquipmentNumber),
|
||||
itemDescription: Boolean(itemDescription),
|
||||
packagingType: Boolean(packagingType),
|
||||
unitCount: unitCount !== '',
|
||||
grossWeightKg: grossWeightKg !== '',
|
||||
},
|
||||
packagingFreightType,
|
||||
};
|
||||
};
|
||||
|
||||
const BULK_PACKAGING_TYPE_OPTIONS = [
|
||||
{ value: 'BAG', label: 'Bag' },
|
||||
{ value: 'SACK', label: 'Sack' },
|
||||
{ value: 'BALE', label: 'Bale' },
|
||||
{ value: 'CARTON', label: 'Carton' },
|
||||
{ value: 'CRATE', label: 'Crate' },
|
||||
{ value: 'DRUM', label: 'Drum' },
|
||||
{ value: 'BARREL', label: 'Barrel' },
|
||||
{ value: 'PALLET', label: 'Pallet' },
|
||||
{ value: 'LOOSE_BULK', label: 'Loose bulk' },
|
||||
{ value: 'OTHER', label: 'Other' },
|
||||
];
|
||||
|
||||
const CONTAINER_PACKAGING_TYPE_OPTIONS = [
|
||||
{ value: 'CONTAINER_20FT', label: '20 ft container' },
|
||||
{ value: 'CONTAINER_40FT', label: '40 ft container' },
|
||||
{ value: 'CONTAINER_45FT', label: '45 ft container' },
|
||||
{ value: 'REEFER_CONTAINER', label: 'Reefer container' },
|
||||
{ value: 'TANK_CONTAINER', label: 'Tank container' },
|
||||
{ value: 'FLAT_RACK_CONTAINER', label: 'Flat rack container' },
|
||||
{ value: 'OPEN_TOP_CONTAINER', label: 'Open top container' },
|
||||
{ value: 'OTHER_CONTAINER', label: 'Other container' },
|
||||
];
|
||||
|
||||
const packagingOptionsFor = (freightType: PackagingFreightType) =>
|
||||
freightType === 'CONTAINER'
|
||||
? CONTAINER_PACKAGING_TYPE_OPTIONS
|
||||
: freightType === 'BULK'
|
||||
? BULK_PACKAGING_TYPE_OPTIONS
|
||||
: [...CONTAINER_PACKAGING_TYPE_OPTIONS, ...BULK_PACKAGING_TYPE_OPTIONS];
|
||||
|
||||
function TruckEntranceFields({
|
||||
value,
|
||||
onChange,
|
||||
lockedFields,
|
||||
packagingFreightType = 'MIXED',
|
||||
}: {
|
||||
value: TruckEntranceFormState;
|
||||
onChange: (next: TruckEntranceFormState) => void;
|
||||
lockedFields?: LockedTruckEntranceFields;
|
||||
packagingFreightType?: PackagingFreightType;
|
||||
}) {
|
||||
const packagingOptions = packagingOptionsFor(packagingFreightType);
|
||||
const quantityLabel =
|
||||
packagingFreightType === 'CONTAINER'
|
||||
? 'Container quantity'
|
||||
: packagingFreightType === 'BULK'
|
||||
? 'Unit count'
|
||||
: 'Quantity';
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text size="sm" fw={600}>Customer and cargo ownership</Text>
|
||||
@@ -163,11 +288,13 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Owner's name"
|
||||
value={value.ownerName}
|
||||
readOnly={lockedFields?.ownerName}
|
||||
onChange={(e) => onChange({ ...value, ownerName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Consignee details"
|
||||
value={value.consigneeDetails}
|
||||
readOnly={lockedFields?.consigneeDetails}
|
||||
onChange={(e) => onChange({ ...value, consigneeDetails: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -175,14 +302,22 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="EDR digital booking ID"
|
||||
value={value.edrDigitalBookingId}
|
||||
readOnly={lockedFields?.edrDigitalBookingId}
|
||||
onChange={(e) => onChange({ ...value, edrDigitalBookingId: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="TIN"
|
||||
value={value.tin}
|
||||
readOnly={lockedFields?.tin}
|
||||
onChange={(e) => onChange({ ...value, tin: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<TextInput
|
||||
label="Customer phone"
|
||||
value={value.customerPhone}
|
||||
readOnly={lockedFields?.customerPhone}
|
||||
onChange={(e) => onChange({ ...value, customerPhone: e.currentTarget.value })}
|
||||
/>
|
||||
|
||||
<Text size="sm" fw={600} mt="xs">Transport and equipment tracking</Text>
|
||||
<Group grow>
|
||||
@@ -202,6 +337,7 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Assigned wagon / container number"
|
||||
value={value.assignedEquipmentNumber}
|
||||
readOnly={lockedFields?.assignedEquipmentNumber}
|
||||
onChange={(e) => onChange({ ...value, assignedEquipmentNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
@@ -281,19 +417,25 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Item description"
|
||||
value={value.itemDescription}
|
||||
readOnly={lockedFields?.itemDescription}
|
||||
onChange={(e) => onChange({ ...value, itemDescription: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
<Select
|
||||
label="Packaging type"
|
||||
data={packagingOptions}
|
||||
clearable
|
||||
searchable
|
||||
readOnly={lockedFields?.packagingType}
|
||||
value={value.packagingType}
|
||||
onChange={(e) => onChange({ ...value, packagingType: e.currentTarget.value })}
|
||||
onChange={(v) => onChange({ ...value, packagingType: v ?? '' })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Unit count"
|
||||
label={quantityLabel}
|
||||
min={0}
|
||||
value={value.unitCount}
|
||||
readOnly={lockedFields?.unitCount}
|
||||
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -302,6 +444,7 @@ function TruckEntranceFields({
|
||||
label="Gross weight (kg)"
|
||||
min={0}
|
||||
value={value.grossWeightKg}
|
||||
readOnly={lockedFields?.grossWeightKg}
|
||||
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
@@ -466,6 +609,8 @@ function EligibleTab({
|
||||
const [truckOpen, setTruckOpen] = useState(false);
|
||||
const [pendingReceiveIds, setPendingReceiveIds] = useState<string[]>([]);
|
||||
const [truckForm, setTruckForm] = useState<TruckEntranceFormState>(emptyTruckEntrance());
|
||||
const [lockedTruckFields, setLockedTruckFields] = useState<LockedTruckEntranceFields>({});
|
||||
const [packagingFreightType, setPackagingFreightType] = useState<PackagingFreightType>('MIXED');
|
||||
|
||||
const locationReady = Boolean(location.warehouseId && location.yardId && location.zoneId);
|
||||
const canReceiveBooking = (row: EligibleBooking) =>
|
||||
@@ -539,6 +684,14 @@ function EligibleTab({
|
||||
const selectableRows = statusFilteredRows.filter(canReceiveBooking);
|
||||
const allSelected = selectableRows.length > 0 && selected.size === selectableRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const pendingReceiveRows = useMemo(
|
||||
() =>
|
||||
pendingReceiveIds
|
||||
.map((id) => rows.find((item) => item.id === id))
|
||||
.filter(Boolean) as EligibleBooking[],
|
||||
[pendingReceiveIds, rows],
|
||||
);
|
||||
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
||||
@@ -549,6 +702,29 @@ function EligibleTab({
|
||||
return next;
|
||||
});
|
||||
|
||||
const receiveBookings = async (bookingIds: string[], truckEntrance?: TruckEntrancePayload) => {
|
||||
try {
|
||||
const r = await bulkReceive.mutateAsync({
|
||||
direction,
|
||||
...location,
|
||||
bookingIds,
|
||||
...(truckEntrance ? { truckEntrance } : {}),
|
||||
});
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
});
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
setLockedTruckFields({});
|
||||
setPackagingFreightType('MIXED');
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
const openTruckReceive = (bookingIds: string[]) => {
|
||||
if (!locationReady) {
|
||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone first' });
|
||||
@@ -564,13 +740,18 @@ function EligibleTab({
|
||||
toast({ variant: 'destructive', title: 'No selected booking is ready to receive' });
|
||||
return;
|
||||
}
|
||||
const row = filteredIds.length === 1 ? rows.find((item) => item.id === filteredIds[0]) : null;
|
||||
const selectedRows = filteredIds
|
||||
.map((id) => rows.find((item) => item.id === id))
|
||||
.filter(Boolean) as EligibleBooking[];
|
||||
if (direction === 'IMPORT') {
|
||||
void receiveBookings(filteredIds);
|
||||
return;
|
||||
}
|
||||
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setTruckForm({
|
||||
...emptyTruckEntrance(),
|
||||
truckPlateNumber: row?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: row?.firstMileTrailerPlateNumber ?? '',
|
||||
});
|
||||
setTruckForm(form);
|
||||
setLockedTruckFields(lockedFields);
|
||||
setPackagingFreightType(nextPackagingFreightType);
|
||||
setTruckOpen(true);
|
||||
};
|
||||
|
||||
@@ -579,24 +760,7 @@ function EligibleTab({
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await bulkReceive.mutateAsync({
|
||||
direction,
|
||||
...location,
|
||||
bookingIds: pendingReceiveIds,
|
||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||
});
|
||||
toast({
|
||||
title: `${r.receivedCount} received at ${direction === 'EXPORT' ? 'facility' : 'warehouse'}`,
|
||||
description: r.results.find((item) => item.grnNumber)?.grnNumber ?? (r.skippedCount ? `${r.skippedCount} skipped` : undefined),
|
||||
});
|
||||
setSelected(new Set());
|
||||
setTruckOpen(false);
|
||||
setPendingReceiveIds([]);
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Receive failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||
};
|
||||
|
||||
const loadPassedExport = async () => {
|
||||
@@ -800,18 +964,33 @@ function EligibleTab({
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<Modal opened={truckOpen} onClose={() => setTruckOpen(false)} title="Truck Entrance Registration" centered size="lg">
|
||||
<Modal
|
||||
opened={truckOpen}
|
||||
onClose={() => setTruckOpen(false)}
|
||||
title="Export Truck Arrival / First Mile Receive Form"
|
||||
centered
|
||||
size="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
Register the arriving truck and driver before receiving {pendingReceiveIds.length} booking{pendingReceiveIds.length === 1 ? '' : 's'}.
|
||||
</Text>
|
||||
<TruckEntranceFields value={truckForm} onChange={setTruckForm} />
|
||||
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
|
||||
<Text size="sm">
|
||||
{pendingHasFirstMile
|
||||
? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.'
|
||||
: 'Register the customer or third-party truck and driver before export receiving and GRN.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
<TruckEntranceFields
|
||||
value={truckForm}
|
||||
onChange={setTruckForm}
|
||||
lockedFields={lockedTruckFields}
|
||||
packagingFreightType={packagingFreightType}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button leftSection={<Truck size={16} />} loading={bulkReceive.isPending} onClick={receive}>
|
||||
Receive and Generate GRN
|
||||
Register Arrival & Generate GRN
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -820,6 +999,161 @@ function EligibleTab({
|
||||
);
|
||||
}
|
||||
|
||||
/** Export received items awaiting inspection before loading. */
|
||||
function ExportReceivedTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
const { data: rows = [], isLoading } = useQuery(
|
||||
api.warehouses.receivedExport.queryOptions({ enabled }),
|
||||
);
|
||||
const inspectMutation = useMutation(
|
||||
api.warehouses.bulkMarkInspected.mutationOptions(),
|
||||
);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [inspectId, setInspectId] = useState<string | null>(null);
|
||||
|
||||
const pendingRows = rows.filter((r) => r.inspectionStatus !== 'PASSED');
|
||||
const allSelected = pendingRows.length > 0 && selected.size === pendingRows.length;
|
||||
const someSelected = selected.size > 0 && !allSelected;
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(pendingRows.map((r) => r.id)));
|
||||
const toggleOne = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const markInspected = async () => {
|
||||
if (selected.size === 0) {
|
||||
toast({ variant: 'destructive', title: 'Select at least one item' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const r = await inspectMutation.mutateAsync({ inventoryIds: [...selected] });
|
||||
toast({
|
||||
title: `${r.inspectedCount} marked inspected`,
|
||||
description: r.skippedCount ? `${r.skippedCount} skipped` : undefined,
|
||||
});
|
||||
setSelected(new Set());
|
||||
onChanged?.();
|
||||
} catch (error) {
|
||||
toast({ variant: 'destructive', title: 'Mark inspected failed', description: extractErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm" mt="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">
|
||||
Selected: <b>{selected.size}</b> / {pendingRows.length} received
|
||||
</Text>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
color="indigo"
|
||||
leftSection={<ClipboardCheck size={14} />}
|
||||
disabled={selected.size === 0}
|
||||
loading={inspectMutation.isPending}
|
||||
onClick={markInspected}
|
||||
>
|
||||
Mark Selected as Inspected
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Group justify="center" py="lg">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : rows.length === 0 ? (
|
||||
<Text c="dimmed" ta="center" py="lg" size="sm">
|
||||
No received export items awaiting inspection.
|
||||
</Text>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={1700}>
|
||||
<Table highlightOnHover verticalSpacing="xs" striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label="Select all"
|
||||
checked={allSelected}
|
||||
indeterminate={someSelected}
|
||||
onChange={toggleAll}
|
||||
/>
|
||||
</Table.Th>
|
||||
<Table.Th>Booking Ref</Table.Th>
|
||||
<Table.Th>Booking ID</Table.Th>
|
||||
<Table.Th>Customer ID</Table.Th>
|
||||
<Table.Th>Customer Name</Table.Th>
|
||||
<Table.Th>Container #</Table.Th>
|
||||
<Table.Th>Cargo Type</Table.Th>
|
||||
<Table.Th>Weight</Table.Th>
|
||||
<Table.Th>Route</Table.Th>
|
||||
<Table.Th>Inspection</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th ta="right">Actions</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{rows.map((r: ReadyToLoadRow) => {
|
||||
const selectable = r.inspectionStatus !== 'PASSED';
|
||||
return (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={`Select ${r.bookingReference ?? r.id}`}
|
||||
checked={selected.has(r.id)}
|
||||
disabled={!selectable}
|
||||
onChange={() => toggleOne(r.id)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>{r.bookingReference ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.bookingId ? `${r.bookingId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="xs" c="dimmed">{r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{r.customerName ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.containerNumber ?? '—'}</Table.Td>
|
||||
<Table.Td>{r.cargoType ?? '—'}</Table.Td>
|
||||
<Table.Td>{formatNumber(Number(r.weight))}</Table.Td>
|
||||
<Table.Td>
|
||||
{r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={r.inspectionStatus === 'PASSED' ? 'green' : 'orange'} variant="light" size="sm">
|
||||
{r.inspectionStatus ?? 'PENDING'}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{r.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td ta="right">
|
||||
<Button size="compact-xs" variant="light" color="orange" onClick={() => setInspectId(r.id)}>
|
||||
Inspect / Report
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<InspectionReportModal
|
||||
inventoryId={inspectId}
|
||||
opened={Boolean(inspectId)}
|
||||
onClose={() => setInspectId(null)}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
/** Export items that passed inspection and are queued to be loaded onto a train. */
|
||||
function ReadyToLoadTab({ enabled, onChanged }: { enabled: boolean; onChanged?: () => void }) {
|
||||
const { toast } = useToast();
|
||||
@@ -1620,6 +1954,7 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
||||
<Tabs defaultValue="receive-queue" mt="xs">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="receive-queue">Receive Queue</Tabs.Tab>
|
||||
<Tabs.Tab value="received">Received</Tabs.Tab>
|
||||
<Tabs.Tab value="ready-to-load">Ready To Load</Tabs.Tab>
|
||||
<Tabs.Tab value="loaded">Loaded</Tabs.Tab>
|
||||
<Tabs.Tab value="dispatch-queue">Dispatch Queue</Tabs.Tab>
|
||||
@@ -1628,6 +1963,9 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal
|
||||
<Tabs.Panel value="receive-queue">
|
||||
<EligibleTab direction="EXPORT" location={location} enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="received">
|
||||
<ExportReceivedTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
<Tabs.Panel value="ready-to-load">
|
||||
<ReadyToLoadTab enabled={opened} onChanged={onReceived} />
|
||||
</Tabs.Panel>
|
||||
@@ -1704,10 +2042,6 @@ function SingleBookingReceiveModal({
|
||||
toast({ variant: 'destructive', title: 'Select warehouse, yard and zone' });
|
||||
return;
|
||||
}
|
||||
if (form.quantity === '' || form.weight === '') {
|
||||
toast({ variant: 'destructive', title: 'Quantity and weight are required' });
|
||||
return;
|
||||
}
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
return;
|
||||
@@ -1717,8 +2051,8 @@ function SingleBookingReceiveModal({
|
||||
warehouseId: form.warehouseId,
|
||||
yardId: form.yardId,
|
||||
zoneId: form.zoneId,
|
||||
quantity: Number(form.quantity),
|
||||
weight: Number(form.weight),
|
||||
quantity: 0,
|
||||
weight: 0,
|
||||
volume: form.volume === '' ? undefined : Number(form.volume),
|
||||
notes: form.notes.trim() || undefined,
|
||||
truckEntrance: toTruckEntrancePayload(truckForm),
|
||||
@@ -1744,21 +2078,13 @@ function SingleBookingReceiveModal({
|
||||
|
||||
<LocationSelects value={location} onChange={(next) => setForm((f) => ({ ...f, ...next }))} />
|
||||
|
||||
<Alert icon={<Info size={16} />} color="blue" variant="light">
|
||||
<Text size="sm">
|
||||
Customer, TIN, phone, quantity and weight are pulled from the selected booking when the GRN is generated.
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Quantity"
|
||||
required
|
||||
min={0}
|
||||
value={form.quantity}
|
||||
onChange={(value) => setForm((f) => ({ ...f, quantity: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={form.weight}
|
||||
onChange={(value) => setForm((f) => ({ ...f, weight: value === '' ? '' : Number(value) }))}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Volume (m³)"
|
||||
placeholder="Optional"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Group, Modal, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Alert, Button, Group, Modal, NumberInput, Select, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Info, Scale } from 'lucide-react';
|
||||
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
@@ -17,23 +17,115 @@ interface ReleaseOrderModalProps {
|
||||
item: WarehouseInventoryItem | null;
|
||||
}
|
||||
|
||||
const REGISTERED_FIRST_LAST_MILE_TRUCKS = [
|
||||
['03-ET A45843', '43495'], ['03-ET A45866', '43470'], ['03-ET A45853', '43508'], ['03-ET A45849', '43414'],
|
||||
['03-ET A45845', '43492'], ['03-ET A45820', '43487'], ['03-ET A45842', '43478'], ['03-ET A45841', '43515'],
|
||||
['03-ET A45832', '43504'], ['03-ET A45856', '43510'], ['03-ET A45865', '43490'], ['03-ET A45855', '43485'],
|
||||
['03-ET A45840', '43499'], ['03-ET A45867', '43493'], ['03-ET A45833', '43466'], ['03-ET A45858', '43496'],
|
||||
['03-ET A45819', '43474'], ['03-ET A45834', '43502'], ['03-ET A45868', '43469'], ['03-ET A45831', '43488'],
|
||||
['03-ET A45828', '43479'], ['03-ET A45850', '43505'], ['03-ET A45823', '43480'], ['03-ET A45838', '43472'],
|
||||
['03-ET A45854', '43500'], ['03-ET A45839', '43486'], ['03-ET A45861', '43513'], ['03-ET A45830', '43501'],
|
||||
['03-ET A45826', '43498'], ['03-ET A45836', '43467'], ['03-ET A45822', '43512'], ['03-ET A45821', '43210'],
|
||||
['03-ET A45837', '43475'], ['03-ET A45860', '43497'], ['03-ET A45863', '43477'], ['03-ET A45825', '43483'],
|
||||
['03-ET A45829', '43473'], ['03-ET A45824', '43491'], ['03-ET A45857', '43481'], ['03-ET A45851', '43509'],
|
||||
['03-ET A45827', '43468'], ['03-ET A45859', '43887'], ['03-ET A45846', '43471'], ['03-ET A45847', '43511'],
|
||||
['03-ET A45852', '43484'], ['03-ET A45844', '43476'], ['03-ET A45835', '43482'], ['03-ET A45864', '43503'],
|
||||
['03-ET A45848', '43494'], ['03-ET A45862', '43465'], ['03-ET A39105', '41218'], ['03-ET A39098', '41220'],
|
||||
['03-ET A29900', '41865'], ['03-ET A39097', '41226'], ['03-ET A39104', '41225'], ['03-ET A39103', '41223'],
|
||||
['03-ET A39106', '41221'], ['03-ET A39107', '41215'], ['03-ET A39094', '41222'], ['03-ET A39099', '41216'],
|
||||
['03-ET A39092', '41224'], ['03-ET A31801', '41214'],
|
||||
].map(([powerPlate, trailerPlate], index) => ({
|
||||
value: powerPlate,
|
||||
label: `${index + 1}. ${powerPlate} / ${trailerPlate}`,
|
||||
trailerPlate,
|
||||
}));
|
||||
|
||||
const toIsoDateTime = (value: string) => {
|
||||
if (!value) return undefined;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
};
|
||||
|
||||
export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalProps) {
|
||||
const { toast } = useToast();
|
||||
const releaseMutation = useMutation(api.warehouses.release.mutationOptions());
|
||||
const [reference, setReference] = useState('');
|
||||
const [truckPlateNumber, setTruckPlateNumber] = useState('');
|
||||
const [trailerPlateNumber, setTrailerPlateNumber] = useState('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverLicense, setDriverLicense] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
const [truckType, setTruckType] = useState('');
|
||||
const [containerNumber, setContainerNumber] = useState('');
|
||||
const [gateInTime, setGateInTime] = useState('');
|
||||
const [tareWeight, setTareWeight] = useState<number | ''>('');
|
||||
const [grossWeight, setGrossWeight] = useState<number | ''>('');
|
||||
const [netWeight, setNetWeight] = useState<number | ''>('');
|
||||
const [gateOutTime, setGateOutTime] = useState('');
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (opened) setReference(item?.releaseOrderReference ?? '');
|
||||
if (opened) {
|
||||
setReference(item?.releaseOrderReference ?? '');
|
||||
setTruckPlateNumber('');
|
||||
setTrailerPlateNumber('');
|
||||
setDriverName('');
|
||||
setDriverLicense('');
|
||||
setDriverPhone('');
|
||||
setTruckType('');
|
||||
setContainerNumber('');
|
||||
setGateInTime('');
|
||||
setTareWeight('');
|
||||
setGrossWeight('');
|
||||
setNetWeight(item?.weight != null ? Number(item.weight) : '');
|
||||
setGateOutTime('');
|
||||
}
|
||||
}, [opened, item]);
|
||||
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
const weightMismatch =
|
||||
computedNetWeight != null && netWeight !== '' && Math.abs(Number(netWeight) - computedNetWeight) > 0.001;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!item) return;
|
||||
if (!truckPlateNumber.trim() || !driverName.trim()) {
|
||||
toast({ variant: 'destructive', title: 'Truck plate and driver name are required' });
|
||||
return;
|
||||
}
|
||||
if (tareWeight === '' || grossWeight === '') {
|
||||
toast({ variant: 'destructive', title: 'Tare and gross weight are required' });
|
||||
return;
|
||||
}
|
||||
if (weightMismatch) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Weight mismatch',
|
||||
description: 'Gate clearance is blocked. Reassign the item to warehouse if it cannot exit.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pdfWindow = window.open('', '_blank');
|
||||
try {
|
||||
const released = await releaseMutation.mutateAsync({
|
||||
id: item.id,
|
||||
payload: { reference: reference.trim() || undefined },
|
||||
payload: {
|
||||
reference: reference.trim() || undefined,
|
||||
bookingId: item.bookingId ?? undefined,
|
||||
customerId: undefined,
|
||||
truckPlateNumber: truckPlateNumber.trim(),
|
||||
trailerPlateNumber: trailerPlateNumber.trim() || undefined,
|
||||
driverName: driverName.trim(),
|
||||
driverLicense: driverLicense.trim() || undefined,
|
||||
driverPhone: driverPhone.trim() || undefined,
|
||||
truckType: truckType.trim() || undefined,
|
||||
containerNumber: containerNumber.trim() || undefined,
|
||||
gateInTime: toIsoDateTime(gateInTime),
|
||||
tareWeight: Number(tareWeight),
|
||||
grossWeight: Number(grossWeight),
|
||||
netWeight: netWeight === '' ? computedNetWeight ?? undefined : Number(netWeight),
|
||||
gateOutTime: toIsoDateTime(gateOutTime),
|
||||
},
|
||||
});
|
||||
setDownloading(true);
|
||||
const response = await warehouseService.downloadReleaseDocument(item.id);
|
||||
@@ -56,12 +148,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title="Issue release exit paper" centered size="md">
|
||||
<Modal opened={opened} onClose={onClose} title="Exit inspection and release paper" centered size="lg">
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Info size={16} />} color="orange" variant="light">
|
||||
<Text size="sm">
|
||||
Creates the warehouse release document with booking, customer, cargo and location details. The
|
||||
printed paper authorizes the goods to leave the warehouse gate.
|
||||
Save the exit inspection before generating the exit paper. Gate clearance is blocked when
|
||||
recorded net weight does not equal gross weight minus tare weight.
|
||||
</Text>
|
||||
</Alert>
|
||||
<TextInput
|
||||
@@ -70,12 +162,69 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
value={reference}
|
||||
onChange={(e) => setReference(e.currentTarget.value)}
|
||||
/>
|
||||
<Select
|
||||
label="Registered first / last-mile truck"
|
||||
placeholder="Select truck or type plate manually below"
|
||||
searchable
|
||||
clearable
|
||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
||||
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
||||
setTruckPlateNumber(truck?.value ?? '');
|
||||
setTrailerPlateNumber(truck?.trailerPlate ?? '');
|
||||
}}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
label="Truck plate number"
|
||||
required
|
||||
value={truckPlateNumber}
|
||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
value={trailerPlateNumber}
|
||||
onChange={(e) => setTrailerPlateNumber(e.currentTarget.value)}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} />
|
||||
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} />
|
||||
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput label="Container number" value={containerNumber} onChange={(e) => setContainerNumber(e.currentTarget.value)} />
|
||||
<TextInput label="Gate in time" type="datetime-local" value={gateInTime} onChange={(e) => setGateInTime(e.currentTarget.value)} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput label="Tare weight (kg)" required min={0} value={tareWeight} onChange={(v) => setTareWeight(v === '' ? '' : Number(v))} />
|
||||
<NumberInput label="Gross weight (kg)" required min={0} value={grossWeight} onChange={(v) => setGrossWeight(v === '' ? '' : Number(v))} />
|
||||
<NumberInput label="Recorded net weight (kg)" min={0} value={netWeight} onChange={(v) => setNetWeight(v === '' ? '' : Number(v))} />
|
||||
</Group>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c={weightMismatch ? 'red' : 'dimmed'}>
|
||||
Computed net: <b>{computedNetWeight == null ? '-' : `${computedNetWeight.toLocaleString()} kg`}</b>
|
||||
</Text>
|
||||
<TextInput label="Gate out time" type="datetime-local" value={gateOutTime} onChange={(e) => setGateOutTime(e.currentTarget.value)} />
|
||||
</Group>
|
||||
{weightMismatch && (
|
||||
<Alert icon={<Scale size={16} />} color="red" variant="light">
|
||||
<Text size="sm">
|
||||
Weight mismatch detected. Exit paper and gate clearance are blocked; use Store or Move to
|
||||
reassign the item back to warehouse handling.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
<Group justify="flex-end" mt="sm">
|
||||
<Button variant="default" onClick={onClose} disabled={releaseMutation.isPending || downloading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="orange" onClick={handleSubmit} loading={releaseMutation.isPending || downloading}>
|
||||
Issue & view exit paper
|
||||
Exit Inspection & View Exit Paper
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
|
||||
@@ -164,7 +164,7 @@ export function WarehouseInventoryTable({
|
||||
loading={busy}
|
||||
onClick={() => onAdvance(item, nextAction)}
|
||||
>
|
||||
{humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
{nextAction === 'release' ? 'Exit Inspection' : humanizeEnum(nextAction.replace(/-/g, '_'))}
|
||||
</Button>
|
||||
)}
|
||||
{item.status === 'READY_FOR_PICKUP' && (
|
||||
|
||||
@@ -192,6 +192,24 @@ export const URL_CONSTANTS = {
|
||||
PIN_WAGONS: (id: string) => `/train-scheduling/schedules/${id}/pin-wagons`,
|
||||
FINALIZE: (id: string) => `/train-scheduling/schedules/${id}/finalize`,
|
||||
DISPATCH: (id: string) => `/train-scheduling/schedules/${id}/dispatch`,
|
||||
IMPORT_DJIBOUTI: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti`,
|
||||
IMPORT_DJIBOUTI_DOCUMENTS: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/documents`,
|
||||
IMPORT_DJIBOUTI_GATEPASS_GRANTED: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`,
|
||||
IMPORT_DJIBOUTI_READY_FOR_LOADING: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/ready-for-loading`,
|
||||
IMPORT_DJIBOUTI_LOADED_ON_TRAIN: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/loaded-on-train`,
|
||||
IMPORT_DJIBOUTI_DEPART: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/depart`,
|
||||
IMPORT_DJIBOUTI_LOAD_LIST: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list`,
|
||||
IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/import-djibouti/load-list/document`,
|
||||
EXPORT_LOAD_LIST_DOCUMENT: (id: string) =>
|
||||
`/train-scheduling/schedules/${id}/export/load-list/document`,
|
||||
CHECKPOINTS: (id: string) => `/train-scheduling/schedules/${id}/checkpoints`,
|
||||
ARRIVE: (id: string) => `/train-scheduling/schedules/${id}/arrive`,
|
||||
RESCHEDULE_PREVIEW: (id: string) =>
|
||||
@@ -322,6 +340,7 @@ export const URL_CONSTANTS = {
|
||||
RECEIVE_BULK: '/warehouse-inventory/receive-bulk',
|
||||
LOAD_PASSED_EXPORT: '/warehouse-inventory/load-passed-export',
|
||||
BULK_MARK_INSPECTED: '/warehouse-inventory/bulk-mark-inspected',
|
||||
RECEIVED_EXPORT: '/warehouse-inventory/received-export',
|
||||
READY_TO_LOAD_EXPORT: '/warehouse-inventory/ready-to-load-export',
|
||||
LOADED_EXPORT: '/warehouse-inventory/loaded-export',
|
||||
BULK_DISPATCH_EXPORT: '/warehouse-inventory/bulk-dispatch-export',
|
||||
@@ -377,6 +396,23 @@ export const URL_CONSTANTS = {
|
||||
CANCEL: (id: string) => `/interchange-documents/${id}/cancel`,
|
||||
},
|
||||
|
||||
IMPORT_OPERATIONS: {
|
||||
DJIBOUTI_INCIDENTS: '/import-operations/djibouti-incidents',
|
||||
CUSTOMS: (bookingId: string) => `/import-operations/customs/${bookingId}`,
|
||||
CUSTOMS_DOCUMENTS: (bookingId: string) => `/import-operations/customs/${bookingId}/documents`,
|
||||
CUSTOMS_DECLARATION: (bookingId: string) => `/import-operations/customs/${bookingId}/declaration`,
|
||||
CUSTOMS_NOTIFY_DUTIES_TAXES: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/notify-duties-taxes`,
|
||||
CUSTOMS_DUTIES_TAXES_PAID: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/duties-taxes-paid`,
|
||||
CUSTOMS_RISK: (bookingId: string) => `/import-operations/customs/${bookingId}/risk`,
|
||||
CUSTOMS_RELEASE_PERMITTED: (bookingId: string) =>
|
||||
`/import-operations/customs/${bookingId}/release-permitted`,
|
||||
EMPTY_CONTAINER_RETURNS: '/import-operations/empty-container-returns',
|
||||
EMPTY_CONTAINER_RETURN_STATUS: (id: string) =>
|
||||
`/import-operations/empty-container-returns/${id}/status`,
|
||||
},
|
||||
|
||||
VEHICLES: {
|
||||
BASE: '/vehicles',
|
||||
BY_ID: (id: string) => `/vehicles/${id}`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const API_BASE_URL =
|
||||
import.meta.env.VITE_BASE_API_URL ||
|
||||
import.meta.env.VITE_BASE_API_URL ||
|
||||
import.meta.env.VITE_API_URL ||
|
||||
'https://edrfreightapi.triaplc.com';
|
||||
'https://edrfreightapi.triaplc.com';
|
||||
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { FleetResourceConfig } from "./resources";
|
||||
import { API_BASE_URL } from "@/constants/apiConfig";
|
||||
|
||||
const VEHICLE_TYPE_OPTIONS = [
|
||||
{ label: "Truck", value: "TRUCK" },
|
||||
@@ -99,4 +98,3 @@ export const vehiclesConfig: FleetResourceConfig = {
|
||||
};
|
||||
|
||||
export { VEHICLE_TYPE_OPTIONS, FUEL_TYPE_OPTIONS, VEHICLE_STATUS_OPTIONS };
|
||||
export { API_BASE_URL };
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
CheckCircle2,
|
||||
Container as ContainerIcon,
|
||||
Eye,
|
||||
FileText,
|
||||
LayoutGrid,
|
||||
Navigation,
|
||||
Package,
|
||||
@@ -56,8 +57,10 @@ import { shouldShowContainerPlacementStep } from "@/components/trainScheduling/s
|
||||
import { TrainCompositionDiagram } from "@/components/trainScheduling/TrainCompositionDiagram";
|
||||
import { WagonPlanGrid } from "@/components/trainScheduling/WagonPlanGrid";
|
||||
import { WorkflowRail, WorkflowStep } from "@/components/trainScheduling/WorkflowStep";
|
||||
import { openPdfBlob } from "@/components/warehouses/pdf";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { api } from "@/services/api";
|
||||
import { trainSchedulingService } from "@/services/trainScheduling.service";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type {
|
||||
ContainerPlacement,
|
||||
@@ -124,6 +127,12 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const unassign = useMutation(api.trainScheduling.unassignBooking.mutationOptions());
|
||||
const finalize = useMutation(api.trainScheduling.finalizeSchedule.mutationOptions());
|
||||
const dispatch = useMutation(api.trainScheduling.dispatchSchedule.mutationOptions());
|
||||
const downloadMarshalling = useMutation({
|
||||
mutationFn: ({ id, direction }: { id: string; direction?: string | null }) =>
|
||||
direction === "EXPORT"
|
||||
? trainSchedulingService.downloadExportLoadListDocument(id)
|
||||
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
|
||||
});
|
||||
|
||||
const assignedIds = useMemo(
|
||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||
@@ -304,6 +313,33 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const canDispatch = schedule.status === "SCHEDULED";
|
||||
const finalizeStep = hasContainerStep ? 3 : 2;
|
||||
const canModifyBookings = canEditBookings && !["DISPATCHED", "ARRIVED"].includes(schedule.status);
|
||||
const canPrintMarshalling = schedule.direction === "IMPORT" || schedule.direction === "EXPORT";
|
||||
|
||||
const openMarshallingDocument = async () => {
|
||||
const pdfWindow = window.open("", "_blank");
|
||||
try {
|
||||
const blob = await downloadMarshalling.mutateAsync({
|
||||
id: scheduleId,
|
||||
direction: schedule.direction,
|
||||
});
|
||||
const prefix = schedule.direction === "EXPORT" ? "export-marshalling" : "import-marshalling";
|
||||
const filename = `${prefix}-${schedule.trainNumber ?? scheduleId}.pdf`;
|
||||
const opened = openPdfBlob(blob, filename, pdfWindow);
|
||||
toast({
|
||||
title: "Marshalling document ready",
|
||||
description: opened
|
||||
? "The PDF opened in a browser tab for printing or saving."
|
||||
: "The browser blocked the preview tab, so the PDF was downloaded.",
|
||||
});
|
||||
} catch (error) {
|
||||
pdfWindow?.close();
|
||||
toast({
|
||||
title: "Could not open marshalling document",
|
||||
description: parseError(error, "Make sure the train has wagon allocations, then try again."),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!allSelectedIds.length) return;
|
||||
@@ -766,6 +802,19 @@ export default function TrainScheduleV2DetailPage() {
|
||||
</Stack>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{canPrintMarshalling ? (
|
||||
<Button
|
||||
variant="light"
|
||||
color="edr-green"
|
||||
radius="lg"
|
||||
size="sm"
|
||||
leftSection={<FileText size={16} />}
|
||||
loading={downloadMarshalling.isPending}
|
||||
onClick={() => void openMarshallingDocument()}
|
||||
>
|
||||
Marshalling PDF
|
||||
</Button>
|
||||
) : null}
|
||||
{["DISPATCHED", "ARRIVED"].includes(schedule.status) ? (
|
||||
<Button
|
||||
component={Link}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Alert,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
@@ -168,7 +169,7 @@ function ExportTrainDetailRows({
|
||||
export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const { data: trains = [], isLoading } = useExportDjiboutiArrivalQueue();
|
||||
const { data: trains = [], isLoading, isError, error } = useExportDjiboutiArrivalQueue();
|
||||
const { data: interchangeDocuments = [] } = useInterchangeDocuments({ direction: 'EXPORT' });
|
||||
const autoUnload = useAutoUnloadExportAtDjibouti();
|
||||
const generateInterchange = useGenerateInterchangeDocument();
|
||||
@@ -272,6 +273,10 @@ export default function ExportDjiboutiUnloadingQueuePage() {
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
) : isError ? (
|
||||
<Alert color="red" variant="light" title="Could not load arrived export trains">
|
||||
{getErrorMessage(error) ?? 'Check your API connection and sign in again.'}
|
||||
</Alert>
|
||||
) : trains.length === 0 ? (
|
||||
<VisualEmptyState
|
||||
variant="train"
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
} from '@mantine/core';
|
||||
import { CheckCircle2, Eye, FileText, Search, XCircle } from 'lucide-react';
|
||||
import { CheckCircle2, Download, Eye, FileText, Printer, Search, XCircle } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
useInterchangeDocuments,
|
||||
} from '@/hooks/useInterchangeDocuments';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { interchangeDocumentsService } from '@/services/interchange-documents.service';
|
||||
import type { InterchangeDocument, InterchangeDocumentStatus } from '@/types/interchangeDocument';
|
||||
|
||||
const statusColor: Record<InterchangeDocumentStatus, string> = {
|
||||
@@ -45,6 +46,116 @@ const getErrorMessage = (error: unknown) => {
|
||||
return error instanceof Error ? error.message : undefined;
|
||||
};
|
||||
|
||||
const escapeHtml = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
|
||||
const filenameFor = (document: InterchangeDocument) =>
|
||||
`${document.documentNo || document.id}-interchange-document.html`.replace(/[\\/:*?"<>|]/g, '-');
|
||||
|
||||
const buildPrintableInterchangeHtml = (document: InterchangeDocument) => {
|
||||
const items = document.items ?? [];
|
||||
const rows = items
|
||||
.map(
|
||||
(item, index) => `
|
||||
<tr>
|
||||
<td>${index + 1}</td>
|
||||
<td>${escapeHtml(item.bookingReference ?? item.bookingId?.slice(0, 8))}</td>
|
||||
<td>${escapeHtml(item.itemType)}</td>
|
||||
<td>${escapeHtml(item.containerNumber)}</td>
|
||||
<td>${escapeHtml(item.sealNumber)}</td>
|
||||
<td>${escapeHtml(item.cargoType ?? item.cargoDescription)}</td>
|
||||
<td>${escapeHtml(formatNumber(item.weight))}</td>
|
||||
<td>${escapeHtml(formatNumber(item.quantity))}</td>
|
||||
<td>${escapeHtml(item.wagonNumber)}</td>
|
||||
<td>${escapeHtml(item.conditionStatus)}</td>
|
||||
<td>${escapeHtml(item.damageDescription)}</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>${escapeHtml(document.documentNo)} Interchange Document</title>
|
||||
<style>
|
||||
@page { size: A4 landscape; margin: 14mm; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: Arial, sans-serif; color: #111827; margin: 0; }
|
||||
.top { display: flex; justify-content: space-between; gap: 24px; border-bottom: 2px solid #111827; padding-bottom: 14px; }
|
||||
h1 { margin: 0; font-size: 24px; }
|
||||
.muted { color: #4b5563; font-size: 12px; }
|
||||
.stamp { border: 2px solid #15803d; color: #15803d; border-radius: 999px; padding: 14px 18px; text-align: center; font-weight: 700; }
|
||||
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px 18px; margin: 18px 0; }
|
||||
.field { border-bottom: 1px solid #d1d5db; padding-bottom: 6px; }
|
||||
.label { color: #6b7280; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.value { font-size: 13px; font-weight: 700; margin-top: 3px; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 11px; }
|
||||
th, td { border: 1px solid #d1d5db; padding: 6px; text-align: left; vertical-align: top; }
|
||||
th { background: #f3f4f6; }
|
||||
.signatures { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; margin-top: 34px; }
|
||||
.signature { border-top: 1px solid #111827; padding-top: 8px; min-height: 48px; }
|
||||
.footer { margin-top: 16px; font-size: 10px; color: #6b7280; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top">
|
||||
<div>
|
||||
<h1>EDR / Djibouti Port Interchange Document</h1>
|
||||
<div class="muted">Official export handover document</div>
|
||||
<div class="muted">Document No: ${escapeHtml(document.documentNo)}</div>
|
||||
</div>
|
||||
<div class="stamp">${escapeHtml(document.status)}<br/>SIGNED HANDOVER</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="field"><div class="label">Direction</div><div class="value">${escapeHtml(document.direction)}</div></div>
|
||||
<div class="field"><div class="label">Train No</div><div class="value">${escapeHtml(document.trainNo)}</div></div>
|
||||
<div class="field"><div class="label">Schedule</div><div class="value">${escapeHtml(document.scheduleId)}</div></div>
|
||||
<div class="field"><div class="label">Handover Location</div><div class="value">${escapeHtml(document.handoverLocation)}</div></div>
|
||||
<div class="field"><div class="label">Handover From</div><div class="value">${escapeHtml(document.handoverFrom)}</div></div>
|
||||
<div class="field"><div class="label">Handover To</div><div class="value">${escapeHtml(document.handoverTo)}</div></div>
|
||||
<div class="field"><div class="label">Generated At</div><div class="value">${escapeHtml(formatDate(document.generatedAt))}</div></div>
|
||||
<div class="field"><div class="label">Acknowledged At</div><div class="value">${escapeHtml(formatDate(document.acknowledgedAt))}</div></div>
|
||||
<div class="field"><div class="label">Signed by EDR</div><div class="value">${escapeHtml(document.generatedBy)}</div></div>
|
||||
<div class="field"><div class="label">Signed by Djibouti Port</div><div class="value">${escapeHtml(document.acknowledgedBy)}</div></div>
|
||||
<div class="field"><div class="label">Port Operator</div><div class="value">${escapeHtml(document.portOperatorName)}</div></div>
|
||||
<div class="field"><div class="label">Manifest Ref</div><div class="value">${escapeHtml(document.manifestReference)}</div></div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Booking</th>
|
||||
<th>Type</th>
|
||||
<th>Container</th>
|
||||
<th>Seal</th>
|
||||
<th>Cargo</th>
|
||||
<th>Weight</th>
|
||||
<th>Qty</th>
|
||||
<th>Wagon</th>
|
||||
<th>Condition</th>
|
||||
<th>Damage / Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>${rows || '<tr><td colspan="11">No items</td></tr>'}</tbody>
|
||||
</table>
|
||||
|
||||
<div class="signatures">
|
||||
<div class="signature">EDR Representative: ${escapeHtml(document.generatedBy)}</div>
|
||||
<div class="signature">Djibouti Port Operator: ${escapeHtml(document.acknowledgedBy)}</div>
|
||||
</div>
|
||||
<div class="footer">Generated from EDR Freight Management System. Printed on ${escapeHtml(new Date().toLocaleString())}.</div>
|
||||
</body>
|
||||
</html>`;
|
||||
};
|
||||
|
||||
function DetailField({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
@@ -157,6 +268,37 @@ export default function InterchangeDocumentsPage() {
|
||||
const dispute = useDisputeInterchangeDocument();
|
||||
const cancel = useCancelInterchangeDocument();
|
||||
|
||||
const getPrintableDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||
if (interchangeDocument.items?.length) return interchangeDocument;
|
||||
return interchangeDocumentsService.getById(interchangeDocument.id).then((response) => response.data);
|
||||
};
|
||||
|
||||
const printDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||
const fullDocument = await getPrintableDocument(interchangeDocument);
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) {
|
||||
toast({ variant: 'destructive', title: 'Pop-up blocked', description: 'Allow pop-ups to print the document.' });
|
||||
return;
|
||||
}
|
||||
win.document.write(buildPrintableInterchangeHtml(fullDocument));
|
||||
win.document.close();
|
||||
win.focus();
|
||||
setTimeout(() => win.print(), 250);
|
||||
};
|
||||
|
||||
const downloadDocument = async (interchangeDocument: InterchangeDocument) => {
|
||||
const fullDocument = await getPrintableDocument(interchangeDocument);
|
||||
const blob = new Blob([buildPrintableInterchangeHtml(fullDocument)], { type: 'text/html;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filenameFor(fullDocument);
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const run = async (fn: () => Promise<unknown>, title: string) => {
|
||||
try {
|
||||
await fn();
|
||||
@@ -169,10 +311,10 @@ export default function InterchangeDocumentsPage() {
|
||||
const acknowledgeDocument = (document: InterchangeDocument) => {
|
||||
const acknowledgedBy = window.prompt('Acknowledged by');
|
||||
if (!acknowledgedBy) return;
|
||||
run(
|
||||
() => acknowledge.mutateAsync({ id: document.id, acknowledgedBy }),
|
||||
'Interchange document acknowledged',
|
||||
);
|
||||
run(async () => {
|
||||
const response = await acknowledge.mutateAsync({ id: document.id, acknowledgedBy });
|
||||
await printDocument(response.data);
|
||||
}, 'Interchange document acknowledged');
|
||||
};
|
||||
|
||||
const disputeDocument = (document: InterchangeDocument) => {
|
||||
@@ -284,6 +426,28 @@ export default function InterchangeDocumentsPage() {
|
||||
Acknowledge
|
||||
</Button>
|
||||
) : null}
|
||||
{document.status === 'ACKNOWLEDGED' ? (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Printer size={14} />}
|
||||
onClick={() => run(() => printDocument(document), 'Print view opened')}
|
||||
>
|
||||
Print
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<Download size={14} />}
|
||||
onClick={() => run(() => downloadDocument(document), 'Document downloaded')}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{document.status !== 'CANCELLED' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
|
||||
@@ -165,6 +165,8 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
|
||||
@@ -254,8 +256,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
const handlePay = async () => {
|
||||
if (!inv || !payAmount) return;
|
||||
try {
|
||||
const paidInvoice = await pay.mutateAsync({ id: inv.id, payload: { amount: Number(payAmount), method: 'MANUAL' } });
|
||||
const paidInvoice = await pay.mutateAsync({
|
||||
id: inv.id,
|
||||
payload: {
|
||||
amount: Number(payAmount),
|
||||
method: 'MANUAL',
|
||||
driverName: driverName.trim() || undefined,
|
||||
driverPhone: driverPhone.trim() || undefined,
|
||||
},
|
||||
});
|
||||
setPayAmount('');
|
||||
setDriverName('');
|
||||
setDriverPhone('');
|
||||
if (paidInvoice.status === 'PAID') {
|
||||
toast({ title: 'Payment recorded', description: 'Downloading receipt, then generating gate clearance and exit paper.' });
|
||||
await downloadReceiptPdf(paidInvoice);
|
||||
@@ -334,6 +346,18 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
onChange={(v) => setPayAmount(v === '' ? '' : Number(v))}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Pickup driver"
|
||||
value={driverName}
|
||||
onChange={(e) => setDriverName(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver phone"
|
||||
value={driverPhone}
|
||||
onChange={(e) => setDriverPhone(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button leftSection={<CreditCard size={16} />} loading={pay.isPending} onClick={handlePay}>
|
||||
Pay
|
||||
</Button>
|
||||
|
||||
@@ -644,6 +644,13 @@ export const api = {
|
||||
() => ["warehouse-inventory", "ready-to-load-export"],
|
||||
),
|
||||
|
||||
receivedExport: endpoint<void, ReadyToLoadRow[]>(
|
||||
"warehouse-inventory",
|
||||
"received-export",
|
||||
() => warehouseService.receivedExport().then((r) => r.data),
|
||||
() => ["warehouse-inventory", "received-export"],
|
||||
),
|
||||
|
||||
loadedExport: endpoint<void, ReadyToLoadRow[]>(
|
||||
"warehouse-inventory",
|
||||
"loaded-export",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { api as client } from '../auth/http';
|
||||
import { URL_CONSTANTS } from '@/constants/URLS';
|
||||
import { unwrap } from '@/utils/endpoint';
|
||||
import type {
|
||||
AssignCustomsRiskPayload,
|
||||
CreateDjiboutiIncidentPayload,
|
||||
CreateEmptyContainerReturnPayload,
|
||||
DjiboutiIncident,
|
||||
EmptyContainerReturn,
|
||||
ImportCustomsFinalization,
|
||||
ImportOperationActionPayload,
|
||||
RecordDeclarationPayload,
|
||||
UpdateEmptyContainerReturnStatusPayload,
|
||||
UploadImportCustomsDocumentPayload,
|
||||
} from '@/types/importOperations';
|
||||
|
||||
export const importOperationsService = {
|
||||
listIncidents: async (bookingId?: string): Promise<DjiboutiIncident[]> => {
|
||||
const response = await client.get<DjiboutiIncident[]>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
|
||||
{ params: { bookingId } },
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
createIncident: async (
|
||||
payload: CreateDjiboutiIncidentPayload,
|
||||
): Promise<DjiboutiIncident> => {
|
||||
const response = await client.post<DjiboutiIncident>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.DJIBOUTI_INCIDENTS,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getCustoms: async (bookingId: string): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.get<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS(bookingId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
uploadCustomsDocument: async (
|
||||
bookingId: string,
|
||||
payload: UploadImportCustomsDocumentPayload,
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DOCUMENTS(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
recordDeclaration: async (
|
||||
bookingId: string,
|
||||
payload: RecordDeclarationPayload,
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DECLARATION(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
notifyDutiesTaxes: async (
|
||||
bookingId: string,
|
||||
payload: ImportOperationActionPayload = {},
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_NOTIFY_DUTIES_TAXES(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markDutiesTaxesPaid: async (
|
||||
bookingId: string,
|
||||
payload: ImportOperationActionPayload = {},
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_DUTIES_TAXES_PAID(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
assignRisk: async (
|
||||
bookingId: string,
|
||||
payload: AssignCustomsRiskPayload,
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RISK(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markReleasePermitted: async (
|
||||
bookingId: string,
|
||||
payload: ImportOperationActionPayload = {},
|
||||
): Promise<ImportCustomsFinalization> => {
|
||||
const response = await client.post<ImportCustomsFinalization>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.CUSTOMS_RELEASE_PERMITTED(bookingId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
listEmptyReturns: async (): Promise<EmptyContainerReturn[]> => {
|
||||
const response = await client.get<EmptyContainerReturn[]>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
createEmptyReturn: async (
|
||||
payload: CreateEmptyContainerReturnPayload,
|
||||
): Promise<EmptyContainerReturn> => {
|
||||
const response = await client.post<EmptyContainerReturn>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURNS,
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
updateEmptyReturnStatus: async (
|
||||
id: string,
|
||||
payload: UpdateEmptyContainerReturnStatusPayload,
|
||||
): Promise<EmptyContainerReturn> => {
|
||||
const response = await client.post<EmptyContainerReturn>(
|
||||
URL_CONSTANTS.IMPORT_OPERATIONS.EMPTY_CONTAINER_RETURN_STATUS(id),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
};
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
CreateTrainSchedulePayload,
|
||||
EligibleContainerBookingsResponse,
|
||||
FreightType,
|
||||
ImportDjiboutiActionPayload,
|
||||
ImportDjiboutiLoadList,
|
||||
ImportDjiboutiOperation,
|
||||
LocomotiveRecord,
|
||||
PinWagonsPayload,
|
||||
RecordCheckpointPayload,
|
||||
@@ -21,6 +24,7 @@ import type {
|
||||
TrainSchedulePreviewResponse,
|
||||
TrainSchedulingGlobalRules,
|
||||
TrainTrackResponse,
|
||||
UploadImportDjiboutiDocumentPayload,
|
||||
WagonAllocationAttemptResult,
|
||||
YardOption,
|
||||
} from "@/types/trainScheduling";
|
||||
@@ -261,6 +265,101 @@ export const trainSchedulingService = {
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
getImportDjiboutiOperation: async (
|
||||
scheduleId: string,
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.get<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI(scheduleId),
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
uploadImportDjiboutiDocument: async (
|
||||
scheduleId: string,
|
||||
payload: UploadImportDjiboutiDocumentPayload,
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DOCUMENTS(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
grantImportDjiboutiGatepass: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_GATEPASS_GRANTED(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
markImportDjiboutiReadyForLoading: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_READY_FOR_LOADING(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
confirmImportDjiboutiLoadedOnTrain: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOADED_ON_TRAIN(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
departImportFromDjibouti: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiOperation> => {
|
||||
const response = await client.post<ImportDjiboutiOperation>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_DEPART(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
generateImportDjiboutiLoadList: async (
|
||||
scheduleId: string,
|
||||
payload: ImportDjiboutiActionPayload = {},
|
||||
): Promise<ImportDjiboutiLoadList> => {
|
||||
const response = await client.post<ImportDjiboutiLoadList>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST(scheduleId),
|
||||
payload,
|
||||
);
|
||||
return unwrap(response.data);
|
||||
},
|
||||
|
||||
downloadImportDjiboutiLoadListDocument: async (
|
||||
scheduleId: string,
|
||||
): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.IMPORT_DJIBOUTI_LOAD_LIST_DOCUMENT(scheduleId),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
downloadExportLoadListDocument: async (
|
||||
scheduleId: string,
|
||||
): Promise<Blob> => {
|
||||
const response = await client.get(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.EXPORT_LOAD_LIST_DOCUMENT(scheduleId),
|
||||
{ responseType: "blob" },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
getTrack: async (scheduleId: string): Promise<TrainTrackResponse> => {
|
||||
const response = await client.get<TrainTrackResponse>(
|
||||
URL_CONSTANTS.TRAIN_SCHEDULING.CHECKPOINTS(scheduleId),
|
||||
|
||||
@@ -149,6 +149,8 @@ export const warehouseService = {
|
||||
apiClient.post<LoadPassedExportResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.LOAD_PASSED_EXPORT, {}),
|
||||
bulkMarkInspected: (payload: BulkInspectPayload) =>
|
||||
apiClient.post<BulkInspectResult>(URL_CONSTANTS.WAREHOUSE_INVENTORY.BULK_MARK_INSPECTED, payload),
|
||||
receivedExport: () =>
|
||||
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.RECEIVED_EXPORT),
|
||||
readyToLoadExport: () =>
|
||||
apiClient.get<ReadyToLoadRow[]>(URL_CONSTANTS.WAREHOUSE_INVENTORY.READY_TO_LOAD_EXPORT),
|
||||
loadedExport: () =>
|
||||
|
||||
124
apps/edr-freight-web/backoffice/src/types/importOperations.ts
Normal file
124
apps/edr-freight-web/backoffice/src/types/importOperations.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
export type DjiboutiIncidentType =
|
||||
| 'SEAL_BROKEN'
|
||||
| 'CONTAINER_OPENED'
|
||||
| 'CONTAINER_DAMAGED'
|
||||
| 'FLUID_LEAKING'
|
||||
| 'QUANTITY_MISMATCH'
|
||||
| 'WEIGHT_MISMATCH'
|
||||
| 'OTHER';
|
||||
|
||||
export interface DjiboutiIncident {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
containerNumber: string | null;
|
||||
cargoId: string | null;
|
||||
facility: string | null;
|
||||
station: string | null;
|
||||
incidentType: DjiboutiIncidentType;
|
||||
description: string;
|
||||
photos: string[];
|
||||
reportedBy: string | null;
|
||||
reportedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateDjiboutiIncidentPayload {
|
||||
bookingId: string;
|
||||
containerNumber?: string;
|
||||
cargoId?: string;
|
||||
facility?: string;
|
||||
station?: string;
|
||||
incidentType: DjiboutiIncidentType;
|
||||
description: string;
|
||||
photos?: string[];
|
||||
reportedBy?: string;
|
||||
reportedAt?: string;
|
||||
}
|
||||
|
||||
export type ImportCustomsDocumentType =
|
||||
| 'IM4'
|
||||
| 'IM5'
|
||||
| 'T1_CLOSURE_PROOF'
|
||||
| 'TRANSIT_PERMIT_SCREENSHOT'
|
||||
| 'CUSTOMER_PAYMENT_SLIP'
|
||||
| 'IMPORT_RELEASE_PERMIT';
|
||||
|
||||
export type ImportCustomsRiskLevel = 'GREEN' | 'YELLOW' | 'BLUE' | 'RED';
|
||||
|
||||
export interface ImportCustomsFinalization {
|
||||
id: string;
|
||||
bookingId: string;
|
||||
documents: Partial<Record<ImportCustomsDocumentType, string>>;
|
||||
declarationSerialNumber: string | null;
|
||||
dutiesTaxesNotifiedAt: string | null;
|
||||
dutiesTaxesPaidAt: string | null;
|
||||
customsRisk: ImportCustomsRiskLevel | null;
|
||||
importReleasePermittedAt: string | null;
|
||||
completedAt: string | null;
|
||||
performedBy: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface ImportOperationActionPayload {
|
||||
performedBy?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UploadImportCustomsDocumentPayload {
|
||||
documentType: ImportCustomsDocumentType;
|
||||
fileId: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface RecordDeclarationPayload {
|
||||
declarationSerialNumber: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface AssignCustomsRiskPayload {
|
||||
risk: ImportCustomsRiskLevel;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export type EmptyContainerReturnStatus =
|
||||
| 'RETURNED'
|
||||
| 'ASSIGNED_STORAGE'
|
||||
| 'DOCUMENTATION_CLEARED'
|
||||
| 'WAGON_ALLOCATED'
|
||||
| 'TRANSPORTED_TO_DJIBOUTI'
|
||||
| 'HANDOVER_ISSUED'
|
||||
| 'COMPLETED';
|
||||
|
||||
export interface EmptyContainerReturn {
|
||||
id: string;
|
||||
containerNumber: string;
|
||||
bookingId: string | null;
|
||||
customerId: string | null;
|
||||
returnDate: string;
|
||||
facility: string | null;
|
||||
yard: string | null;
|
||||
zone: string | null;
|
||||
condition: string | null;
|
||||
handoverNote: string | null;
|
||||
status: EmptyContainerReturnStatus;
|
||||
wagonAllocationReference: string | null;
|
||||
performedBy: string | null;
|
||||
}
|
||||
|
||||
export interface CreateEmptyContainerReturnPayload {
|
||||
containerNumber: string;
|
||||
bookingId?: string;
|
||||
customerId?: string;
|
||||
returnDate?: string;
|
||||
facility?: string;
|
||||
yard?: string;
|
||||
zone?: string;
|
||||
condition?: string;
|
||||
handoverNote?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface UpdateEmptyContainerReturnStatusPayload extends ImportOperationActionPayload {
|
||||
status: EmptyContainerReturnStatus;
|
||||
wagonAllocationReference?: string;
|
||||
handoverNote?: string;
|
||||
}
|
||||
@@ -399,6 +399,81 @@ export interface TrainScheduleDetail {
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| "DELIVERY_ORDER"
|
||||
| "PORT_INVOICE"
|
||||
| "DJIBOUTI_T1"
|
||||
| "ETHIOPIA_T1"
|
||||
| "RAILWAY_BILL";
|
||||
|
||||
export interface ImportDjiboutiDocumentRecord {
|
||||
fileId?: string | null;
|
||||
fileUrl?: string | null;
|
||||
reference?: string | null;
|
||||
uploadedAt: string;
|
||||
uploadedBy?: string | null;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiOperation {
|
||||
trainScheduleId: string;
|
||||
trainNumber: string | null;
|
||||
direction: string | null;
|
||||
status: {
|
||||
documentsComplete: boolean;
|
||||
missingDocuments: ImportDjiboutiDocumentType[];
|
||||
gatepassGranted: boolean;
|
||||
readyForLoading: boolean;
|
||||
loadedOnTrain: boolean;
|
||||
departedFromDjibouti: boolean;
|
||||
loadListGenerated: boolean;
|
||||
};
|
||||
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||
gatepassGrantedAt: string | null;
|
||||
readyForLoadingAt: string | null;
|
||||
loadedOnTrainAt: string | null;
|
||||
departedFromDjiboutiAt: string | null;
|
||||
loadListGeneratedAt: string | null;
|
||||
performedBy: string | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface UploadImportDjiboutiDocumentPayload {
|
||||
documentType: ImportDjiboutiDocumentType;
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
reference?: string;
|
||||
notes?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiActionPayload {
|
||||
notes?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiLoadList {
|
||||
generatedAt: string;
|
||||
trainScheduleId: string;
|
||||
trainNumber: string | null;
|
||||
route: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
totalBookings: number;
|
||||
wagons: Array<{
|
||||
sequenceNo: number;
|
||||
wagonNumber: string | null;
|
||||
allocations: Array<{
|
||||
bookingId: string;
|
||||
bookingReference: string | null;
|
||||
loadType: string | null;
|
||||
allocatedWeightTons: number;
|
||||
containerNumbers: string[];
|
||||
}>;
|
||||
}>;
|
||||
operation: ImportDjiboutiOperation;
|
||||
}
|
||||
|
||||
export type TrainCheckpointKind = "DEPARTED" | "PASSED" | "ARRIVED";
|
||||
|
||||
export interface TrackStation {
|
||||
|
||||
@@ -341,6 +341,20 @@ export interface ReserveInventoryPayload {
|
||||
export interface ReleaseOrderPayload {
|
||||
reference?: string;
|
||||
releaseDate?: string;
|
||||
bookingId?: string;
|
||||
customerId?: string;
|
||||
truckPlateNumber?: string;
|
||||
trailerPlateNumber?: string;
|
||||
driverName?: string;
|
||||
driverLicense?: string;
|
||||
driverPhone?: string;
|
||||
truckType?: string;
|
||||
containerNumber?: string;
|
||||
gateInTime?: string;
|
||||
tareWeight?: number;
|
||||
grossWeight?: number;
|
||||
netWeight?: number;
|
||||
gateOutTime?: string;
|
||||
}
|
||||
|
||||
/** Import branch: proof of delivery captured on customer pickup. */
|
||||
@@ -356,6 +370,13 @@ export interface EligibleBooking {
|
||||
reference: string;
|
||||
customerId: string | null;
|
||||
customer: string | null;
|
||||
customerTin: string | null;
|
||||
customerPhone: string | null;
|
||||
containerNumber: string | null;
|
||||
containerQuantity: number | null;
|
||||
containerPackagingType: string | null;
|
||||
cargoDescription: string | null;
|
||||
lastMileRequested: boolean;
|
||||
direction: string;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
@@ -370,6 +391,10 @@ export interface EligibleBooking {
|
||||
firstMileVehicleId: string | null;
|
||||
firstMileTruckPlateNumber: string | null;
|
||||
firstMileTrailerPlateNumber: string | null;
|
||||
firstMileDriverName: string | null;
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
@@ -378,7 +403,7 @@ export interface BulkReceivePayload {
|
||||
yardId: string;
|
||||
zoneId: string;
|
||||
bookingIds: string[];
|
||||
truckEntrance: TruckEntrancePayload;
|
||||
truckEntrance?: TruckEntrancePayload;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
@@ -392,6 +417,7 @@ export interface TruckEntrancePayload {
|
||||
consigneeDetails?: string;
|
||||
edrDigitalBookingId?: string;
|
||||
tin?: string;
|
||||
customerPhone?: string;
|
||||
truckPlateNumber: string;
|
||||
trailerPlateNumber?: string;
|
||||
assignedEquipmentNumber?: string;
|
||||
@@ -822,6 +848,8 @@ export interface PayInvoicePayload {
|
||||
amount: number;
|
||||
method?: string;
|
||||
reference?: string;
|
||||
driverName?: string;
|
||||
driverPhone?: string;
|
||||
}
|
||||
|
||||
// ── Payloads ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
// export const API_BASE_URL = 'https://fhcdev-backend.triaplc.com';
|
||||
// export const API_BASE_URL = 'http://localhost:3001';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_seatId_fkey";
|
||||
|
||||
-- DropForeignKey
|
||||
ALTER TABLE "passenger"."TicketSeat" DROP CONSTRAINT IF EXISTS "TicketSeat_ticketId_fkey";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "passenger"."Ticket_bookingId_key";
|
||||
|
||||
-- AlterTable: Station
|
||||
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";
|
||||
|
||||
-- AlterTable: Ticket — add columns with safe defaults
|
||||
ALTER TABLE "passenger"."Ticket"
|
||||
ADD COLUMN IF NOT EXISTS "leg" INTEGER NOT NULL DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS "passengerName" TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS "scheduleId" TEXT,
|
||||
ADD COLUMN IF NOT EXISTS "seatId" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE IF EXISTS "passenger"."TicketSeat";
|
||||
|
||||
-- Remove GateValidationLog rows referencing orphan tickets first
|
||||
DELETE FROM "passenger"."GateValidationLog"
|
||||
WHERE "ticketId" IN (
|
||||
SELECT "id" FROM "passenger"."Ticket"
|
||||
WHERE "seatId" = ''
|
||||
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat")
|
||||
);
|
||||
|
||||
-- Remove orphan ticket rows
|
||||
DELETE FROM "passenger"."Ticket"
|
||||
WHERE "seatId" = ''
|
||||
OR "seatId" NOT IN (SELECT "id" FROM "passenger"."Seat");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Ticket_bookingId_idx" ON "passenger"."Ticket"("bookingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX IF NOT EXISTS "Ticket_seatId_idx" ON "passenger"."Ticket"("seatId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "passenger"."Ticket"
|
||||
ADD CONSTRAINT "Ticket_seatId_fkey"
|
||||
FOREIGN KEY ("seatId") REFERENCES "passenger"."Seat"("id")
|
||||
ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Drop temporary defaults that were only needed for the backfill
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "passengerName" DROP DEFAULT;
|
||||
ALTER TABLE "passenger"."Ticket" ALTER COLUMN "seatId" DROP DEFAULT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Remove timezone column if it still exists
|
||||
ALTER TABLE "passenger"."Station" DROP COLUMN IF EXISTS "timezone";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "passenger"."TravelerProfile" ADD COLUMN "gender" TEXT;
|
||||
@@ -0,0 +1,117 @@
|
||||
-- Migration: Add Configurable Fare Management System
|
||||
|
||||
-- Main fare configuration table
|
||||
CREATE TABLE "fare_configurations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"effective_date" TIMESTAMP(3) NOT NULL,
|
||||
"expiry_date" TIMESTAMP(3),
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT false,
|
||||
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_by" TEXT,
|
||||
"approved_by" TEXT,
|
||||
"approved_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "fare_configurations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Rate structure by nationality and coach/position
|
||||
CREATE TABLE "fare_rate_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"nationality_type" TEXT NOT NULL, -- 'LOCAL' or 'INTERNATIONAL'
|
||||
"coach_type" TEXT NOT NULL, -- 'REGULAR_SEAT', 'ECONOMY_BED', 'VIP_BED'
|
||||
"bed_position" TEXT, -- 'UPPER', 'MIDDLE', 'LOWER', NULL for seats
|
||||
"rate_per_km_minor" INTEGER NOT NULL,
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "fare_rate_rules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Configurable fare components (insurance, premiums, service charges, taxes)
|
||||
CREATE TABLE "fare_components" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"component_type" TEXT NOT NULL, -- 'INSURANCE', 'PREMIUM', 'SERVICE_CHARGE', 'TAX', 'DEMAND'
|
||||
"component_name" TEXT NOT NULL,
|
||||
"calculation_method" TEXT NOT NULL, -- 'MULTIPLIER', 'PERCENTAGE', 'FIXED_AMOUNT'
|
||||
"value_minor" INTEGER, -- For fixed amounts
|
||||
"percentage_value" DECIMAL(10,6), -- For percentages (e.g., 0.02 for 2%)
|
||||
"applies_to" TEXT NOT NULL DEFAULT 'SUBTOTAL', -- 'BASE_FARE', 'SUBTOTAL', 'TOTAL'
|
||||
"apply_order" INTEGER NOT NULL DEFAULT 1, -- Order of application
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "fare_components_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Age-based pricing rules
|
||||
CREATE TABLE "age_pricing_rules" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"rule_name" TEXT NOT NULL,
|
||||
"min_age" INTEGER NOT NULL,
|
||||
"max_age" INTEGER,
|
||||
"pricing_type" TEXT NOT NULL, -- 'FREE', 'FULL_FARE', 'DISCOUNTED'
|
||||
"discount_percentage" DECIMAL(5,4), -- For discounted fares
|
||||
"max_free_passengers" INTEGER, -- For free fares (e.g., 1 free child)
|
||||
"applies_to_components" BOOLEAN NOT NULL DEFAULT false, -- Whether discount applies to components too
|
||||
"is_active" BOOLEAN NOT NULL DEFAULT true,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "age_pricing_rules_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Audit trail for configuration changes
|
||||
CREATE TABLE "fare_configuration_audit" (
|
||||
"id" TEXT NOT NULL,
|
||||
"fare_config_id" TEXT NOT NULL,
|
||||
"action" TEXT NOT NULL, -- 'CREATED', 'UPDATED', 'ACTIVATED', 'DEACTIVATED'
|
||||
"changed_by" TEXT,
|
||||
"changes" JSONB, -- Store the actual changes made
|
||||
"timestamp" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "fare_configuration_audit_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Foreign key constraints
|
||||
ALTER TABLE "fare_rate_rules" ADD CONSTRAINT "fare_rate_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "fare_components" ADD CONSTRAINT "fare_components_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "age_pricing_rules" ADD CONSTRAINT "age_pricing_rules_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
ALTER TABLE "fare_configuration_audit" ADD CONSTRAINT "fare_configuration_audit_fare_config_id_fkey" FOREIGN KEY ("fare_config_id") REFERENCES "fare_configurations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX "fare_configurations_effective_date_idx" ON "fare_configurations"("effective_date");
|
||||
CREATE INDEX "fare_configurations_is_active_idx" ON "fare_configurations"("is_active");
|
||||
CREATE UNIQUE INDEX "fare_configurations_default_unique_idx" ON "fare_configurations"("is_default") WHERE "is_default" = true;
|
||||
|
||||
CREATE INDEX "fare_rate_rules_config_lookup_idx" ON "fare_rate_rules"("fare_config_id", "nationality_type", "coach_type", "bed_position");
|
||||
CREATE INDEX "fare_components_config_order_idx" ON "fare_components"("fare_config_id", "apply_order");
|
||||
CREATE INDEX "age_pricing_rules_age_lookup_idx" ON "age_pricing_rules"("fare_config_id", "min_age", "max_age");
|
||||
|
||||
-- Add legacy mode flag to existing fare tables for gradual migration
|
||||
ALTER TABLE "FareRule" ADD COLUMN "migrated_to_config_id" TEXT;
|
||||
ALTER TABLE "SegmentFareRule" ADD COLUMN "migrated_to_config_id" TEXT;
|
||||
|
||||
-- Add feature flag support
|
||||
CREATE TABLE "system_features" (
|
||||
"id" TEXT NOT NULL,
|
||||
"feature_name" TEXT NOT NULL UNIQUE,
|
||||
"is_enabled" BOOLEAN NOT NULL DEFAULT false,
|
||||
"config" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "system_features_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- Insert the configurable fares feature flag
|
||||
INSERT INTO "system_features" ("id", "feature_name", "is_enabled", "config")
|
||||
VALUES ('cf-001', 'USE_CONFIGURABLE_FARES', false, '{"rollout_percentage": 0}');
|
||||
@@ -304,6 +304,7 @@ model TravelerProfile {
|
||||
id String @id @default(uuid())
|
||||
passengerId String
|
||||
fullName String
|
||||
gender String?
|
||||
relationship String
|
||||
dateOfBirth DateTime?
|
||||
nationalId String?
|
||||
@@ -321,7 +322,6 @@ model Station {
|
||||
countryCode String?
|
||||
sequence Int @default(0)
|
||||
isOperational Boolean @default(true)
|
||||
timezone String @default("Africa/Addis_Ababa")
|
||||
lat Decimal? @db.Decimal(9, 6)
|
||||
lng Decimal? @db.Decimal(9, 6)
|
||||
originSchedules TrainSchedule[] @relation("OriginTrips")
|
||||
@@ -459,7 +459,7 @@ model Seat {
|
||||
coach Coach @relation(fields: [coachId], references: [id])
|
||||
bookingSeats BookingSeat[]
|
||||
blocks SeatBlock[]
|
||||
ticketSeats TicketSeat[]
|
||||
tickets Ticket[]
|
||||
|
||||
@@unique([coachId, seatNumber])
|
||||
@@unique([coachId, row, col])
|
||||
@@ -542,7 +542,7 @@ model Booking {
|
||||
returnSchedule TrainSchedule? @relation("ReturnSchedule", fields: [returnScheduleId], references: [id])
|
||||
seats BookingSeat[]
|
||||
paymentIntent PaymentIntent?
|
||||
ticket Ticket?
|
||||
tickets Ticket[]
|
||||
foodOrders FoodOrder[]
|
||||
agentBooking AgentBooking?
|
||||
modifications BookingModification[]
|
||||
@@ -660,8 +660,12 @@ model PaymentRefund {
|
||||
|
||||
model Ticket {
|
||||
id String @id @default(uuid())
|
||||
bookingId String @unique
|
||||
bookingId String
|
||||
bookingRef String
|
||||
passengerName String
|
||||
seatId String
|
||||
leg Int @default(1)
|
||||
scheduleId String?
|
||||
status String @default("ACTIVE")
|
||||
qrPayload String
|
||||
barcodePayload String?
|
||||
@@ -672,20 +676,9 @@ model Ticket {
|
||||
validatorId String?
|
||||
boardedAt DateTime?
|
||||
booking Booking @relation(fields: [bookingId], references: [id])
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
validationLogs GateValidationLog[]
|
||||
seats TicketSeat[]
|
||||
@@schema("passenger")
|
||||
}
|
||||
|
||||
model TicketSeat {
|
||||
id String @id @default(uuid())
|
||||
ticketId String
|
||||
seatId String
|
||||
seatIndex Int @default(0)
|
||||
ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade)
|
||||
seat Seat @relation(fields: [seatId], references: [id])
|
||||
|
||||
@@index([ticketId])
|
||||
@@index([bookingId])
|
||||
@@index([seatId])
|
||||
@@schema("passenger")
|
||||
}
|
||||
@@ -1013,7 +1006,7 @@ model RouteStop {
|
||||
routeId String
|
||||
stationId String
|
||||
sequence Int
|
||||
distanceKm Int?
|
||||
distanceKm Float?
|
||||
createdAt DateTime @default(now())
|
||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ async function seedRoute() {
|
||||
const station = await prisma.station.findUnique({ where: { code: stationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: route.id, sequence: i + 1 } },
|
||||
update: {},
|
||||
update: { distanceKm: routeDistancesKm[i] },
|
||||
create: { routeId: route.id, stationId: station!.id, sequence: i + 1, distanceKm: routeDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
@@ -227,12 +227,13 @@ async function seedRoute() {
|
||||
});
|
||||
|
||||
const returnStationCodes = ['DRE', 'BIK', 'MIS', 'MTE', 'ADM', 'MOJ', 'BSH', 'LEB', 'SBT'];
|
||||
// Cumulative distances from origin (Dire Dawa), mirroring the outbound route in reverse
|
||||
const returnRouteDistancesKm = [0, 119.4, 181.4, 232.8, 306.3, 323.1, 345.8, 401.5, 413.0];
|
||||
for (let i = 0; i < returnStationCodes.length; i++) {
|
||||
const station = await prisma.station.findUnique({ where: { code: returnStationCodes[i] } });
|
||||
await prisma.routeStop.upsert({
|
||||
where: { routeId_sequence: { routeId: returnRoute!.id, sequence: i + 1 } },
|
||||
update: {},
|
||||
update: { distanceKm: returnRouteDistancesKm[i] },
|
||||
create: { routeId: returnRoute!.id, stationId: station!.id, sequence: i + 1, distanceKm: returnRouteDistancesKm[i] },
|
||||
});
|
||||
}
|
||||
@@ -757,13 +758,7 @@ async function runStep(name: string, step: () => Promise<unknown>): Promise<bool
|
||||
async function main() {
|
||||
console.log('🌱 Comprehensive EDR Seed Starting...\n');
|
||||
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
['system users', seedSystemUsers],
|
||||
['fare rules', seedFareRules],
|
||||
['segment fares', seedSegmentFares],
|
||||
['currency', seedCurrency],
|
||||
['notification templates', seedNotificationTemplates],
|
||||
['kulubbi package', seedKulubbiPackage],
|
||||
const steps: Array<[string, () => Promise<unknown>]> = [
|
||||
];
|
||||
|
||||
let failed = 0;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ThrottlerModule } from '@nestjs/throttler';
|
||||
import { DynamicThrottlerGuard } from './common/dynamic-throttler.guard';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { APP_GUARD, APP_FILTER } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
@@ -24,6 +24,7 @@ import { PrismaModule } from './common/prisma.module';
|
||||
import { AuditModule } from './common/audit.module';
|
||||
import { I18nModule } from './common/i18n/i18n.module';
|
||||
import { LocaleMiddleware } from './common/i18n/locale.middleware';
|
||||
import { DeleteExceptionFilter } from './common/exceptions/delete-exception.filter';
|
||||
import appConfig from './config/app.config';
|
||||
import dbConfig from './config/database.config';
|
||||
import iamDatabaseConfig from './config/iam-database.config';
|
||||
@@ -65,6 +66,7 @@ import { PackagesModule } from './modules/packages/packages.module';
|
||||
import { ExcessBaggageModule } from './modules/excess-baggage/excess-baggage.module';
|
||||
import { HealthModule } from './modules/health/health.module';
|
||||
import { TasksModule } from './modules/tasks/tasks.module';
|
||||
import { ConfigurableFareModule } from './modules/configurable-fare/configurable-fare.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -134,9 +136,11 @@ import { TasksModule } from './modules/tasks/tasks.module';
|
||||
ExcessBaggageModule,
|
||||
HealthModule,
|
||||
TasksModule,
|
||||
ConfigurableFareModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: DynamicThrottlerGuard },
|
||||
{ provide: APP_FILTER, useClass: DeleteExceptionFilter },
|
||||
DynamicThrottlerGuard,
|
||||
EdrPassengerOrgSeeder,
|
||||
PassengerStaffUsersSeeder,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ExceptionFilter, Catch, ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
import { DeleteOperationException } from './delete-operation.exception';
|
||||
|
||||
@Catch(DeleteOperationException, HttpException)
|
||||
export class DeleteExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: DeleteOperationException | HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const status = exception.getStatus?.() || HttpStatus.BAD_REQUEST;
|
||||
|
||||
if (exception instanceof DeleteOperationException) {
|
||||
// Format the response specifically for delete operations
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
error: 'Delete Operation Failed',
|
||||
message: exception.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: 'DELETE_CONSTRAINT_VIOLATION',
|
||||
userFriendly: true,
|
||||
details: {
|
||||
canRetry: true,
|
||||
action: 'RESOLVE_DEPENDENCIES',
|
||||
hint: 'Please resolve the listed dependencies and try again.'
|
||||
}
|
||||
});
|
||||
} else if (exception instanceof HttpException) {
|
||||
// Handle other HTTP exceptions normally
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
response.status(status).json({
|
||||
statusCode: status,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(typeof exceptionResponse === 'object'
|
||||
? exceptionResponse
|
||||
: { message: exceptionResponse }
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export interface DeleteConstraint {
|
||||
entityName: string;
|
||||
count: number;
|
||||
action: 'delete' | 'reassign' | 'cancel' | 'complete';
|
||||
}
|
||||
|
||||
export class DeleteOperationException extends BadRequestException {
|
||||
constructor(
|
||||
entityType: string,
|
||||
entityName: string,
|
||||
constraints: DeleteConstraint[]
|
||||
) {
|
||||
const message = DeleteOperationException.buildUserFriendlyMessage(
|
||||
entityType,
|
||||
entityName,
|
||||
constraints
|
||||
);
|
||||
super(message);
|
||||
}
|
||||
|
||||
private static buildUserFriendlyMessage(
|
||||
entityType: string,
|
||||
entityName: string,
|
||||
constraints: DeleteConstraint[]
|
||||
): string {
|
||||
const baseMessage = `Cannot delete ${entityType.toLowerCase()} "${entityName}".`;
|
||||
|
||||
if (constraints.length === 0) {
|
||||
return `${baseMessage} Unknown constraint violation.`;
|
||||
}
|
||||
|
||||
const constraintMessages = constraints.map(constraint => {
|
||||
const { entityName: constraintEntity, count, action } = constraint;
|
||||
|
||||
const entityDisplayName = count === 1
|
||||
? constraintEntity.toLowerCase()
|
||||
: `${constraintEntity.toLowerCase()}s`;
|
||||
|
||||
const actionText = this.getActionText(action, count);
|
||||
|
||||
return `• ${count} ${entityDisplayName} ${count === 1 ? 'is' : 'are'} still ${this.getStatusText(constraintEntity)}. Please ${actionText} first.`;
|
||||
});
|
||||
|
||||
return [
|
||||
baseMessage,
|
||||
'',
|
||||
'The following dependencies must be resolved:',
|
||||
...constraintMessages,
|
||||
'',
|
||||
'Once all dependencies are resolved, you can retry the deletion.'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
private static getActionText(action: string, count: number): string {
|
||||
const actions: Record<string, string> = {
|
||||
delete: count === 1 ? 'delete it' : 'delete them',
|
||||
reassign: count === 1 ? 'reassign it' : 'reassign them',
|
||||
cancel: count === 1 ? 'cancel it' : 'cancel them',
|
||||
complete: count === 1 ? 'complete it' : 'complete them'
|
||||
};
|
||||
return actions[action] || (count === 1 ? 'resolve it' : 'resolve them');
|
||||
}
|
||||
|
||||
private static getStatusText(entityName: string): string {
|
||||
const statusTexts: Record<string, string> = {
|
||||
booking: 'active',
|
||||
schedule: 'in use',
|
||||
coach: 'assigned',
|
||||
seat: 'occupied or blocked',
|
||||
'seat class': 'in use by fare rules',
|
||||
'coach type': 'in use by coaches or seat classes',
|
||||
train: 'scheduled',
|
||||
ticket: 'issued',
|
||||
'payment record': 'linked',
|
||||
'fare rule': 'active',
|
||||
route: 'in use by schedules',
|
||||
passenger: 'active with bookings or accounts',
|
||||
promotion: 'active',
|
||||
station: 'in use by routes'
|
||||
};
|
||||
return statusTexts[entityName.toLowerCase()] || 'in use';
|
||||
}
|
||||
}
|
||||
2
apps/edr-passenger-api/src/common/exceptions/index.ts
Normal file
2
apps/edr-passenger-api/src/common/exceptions/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { DeleteOperationException, DeleteConstraint } from './delete-operation.exception';
|
||||
export { DeleteExceptionFilter } from './delete-exception.filter';
|
||||
33
apps/edr-passenger-api/src/common/iam.guard.ts
Normal file
33
apps/edr-passenger-api/src/common/iam.guard.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@Injectable()
|
||||
export class IamGuard implements CanActivate {
|
||||
canActivate(
|
||||
context: ExecutionContext,
|
||||
): boolean | Promise<boolean> | Observable<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('No IAM token provided');
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
// TODO: Implement actual IAM token validation
|
||||
// For now, just check if token exists
|
||||
if (!token) {
|
||||
throw new UnauthorizedException('Invalid IAM token');
|
||||
}
|
||||
|
||||
// Add user info to request for downstream usage
|
||||
request.user = {
|
||||
id: 'iam-user-id',
|
||||
roles: ['AGENT'],
|
||||
permissions: []
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
137
apps/edr-passenger-api/src/common/utils/timezone.utils.ts
Normal file
137
apps/edr-passenger-api/src/common/utils/timezone.utils.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Timezone Utility for Ethiopian Railway
|
||||
*
|
||||
* All dates/times in the system are stored and handled in Ethiopian Time (EAT - UTC+3).
|
||||
* This utility ensures consistent date handling across the application.
|
||||
*
|
||||
* IMPORTANT: The application timezone is set to 'Africa/Addis_Ababa' in main.ts
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a date string or Date object ensuring it's treated as Ethiopian time (EAT - UTC+3)
|
||||
*
|
||||
* @param dateInput - ISO string, date string, or Date object
|
||||
* @returns Date object in Ethiopian time
|
||||
*
|
||||
* @example
|
||||
* parseEthiopianTime('2026-06-15T08:00:00') // Treats as 08:00 EAT, not UTC
|
||||
* parseEthiopianTime('2026-06-15') // Treats as midnight EAT
|
||||
*/
|
||||
export function parseEthiopianTime(dateInput: string | Date): Date {
|
||||
if (dateInput instanceof Date) {
|
||||
return dateInput;
|
||||
}
|
||||
|
||||
// Parse as local time (EAT) since TZ is set to Africa/Addis_Ababa
|
||||
return new Date(dateInput);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of day (00:00:00) in Ethiopian time
|
||||
*
|
||||
* @param date - Date object or date string
|
||||
* @returns Date object set to midnight EAT
|
||||
*/
|
||||
export function startOfDayEAT(date: Date | string): Date {
|
||||
const d = typeof date === 'string' ? new Date(date) : new Date(date);
|
||||
d.setHours(0, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the end of day (23:59:59.999) in Ethiopian time
|
||||
*
|
||||
* @param date - Date object or date string
|
||||
* @returns Date object set to end of day EAT
|
||||
*/
|
||||
export function endOfDayEAT(date: Date | string): Date {
|
||||
const d = typeof date === 'string' ? new Date(date) : new Date(date);
|
||||
d.setHours(23, 59, 59, 999);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the start of the next day in Ethiopian time
|
||||
*
|
||||
* @param date - Date object or date string
|
||||
* @returns Date object set to midnight of next day EAT
|
||||
*/
|
||||
export function startOfNextDayEAT(date: Date | string): Date {
|
||||
const d = startOfDayEAT(date);
|
||||
d.setDate(d.getDate() + 1);
|
||||
return d;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date for display in Ethiopian time
|
||||
*
|
||||
* @param date - Date object
|
||||
* @param options - Intl.DateTimeFormatOptions
|
||||
* @returns Formatted date string
|
||||
*/
|
||||
export function formatEthiopianTime(
|
||||
date: Date,
|
||||
options: Intl.DateTimeFormatOptions = {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
}
|
||||
): string {
|
||||
return new Intl.DateTimeFormat('en-ET', {
|
||||
...options,
|
||||
timeZone: 'Africa/Addis_Ababa',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add minutes to a date
|
||||
*
|
||||
* @param date - Date object
|
||||
* @param minutes - Number of minutes to add
|
||||
* @returns New Date object
|
||||
*/
|
||||
export function addMinutes(date: Date, minutes: number): Date {
|
||||
return new Date(date.getTime() + minutes * 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add hours to a date
|
||||
*
|
||||
* @param date - Date object
|
||||
* @param hours - Number of hours to add
|
||||
* @returns New Date object
|
||||
*/
|
||||
export function addHours(date: Date, hours: number): Date {
|
||||
return new Date(date.getTime() + hours * 60 * 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add days to a date
|
||||
*
|
||||
* @param date - Date object
|
||||
* @param days - Number of days to add
|
||||
* @returns New Date object
|
||||
*/
|
||||
export function addDays(date: Date, days: number): Date {
|
||||
const result = new Date(date);
|
||||
result.setDate(result.getDate() + days);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two dates are on the same day (Ethiopian time)
|
||||
*
|
||||
* @param date1 - First date
|
||||
* @param date2 - Second date
|
||||
* @returns true if both dates are on the same calendar day in EAT
|
||||
*/
|
||||
export function isSameDayEAT(date1: Date, date2: Date): boolean {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import { HttpExceptionFilter } from "./common/filters/http-exception.filter";
|
||||
import { ResponseTransformInterceptor } from "./common/interceptors/response-transform.interceptor";
|
||||
import { SessionActivityInterceptor } from "./common/interceptors/session-activity.interceptor";
|
||||
|
||||
// Set timezone to Africa/Addis_Ababa (EAT - UTC+3) for Ethiopian Railway operations
|
||||
process.env.TZ = 'Africa/Addis_Ababa';
|
||||
|
||||
async function bootstrap() {
|
||||
// rawBody: true buffers the unparsed request body onto req.rawBody so webhook handlers
|
||||
// (e.g. Waafi HMAC verification) can sign over the exact bytes the provider signed.
|
||||
@@ -44,6 +47,7 @@ async function bootstrap() {
|
||||
Enterprise-grade REST API for the Ethio-Djibouti Railway passenger booking and management platform. Built with NestJS, TypeScript, PostgreSQL, and Prisma ORM.
|
||||
|
||||
## Latest Updates
|
||||
- **Enhanced Module Coverage:** Complete API coverage with 25+ core modules including System Config, Excess Baggage, Packages, and comprehensive CRUD operations across all entities.
|
||||
- **Health Check Endpoints:** Three public probes added under \`/health\`. Liveness (\`GET /health\`), readiness with live DB ping (\`GET /health/ready\`), and app info (\`GET /health/info\`). All are exempt from rate limiting.
|
||||
- **Rate Limiting:** Global throttle enforced via ThrottlerGuard with three named tiers: auth (5 req/min on \`/auth\` and \`/fayda/verification\`), strict (20 req/min on \`/bookings\`, \`/passengers\`, \`/payments\`, \`/wallet\`), default (100 req/min everywhere else). Health probes, webhook handlers, and internal service endpoints are exempt.
|
||||
- **Boarding Pass on Gate Validation:** Every successful gate validation at \`POST /tickets/:ref/validate\` now automatically delivers a boarding pass to the passenger via email (full HTML with QR code, route, seat table) and SMS (compact text with ref, route, seats, barcode). The leg label (OUTBOUND, RETURN, LEG1, etc.) is included so passengers know which boarding it covers.
|
||||
@@ -313,10 +317,9 @@ Payment providers send notifications to:
|
||||
.addTag("Agents", "Counter booking, shift management, commission tracking, and reconciliation")
|
||||
.addTag("Excess Baggage", "IAM-protected agent/supervisor endpoints to log excess baggage charges, waive fees, resend payment links, and manage allowance rules per seat class. Public token-based endpoints let passengers self-pay outstanding charges.")
|
||||
.addTag("Packages", "Bundled travel packages with tiered pricing. Public endpoints for browsing and booking; JWT-authenticated endpoints for purchase history; IAM-protected endpoints for admin CRUD and tier management.")
|
||||
.addTag("Config", "System-wide configuration management including feature flags, maintenance modes, and operational parameters. IAM-protected endpoints for administrative control.")
|
||||
.addTag("Audit", "User activity logging, system changes, compliance tracking, and audit trails")
|
||||
.addTag("Passenger Auth", "Passenger registration, login, OTP, password reset, Fayda password setup, and profile management")
|
||||
.addTag("Booking", "Complete booking lifecycle: create, modify, cancel, guest checkout. Supports ONE_WAY | ROUND_TRIP | TRANSIT | ROUND_TRIP_TRANSIT booking types. returnLegStatus filter for round-trip no-show management")
|
||||
.addTag("Config", "System settings, feature flags, and configuration management")
|
||||
.addTag("Currencies", "Multi-currency support, exchange rates, and currency conversion")
|
||||
.addTag("Dashboard", "Home screen aggregations: trips, loyalty, wallet, notifications")
|
||||
.addTag("Fare Engine", "Distance-based fare calculation with age-based pricing and multi-currency")
|
||||
@@ -328,9 +331,10 @@ Payment providers send notifications to:
|
||||
.addTag("Live Tracking", "Real-time trip status, location updates, delays, and crowd signals")
|
||||
.addTag("Loyalty", "Points ledger, tier management (Bronze/Silver/Gold/Platinum), rewards")
|
||||
.addTag("Notifications", "Multi-channel delivery (email, SMS, push) and preference management. Boarding pass email+SMS sent automatically on gate validation.")
|
||||
.addTag("Configurable Fares", "Advanced fare management system with flexible configurations, rate rules, components, age-based pricing, and migration tools. Supports nationality-based rates, bed position pricing, and dynamic component calculations.")
|
||||
.addTag("Passenger Auth", "JWT-authenticated passenger login, profile, and session management")
|
||||
.addTag("Passengers", "Registration, Fayda verification, international passports, saved profiles. Rate limited: 20 req/min.")
|
||||
.addTag("Payment", "Telebirr, CBE Birr, Waafi, Card, Wallet payment processing and refunds. Rate limited: 20 req/min.")
|
||||
.addTag("Payment Webhooks", "Payment provider webhook handlers and transaction confirmation. Exempt from rate limiting.")
|
||||
.addTag("Promotions", "Promo codes, campaigns, discounts, and redemption tracking")
|
||||
.addTag("Reports", "Revenue analytics, occupancy reports, agent sales, and KPI dashboards")
|
||||
.addTag("Routes", "Route templates with ordered stops, fare rules, and baggage allowance")
|
||||
@@ -342,7 +346,6 @@ Payment providers send notifications to:
|
||||
.addTag("Stations", "Station directory, location data, baggage facilities, and amenities")
|
||||
.addTag("Support", "FAQ management, search, live chat conversations, and ticket resolution")
|
||||
.addTag("Tickets", "QR/barcode generation, gate validation with per-leg tracking (OUTBOUND/RETURN/LEG1/LEG2/OUTBOUND_LEG1/OUTBOUND_LEG2/RETURN_LEG1/RETURN_LEG2), audit trails. Boarding pass email+SMS sent automatically on every successful validation.")
|
||||
.addTag("Transit Stops", "Cross-border journey management, Dire Dawa hub, TRANSIT and ROUND_TRIP_TRANSIT bookings")
|
||||
.addTag("Wallet", "Balance management, top-ups, withdrawals, and transaction ledger. Rate limited: 20 req/min.")
|
||||
//.addServer('http://localhost:4000', 'Development')
|
||||
// .addServer("https://api.edr-platform.com", "Production")
|
||||
|
||||
@@ -10,6 +10,7 @@ import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { CurrencyService } from '../currency/currency.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { Currency, PassengerCategory, IdDocumentType } from '@prisma/client';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
function generateRef(): string {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
@@ -277,6 +278,16 @@ export class BookingsService {
|
||||
return {
|
||||
items: items.map(booking => {
|
||||
const iam = booking.passenger?.iamUserId ? iamMap.get(booking.passenger.iamUserId) : undefined;
|
||||
// Build passenger list with categories
|
||||
const passengerDetails = booking.seats.map((s: any) => ({
|
||||
name: s.passengerName,
|
||||
category: s.passengerCategory // 'ADULT' or 'CHILD'
|
||||
}));
|
||||
// Get unique names with their categories
|
||||
const uniquePassengers = Array.from(
|
||||
new Map(passengerDetails.map(p => [p.name, p])).values()
|
||||
);
|
||||
|
||||
return {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
@@ -296,6 +307,7 @@ export class BookingsService {
|
||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||
: null,
|
||||
passengerNames: [...new Set(booking.seats.map((s: any) => s.passengerName))],
|
||||
passengers: uniquePassengers, // Include category info
|
||||
schedule: {
|
||||
train: booking.schedule.train,
|
||||
originStation: booking.schedule.originStation,
|
||||
@@ -359,6 +371,24 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(fareCalculation.totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track which child gets free fare (first child encountered)
|
||||
let freeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let fareMinor: number;
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
} else {
|
||||
// Child: first child is free, subsequent children pay full fare
|
||||
if (!freeChildUsed) {
|
||||
fareMinor = 0;
|
||||
freeChildUsed = true;
|
||||
} else {
|
||||
fareMinor = fareCalculation.baseFareMinor;
|
||||
}
|
||||
}
|
||||
return { ...p, fareMinor };
|
||||
});
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -372,7 +402,7 @@ export class BookingsService {
|
||||
displayCurrency,
|
||||
displayTotalMinor,
|
||||
seats: {
|
||||
create: passengersData.map(p => ({
|
||||
create: passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
passengerName: p.passengerName,
|
||||
dateOfBirth: p.dateOfBirth,
|
||||
@@ -382,7 +412,7 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? fareCalculation.baseFareMinor : (fareCalculation.paidChildrenCount > 0 ? fareCalculation.baseFareMinor : 0),
|
||||
fareMinor: p.fareMinor,
|
||||
displayCurrency
|
||||
}))
|
||||
}
|
||||
@@ -462,6 +492,37 @@ export class BookingsService {
|
||||
displayTotalMinor = await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency);
|
||||
}
|
||||
|
||||
// Track which child gets free fare for outbound and return legs
|
||||
let outboundFreeChildUsed = false;
|
||||
let returnFreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let outboundFareMinor: number;
|
||||
let returnFareMinor: number;
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
} else {
|
||||
// Child fare for outbound
|
||||
if (!outboundFreeChildUsed) {
|
||||
outboundFareMinor = 0;
|
||||
outboundFreeChildUsed = true;
|
||||
} else {
|
||||
outboundFareMinor = outboundFare.baseFareMinor;
|
||||
}
|
||||
|
||||
// Child fare for return
|
||||
if (!returnFreeChildUsed) {
|
||||
returnFareMinor = 0;
|
||||
returnFreeChildUsed = true;
|
||||
} else {
|
||||
returnFareMinor = returnFare.baseFareMinor;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...p, outboundFareMinor, returnFareMinor };
|
||||
});
|
||||
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
bookingRef: generateRef(),
|
||||
@@ -482,7 +543,7 @@ export class BookingsService {
|
||||
returnLegStatus: 'NEITHER_USED',
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
...passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.outboundSeatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -494,10 +555,10 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? outboundFare.baseFareMinor : (outboundFare.paidChildrenCount > 0 ? outboundFare.baseFareMinor : 0),
|
||||
fareMinor: p.outboundFareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map(p => ({
|
||||
...passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.returnSeatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.returnScheduleId,
|
||||
@@ -509,7 +570,7 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? returnFare.baseFareMinor : (returnFare.paidChildrenCount > 0 ? returnFare.baseFareMinor : 0),
|
||||
fareMinor: p.returnFareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
@@ -606,6 +667,37 @@ export class BookingsService {
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
// Track which child gets free fare for leg1 and leg2
|
||||
let leg1FreeChildUsed = false;
|
||||
let leg2FreeChildUsed = false;
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let leg1FareMinor: number;
|
||||
let leg2FareMinor: number;
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
leg1FareMinor = leg1Fare.baseFareMinor;
|
||||
leg2FareMinor = leg2Fare.baseFareMinor;
|
||||
} else {
|
||||
// Child fare for leg1
|
||||
if (!leg1FreeChildUsed) {
|
||||
leg1FareMinor = 0;
|
||||
leg1FreeChildUsed = true;
|
||||
} else {
|
||||
leg1FareMinor = leg1Fare.baseFareMinor;
|
||||
}
|
||||
|
||||
// Child fare for leg2
|
||||
if (!leg2FreeChildUsed) {
|
||||
leg2FareMinor = 0;
|
||||
leg2FreeChildUsed = true;
|
||||
} else {
|
||||
leg2FareMinor = leg2Fare.baseFareMinor;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...p, leg1FareMinor, leg2FareMinor };
|
||||
});
|
||||
|
||||
// Single booking — leg-1 seats at leg=1, leg-2 seats at leg=2
|
||||
const booking = await this.prisma.booking.create({
|
||||
data: {
|
||||
@@ -625,7 +717,7 @@ export class BookingsService {
|
||||
leg2SeatClassId,
|
||||
seats: {
|
||||
create: [
|
||||
...passengersData.map(p => ({
|
||||
...passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.seatId } },
|
||||
leg: 1,
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -637,10 +729,10 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? leg1Fare.baseFareMinor : (leg1Fare.paidChildrenCount > 0 ? leg1Fare.baseFareMinor : 0),
|
||||
fareMinor: p.leg1FareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
...passengersData.map(p => ({
|
||||
...passengersWithFares.map(p => ({
|
||||
seat: { connect: { id: p.leg2SeatId ?? p.seatId } },
|
||||
leg: 2,
|
||||
scheduleId: dto.leg2ScheduleId,
|
||||
@@ -652,7 +744,7 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? leg2Fare.baseFareMinor : (leg2Fare.paidChildrenCount > 0 ? leg2Fare.baseFareMinor : 0),
|
||||
fareMinor: p.leg2FareMinor,
|
||||
displayCurrency,
|
||||
})),
|
||||
],
|
||||
@@ -769,7 +861,32 @@ export class BookingsService {
|
||||
? await this.currencyService.convertAmount(totalMinor, Currency.ETB, displayCurrency)
|
||||
: totalMinor;
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fare: Awaited<ReturnType<BookingsService['calculateFare']>>) => ({
|
||||
// Track which child gets free fare for all 4 legs
|
||||
let obL1FreeChildUsed = false;
|
||||
let obL2FreeChildUsed = false;
|
||||
let retL1FreeChildUsed = false;
|
||||
let retL2FreeChildUsed = false;
|
||||
|
||||
const passengersWithFares = passengersData.map(p => {
|
||||
let obL1FareMinor: number, obL2FareMinor: number, retL1FareMinor: number, retL2FareMinor: number;
|
||||
|
||||
if (p.category === PassengerCategory.ADULT) {
|
||||
obL1FareMinor = obL1Fare.baseFareMinor;
|
||||
obL2FareMinor = obL2Fare.baseFareMinor;
|
||||
retL1FareMinor = retL1Fare.baseFareMinor;
|
||||
retL2FareMinor = retL2Fare.baseFareMinor;
|
||||
} else {
|
||||
// Child fares for each leg
|
||||
obL1FareMinor = !obL1FreeChildUsed ? (obL1FreeChildUsed = true, 0) : obL1Fare.baseFareMinor;
|
||||
obL2FareMinor = !obL2FreeChildUsed ? (obL2FreeChildUsed = true, 0) : obL2Fare.baseFareMinor;
|
||||
retL1FareMinor = !retL1FreeChildUsed ? (retL1FreeChildUsed = true, 0) : retL1Fare.baseFareMinor;
|
||||
retL2FareMinor = !retL2FreeChildUsed ? (retL2FreeChildUsed = true, 0) : retL2Fare.baseFareMinor;
|
||||
}
|
||||
|
||||
return { ...p, obL1FareMinor, obL2FareMinor, retL1FareMinor, retL2FareMinor };
|
||||
});
|
||||
|
||||
const makeSeat = (p: any, seatId: string, leg: number, scheduleId: string, fareMinor: number) => ({
|
||||
seat: { connect: { id: seatId } },
|
||||
leg,
|
||||
scheduleId,
|
||||
@@ -781,7 +898,7 @@ export class BookingsService {
|
||||
passportCountry: p.passportCountry,
|
||||
verifaydaVerified: p.verifaydaVerified,
|
||||
verifaydaData: p.verifaydaData,
|
||||
fareMinor: p.category === PassengerCategory.ADULT ? fare.baseFareMinor : (fare.paidChildrenCount > 0 ? fare.baseFareMinor : 0),
|
||||
fareMinor,
|
||||
displayCurrency,
|
||||
});
|
||||
|
||||
@@ -811,13 +928,13 @@ export class BookingsService {
|
||||
seats: {
|
||||
create: [
|
||||
// Outbound leg-1 (sequence 1)
|
||||
...passengersData.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, obL1Fare)),
|
||||
...passengersWithFares.map(p => makeSeat(p, p.outboundSeatId, 1, dto.scheduleId, p.obL1FareMinor)),
|
||||
// Outbound leg-2 (sequence 2)
|
||||
...passengersData.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, obL2Fare)),
|
||||
...passengersWithFares.map(p => makeSeat(p, p.outboundLeg2SeatId ?? p.outboundSeatId, 2, dto.leg2ScheduleId!, p.obL2FareMinor)),
|
||||
// Return leg-1 (sequence 3)
|
||||
...passengersData.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, retL1Fare)),
|
||||
...passengersWithFares.map(p => makeSeat(p, p.returnSeatId, 3, dto.returnScheduleId!, p.retL1FareMinor)),
|
||||
// Return leg-2 (sequence 4)
|
||||
...passengersData.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, retL2Fare)),
|
||||
...passengersWithFares.map(p => makeSeat(p, p.returnLeg2SeatId ?? p.returnSeatId, 4, dto.returnLeg2ScheduleId!, p.retL2FareMinor)),
|
||||
],
|
||||
},
|
||||
} as any,
|
||||
@@ -1060,7 +1177,7 @@ export class BookingsService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: { include: { seatClasses: true } } } } } } } },
|
||||
paymentIntent: true, ticket: true,
|
||||
paymentIntent: true, tickets: { take: 1 },
|
||||
},
|
||||
});
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
@@ -1077,14 +1194,14 @@ export class BookingsService {
|
||||
contactPhone: booking.contactPhone,
|
||||
createdAt: booking.createdAt,
|
||||
schedule: {
|
||||
id: booking.schedule.id,
|
||||
trainNumber: booking.schedule.train.number,
|
||||
trainName: booking.schedule.train.name,
|
||||
origin: { id: booking.schedule.originStation.id, name: booking.schedule.originStation.name, code: booking.schedule.originStation.code, city: booking.schedule.originStation.city },
|
||||
destination: { id: booking.schedule.destinationStation.id, name: booking.schedule.destinationStation.name, code: booking.schedule.destinationStation.code, city: booking.schedule.destinationStation.city },
|
||||
departureAt: booking.schedule.departureAt, arrivalAt: booking.schedule.arrivalAt,
|
||||
id: (booking as any).schedule.id,
|
||||
trainNumber: (booking as any).schedule.train.number,
|
||||
trainName: (booking as any).schedule.train.name,
|
||||
origin: { id: (booking as any).schedule.originStation.id, name: (booking as any).schedule.originStation.name, code: (booking as any).schedule.originStation.code, city: (booking as any).schedule.originStation.city },
|
||||
destination: { id: (booking as any).schedule.destinationStation.id, name: (booking as any).schedule.destinationStation.name, code: (booking as any).schedule.destinationStation.code, city: (booking as any).schedule.destinationStation.city },
|
||||
departureAt: (booking as any).schedule.departureAt, arrivalAt: (booking as any).schedule.arrivalAt,
|
||||
},
|
||||
passengers: booking.seats?.map((bs: any) => ({
|
||||
passengers: (booking as any).seats?.map((bs: any) => ({
|
||||
fullName: bs.passengerName,
|
||||
category: bs.passengerCategory,
|
||||
leg: bs.leg ?? 1,
|
||||
@@ -1098,8 +1215,8 @@ export class BookingsService {
|
||||
seatClass: bs.seat.coach.coachType?.seatClasses?.[0]?.name ?? null,
|
||||
},
|
||||
})),
|
||||
payment: booking.paymentIntent ? { method: booking.paymentIntent.method, status: booking.paymentIntent.status } : undefined,
|
||||
ticket: booking.ticket ? { id: booking.ticket.id, qrPayload: booking.ticket.qrPayload, barcodePayload: booking.ticket.barcodePayload, status: booking.ticket.status } : undefined,
|
||||
payment: (booking as any).paymentIntent ? { method: (booking as any).paymentIntent.method, status: (booking as any).paymentIntent.status } : undefined,
|
||||
ticket: (booking as any).tickets?.[0] ? { id: (booking as any).tickets[0].id, qrPayload: (booking as any).tickets[0].qrPayload, barcodePayload: (booking as any).tickets[0].barcodePayload, status: (booking as any).tickets[0].status } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1153,6 +1270,12 @@ export class BookingsService {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { id }, include: { seats: true } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
// Check usage before allowing deletion
|
||||
const usage = await this.checkBookingUsage(id);
|
||||
if (usage.isInUse && usage.constraints) {
|
||||
throw new DeleteOperationException('Booking', booking.bookingRef, usage.constraints);
|
||||
}
|
||||
|
||||
await this.seatsService.releaseSeats(booking.id);
|
||||
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { bookingId: id } });
|
||||
@@ -1172,15 +1295,16 @@ export class BookingsService {
|
||||
this.prisma.bookingCancellation.count({ where: { bookingId: id } }),
|
||||
]);
|
||||
|
||||
const usage = [];
|
||||
if (ticketCount > 0) usage.push('Ticket(s)');
|
||||
if (paymentIntentCount > 0) usage.push('Payment record(s)');
|
||||
if (modificationsCount > 0) usage.push('Modification history');
|
||||
if (cancellationCount > 0) usage.push('Cancellation record(s)');
|
||||
const constraints = [];
|
||||
if (ticketCount > 0) constraints.push({ entityName: 'ticket', count: ticketCount, action: 'complete' as const });
|
||||
if (paymentIntentCount > 0) constraints.push({ entityName: 'payment record', count: paymentIntentCount, action: 'complete' as const });
|
||||
if (modificationsCount > 0) constraints.push({ entityName: 'modification record', count: modificationsCount, action: 'complete' as const });
|
||||
if (cancellationCount > 0) constraints.push({ entityName: 'cancellation record', count: cancellationCount, action: 'complete' as const });
|
||||
|
||||
return {
|
||||
isInUse: usage.length > 0,
|
||||
affectedModules: usage,
|
||||
isInUse: constraints.length > 0,
|
||||
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
|
||||
constraints
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -231,6 +231,9 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
// Save passenger details as traveler profiles
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
// Confirm seats
|
||||
await this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId));
|
||||
this.eventEmitter.emit('booking.created', { booking });
|
||||
@@ -443,6 +446,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(outboundSeatIds),
|
||||
this.seatsService.confirmSeats(returnSeatIds),
|
||||
@@ -635,6 +640,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
|
||||
@@ -822,6 +829,8 @@ export class GuestBookingService {
|
||||
},
|
||||
});
|
||||
|
||||
await this.createTravelerProfiles(guestPassengerId, passengersData);
|
||||
|
||||
await Promise.all([
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.seatId)),
|
||||
this.seatsService.confirmSeats(dto.passengers.map(p => p.leg2SeatId!)),
|
||||
@@ -870,12 +879,44 @@ export class GuestBookingService {
|
||||
return { guestPassengerId: result.user.passengerId, iamUserId: result.user.iamUserId, createdAccount: true };
|
||||
}
|
||||
|
||||
// Create guest passenger with basic profile
|
||||
const guestPassenger = await this.prisma.passenger.create({ data: {} });
|
||||
await this.prisma.loyaltyAccount.create({ data: { passengerId: guestPassenger.id, pointsBalance: 0, tier: 'BRONZE' } });
|
||||
await this.prisma.walletAccount.create({ data: { passengerId: guestPassenger.id, balanceMinor: 0 } });
|
||||
|
||||
return { guestPassengerId: guestPassenger.id, iamUserId: null, createdAccount: false };
|
||||
}
|
||||
|
||||
private async createTravelerProfiles(passengerId: string, passengersData: any[]): Promise<void> {
|
||||
for (const passenger of passengersData) {
|
||||
let gender: string | null = null;
|
||||
if (passenger.verifaydaData && typeof passenger.verifaydaData === 'object') {
|
||||
gender = passenger.verifaydaData.gender || passenger.verifaydaData.Gender || null;
|
||||
}
|
||||
|
||||
await this.prisma.travelerProfile.create({
|
||||
data: {
|
||||
passengerId,
|
||||
fullName: passenger.passengerName,
|
||||
gender,
|
||||
dateOfBirth: passenger.dateOfBirth,
|
||||
nationalId: passenger.idDocumentType === IdDocumentType.NATIONAL_ID ? passenger.idDocumentNumber : null,
|
||||
relationship: 'self',
|
||||
notes: JSON.stringify({
|
||||
idDocumentType: passenger.idDocumentType,
|
||||
idDocumentNumber: passenger.idDocumentNumber,
|
||||
passportNumber: passenger.passportNumber,
|
||||
passportCountry: passenger.passportCountry,
|
||||
nationality: passenger.nationality,
|
||||
phone: passenger.phone,
|
||||
email: passenger.email,
|
||||
verifaydaVerified: passenger.verifaydaVerified,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getSavedPassengers(userId?: string, deviceId?: string): Promise<SavedPassengerProfileDto[]> {
|
||||
if (!userId && !deviceId) {
|
||||
throw new BadRequestException('Either userId or deviceId is required');
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { Body, Controller, Get, Post, Put, Delete, Param, UseGuards, Request, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiBearerAuth, ApiOperation, ApiResponse, ApiParam, ApiQuery } from '@nestjs/swagger';
|
||||
import { ConfigurableFareService } from './configurable-fare.service';
|
||||
import {
|
||||
CreateFareConfigurationDto,
|
||||
UpdateFareConfigurationDto,
|
||||
FareTestScenarioDto,
|
||||
FareCalculationResultDto,
|
||||
MigrateLegacyDto,
|
||||
CreateNewFormulaDto,
|
||||
ToggleFeatureDto
|
||||
} from './configurable-fare.dto';
|
||||
import { IamGuard } from '../../common/iam-adapter';
|
||||
import { Roles } from '../../common/roles.decorator';
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-configurations')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class ConfigurableFareController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List all fare configurations' })
|
||||
@ApiResponse({ status: 200, description: 'List of all configurations with summary counts' })
|
||||
async getAllConfigurations() {
|
||||
return this.service.getAllConfigurations();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create new fare configuration' })
|
||||
@ApiResponse({ status: 201, description: 'Configuration created successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid configuration data' })
|
||||
async createConfiguration(
|
||||
@Body() dto: CreateFareConfigurationDto,
|
||||
@Request() req: any
|
||||
) {
|
||||
const createdBy = req.user?.id || req.user?.sub;
|
||||
return this.service.createConfiguration(dto, createdBy);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get configuration details' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration details with all rules' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async getConfigurationById(@Param('id') id: string) {
|
||||
return this.service.getConfigurationById(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration updated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async updateConfiguration(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateFareConfigurationDto,
|
||||
@Request() req: any
|
||||
) {
|
||||
const updatedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.updateConfiguration(id, dto, updatedBy);
|
||||
}
|
||||
|
||||
@Post(':id/activate')
|
||||
@ApiOperation({ summary: 'Activate configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration activated successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
async activateConfiguration(@Param('id') id: string, @Request() req: any) {
|
||||
const activatedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.activateConfiguration(id, activatedBy);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Configuration deleted successfully' })
|
||||
@ApiResponse({ status: 404, description: 'Configuration not found' })
|
||||
@ApiResponse({ status: 409, description: 'Cannot delete active configuration' })
|
||||
async deleteConfiguration(@Param('id') id: string, @Request() req: any) {
|
||||
const deletedBy = req.user?.id || req.user?.sub;
|
||||
return this.service.deleteConfiguration(id, deletedBy);
|
||||
}
|
||||
|
||||
@Post(':id/test')
|
||||
@ApiOperation({ summary: 'Test fare calculation with configuration' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, type: FareCalculationResultDto, description: 'Fare calculation result' })
|
||||
@ApiResponse({ status: 400, description: 'Invalid test scenario or missing rate rules' })
|
||||
async testConfiguration(
|
||||
@Param('id') id: string,
|
||||
@Body() scenario: FareTestScenarioDto
|
||||
): Promise<FareCalculationResultDto> {
|
||||
return this.service.testConfiguration(id, scenario);
|
||||
}
|
||||
|
||||
@Get(':id/audit')
|
||||
@ApiOperation({ summary: 'Get configuration audit trail' })
|
||||
@ApiParam({ name: 'id', description: 'Configuration ID' })
|
||||
@ApiResponse({ status: 200, description: 'Audit trail entries' })
|
||||
async getAuditTrail(@Param('id') id: string) {
|
||||
return this.service.getAuditTrail(id);
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-migration')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class FareMigrationController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Post('migrate-legacy')
|
||||
@ApiOperation({ summary: 'Migrate existing fare rules to configurable system' })
|
||||
@ApiResponse({ status: 200, description: 'Migration completed successfully' })
|
||||
@ApiResponse({ status: 400, description: 'Migration failed' })
|
||||
async migrateLegacySystem(@Body() dto: MigrateLegacyDto) {
|
||||
return this.service.migrateLegacySystem(dto);
|
||||
}
|
||||
|
||||
@Post('create-new-formula')
|
||||
@ApiOperation({ summary: 'Create new formula configuration with defaults' })
|
||||
@ApiResponse({ status: 201, description: 'New formula configuration created' })
|
||||
async createNewFormulaConfiguration(@Body() dto: CreateNewFormulaDto) {
|
||||
return this.service.createNewFormulaConfiguration(dto);
|
||||
}
|
||||
|
||||
@Post('complete-setup')
|
||||
@ApiOperation({
|
||||
summary: 'Complete system setup (migrate + create + activate)',
|
||||
description: 'Performs full system migration and setup in one operation'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'System setup completed successfully' })
|
||||
async completeSetup(@Body() body: { activateNewFormula?: boolean; enableFeature?: boolean }) {
|
||||
// Step 1: Migrate legacy system
|
||||
const migrationResult = await this.service.migrateLegacySystem({ dryRun: false });
|
||||
|
||||
// Step 2: Create new formula configuration
|
||||
const newConfig = await this.service.createNewFormulaConfiguration({
|
||||
name: 'Default System Configuration',
|
||||
description: 'System-generated configuration with optimal defaults',
|
||||
activateImmediately: body.activateNewFormula !== false
|
||||
});
|
||||
|
||||
// Step 3: Enable feature flag if requested
|
||||
if (body.enableFeature) {
|
||||
await this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: true,
|
||||
config: { rollout_percentage: 100 }
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
migration: migrationResult,
|
||||
newConfiguration: newConfig,
|
||||
featureEnabled: body.enableFeature || false,
|
||||
message: 'System setup completed successfully'
|
||||
};
|
||||
}
|
||||
|
||||
@Get('status')
|
||||
@ApiOperation({ summary: 'Get migration and setup status' })
|
||||
@ApiResponse({ status: 200, description: 'Current system status' })
|
||||
async getStatus() {
|
||||
const featureStatus = await this.service.getFeatureStatus('USE_CONFIGURABLE_FARES');
|
||||
const configurations = await this.service.getAllConfigurations();
|
||||
|
||||
const activeConfig = (configurations as any[]).find(config => config.is_active);
|
||||
|
||||
return {
|
||||
configurableFaresEnabled: featureStatus.enabled,
|
||||
rolloutPercentage: featureStatus.config?.rollout_percentage || 0,
|
||||
totalConfigurations: (configurations as any[]).length,
|
||||
activeConfiguration: activeConfig?.id || null,
|
||||
activeConfigurationName: activeConfig?.name || null,
|
||||
systemReady: featureStatus.enabled && !!activeConfig
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('Configurable Fares')
|
||||
@Controller('admin/fare-configurations/system')
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@UseGuards(IamGuard)
|
||||
@Roles('ADMIN')
|
||||
export class FareSystemController {
|
||||
constructor(private service: ConfigurableFareService) {}
|
||||
|
||||
@Get('feature-status')
|
||||
@ApiOperation({ summary: 'Check configurable fares feature status' })
|
||||
@ApiQuery({ name: 'feature', required: false, description: 'Feature name (defaults to USE_CONFIGURABLE_FARES)' })
|
||||
@ApiResponse({ status: 200, description: 'Feature status retrieved' })
|
||||
async getFeatureStatus(@Query('feature') featureName = 'USE_CONFIGURABLE_FARES') {
|
||||
return this.service.getFeatureStatus(featureName);
|
||||
}
|
||||
|
||||
@Post('toggle-feature')
|
||||
@ApiOperation({ summary: 'Enable or disable configurable fares system' })
|
||||
@ApiResponse({ status: 200, description: 'Feature toggled successfully' })
|
||||
async toggleFeature(@Body() dto: ToggleFeatureDto) {
|
||||
return this.service.toggleFeature(dto);
|
||||
}
|
||||
|
||||
@Post('enable-configurable-fares')
|
||||
@ApiOperation({
|
||||
summary: 'Enable configurable fares with rollout percentage',
|
||||
description: 'Quick endpoint to enable the configurable fares feature'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Configurable fares enabled successfully' })
|
||||
async enableConfigurableFares(@Body() body: { rolloutPercentage?: number }) {
|
||||
return this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: true,
|
||||
config: { rollout_percentage: body.rolloutPercentage || 100 }
|
||||
});
|
||||
}
|
||||
|
||||
@Post('disable-configurable-fares')
|
||||
@ApiOperation({
|
||||
summary: 'Disable configurable fares (fallback to legacy system)',
|
||||
description: 'Disables the configurable fares feature and falls back to legacy fare calculation'
|
||||
})
|
||||
@ApiResponse({ status: 200, description: 'Configurable fares disabled successfully' })
|
||||
async disableConfigurableFares() {
|
||||
return this.service.toggleFeature({
|
||||
featureName: 'USE_CONFIGURABLE_FARES',
|
||||
enabled: false,
|
||||
config: { rollout_percentage: 0 }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import { IsString, IsOptional, IsBoolean, IsInt, IsArray, ValidateNested, IsDateString, IsEnum, IsNumber, Min, Max } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
export enum NationalityType {
|
||||
LOCAL = 'LOCAL',
|
||||
INTERNATIONAL = 'INTERNATIONAL'
|
||||
}
|
||||
|
||||
export enum CoachType {
|
||||
REGULAR_SEAT = 'REGULAR_SEAT',
|
||||
ECONOMY_BED = 'ECONOMY_BED',
|
||||
VIP_BED = 'VIP_BED'
|
||||
}
|
||||
|
||||
export enum BedPosition {
|
||||
UPPER = 'UPPER',
|
||||
MIDDLE = 'MIDDLE',
|
||||
LOWER = 'LOWER'
|
||||
}
|
||||
|
||||
export enum ComponentType {
|
||||
INSURANCE = 'INSURANCE',
|
||||
PREMIUM = 'PREMIUM',
|
||||
SERVICE_CHARGE = 'SERVICE_CHARGE',
|
||||
TAX = 'TAX',
|
||||
DEMAND = 'DEMAND'
|
||||
}
|
||||
|
||||
export enum CalculationMethod {
|
||||
MULTIPLIER = 'MULTIPLIER',
|
||||
PERCENTAGE = 'PERCENTAGE',
|
||||
FIXED_AMOUNT = 'FIXED_AMOUNT'
|
||||
}
|
||||
|
||||
export enum AppliesTo {
|
||||
BASE_FARE = 'BASE_FARE',
|
||||
SUBTOTAL = 'SUBTOTAL',
|
||||
TOTAL = 'TOTAL'
|
||||
}
|
||||
|
||||
export enum PricingType {
|
||||
FREE = 'FREE',
|
||||
FULL_FARE = 'FULL_FARE',
|
||||
DISCOUNTED = 'DISCOUNTED'
|
||||
}
|
||||
|
||||
export class FareRateRuleDto {
|
||||
@ApiProperty({ enum: NationalityType })
|
||||
@IsEnum(NationalityType)
|
||||
nationalityType: NationalityType;
|
||||
|
||||
@ApiProperty({ enum: CoachType })
|
||||
@IsEnum(CoachType)
|
||||
coachType: CoachType;
|
||||
|
||||
@ApiPropertyOptional({ enum: BedPosition })
|
||||
@IsOptional()
|
||||
@IsEnum(BedPosition)
|
||||
bedPosition?: BedPosition;
|
||||
|
||||
@ApiProperty({ example: 3000, description: 'Rate per km in minor units (e.g., 30.00 ETB = 3000)' })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
ratePerKmMinor: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class FareComponentDto {
|
||||
@ApiProperty({ enum: ComponentType })
|
||||
@IsEnum(ComponentType)
|
||||
componentType: ComponentType;
|
||||
|
||||
@ApiProperty({ example: 'Travel Insurance' })
|
||||
@IsString()
|
||||
componentName: string;
|
||||
|
||||
@ApiProperty({ enum: CalculationMethod })
|
||||
@IsEnum(CalculationMethod)
|
||||
calculationMethod: CalculationMethod;
|
||||
|
||||
@ApiPropertyOptional({ example: 500, description: 'Fixed amount in minor units' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
valueMinor?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.02, description: 'Percentage value (e.g., 0.02 for 2%)' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
percentageValue?: number;
|
||||
|
||||
@ApiProperty({ enum: AppliesTo, default: AppliesTo.SUBTOTAL })
|
||||
@IsEnum(AppliesTo)
|
||||
appliesTo: AppliesTo;
|
||||
|
||||
@ApiProperty({ example: 1, description: 'Order of application' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
applyOrder: number;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class AgePricingRuleDto {
|
||||
@ApiProperty({ example: 'Adult Passengers' })
|
||||
@IsString()
|
||||
ruleName: string;
|
||||
|
||||
@ApiProperty({ example: 5 })
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
minAge: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 120 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxAge?: number;
|
||||
|
||||
@ApiProperty({ enum: PricingType })
|
||||
@IsEnum(PricingType)
|
||||
pricingType: PricingType;
|
||||
|
||||
@ApiPropertyOptional({ example: 0.5, description: 'Discount percentage for DISCOUNTED type' })
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
discountPercentage?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, description: 'Max free passengers for FREE type' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
maxFreePassengers?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
appliesToComponents?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export class CreateFareConfigurationDto {
|
||||
@ApiProperty({ example: 'Summer 2024 Rates' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'Updated rates for summer season' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiProperty({ example: '2024-06-01T00:00:00.000Z' })
|
||||
@IsDateString()
|
||||
effectiveDate: string;
|
||||
|
||||
@ApiPropertyOptional({ example: '2024-08-31T23:59:59.000Z' })
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiryDate?: string;
|
||||
|
||||
@ApiProperty({ type: [FareRateRuleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareRateRuleDto)
|
||||
rateRules: FareRateRuleDto[];
|
||||
|
||||
@ApiProperty({ type: [FareComponentDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareComponentDto)
|
||||
components: FareComponentDto[];
|
||||
|
||||
@ApiProperty({ type: [AgePricingRuleDto] })
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AgePricingRuleDto)
|
||||
ageRules: AgePricingRuleDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export class UpdateFareConfigurationDto {
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
effectiveDate?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiryDate?: string;
|
||||
|
||||
@ApiPropertyOptional({ type: [FareRateRuleDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareRateRuleDto)
|
||||
rateRules?: FareRateRuleDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [FareComponentDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FareComponentDto)
|
||||
components?: FareComponentDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: [AgePricingRuleDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => AgePricingRuleDto)
|
||||
ageRules?: AgePricingRuleDto[];
|
||||
}
|
||||
|
||||
export class FareTestScenarioDto {
|
||||
@ApiProperty({ example: 100 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
distanceKm: number;
|
||||
|
||||
@ApiProperty({ example: 'Ethiopian' })
|
||||
@IsString()
|
||||
nationality: string;
|
||||
|
||||
@ApiProperty({ enum: CoachType })
|
||||
@IsEnum(CoachType)
|
||||
coachType: CoachType;
|
||||
|
||||
@ApiPropertyOptional({ enum: BedPosition })
|
||||
@IsOptional()
|
||||
@IsEnum(BedPosition)
|
||||
bedPosition?: BedPosition;
|
||||
|
||||
@ApiProperty({ example: 2, default: 1 })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
adultCount: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 1, default: 0 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
childCount?: number;
|
||||
|
||||
@ApiPropertyOptional({ example: 'SUMMER20' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
promoCode?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 500 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
loyaltyPoints?: number;
|
||||
}
|
||||
|
||||
export class FareCalculationResultDto {
|
||||
@ApiProperty()
|
||||
baseFareMinor: number;
|
||||
|
||||
@ApiProperty()
|
||||
componentsTotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
totalBeforeDiscounts: number;
|
||||
|
||||
@ApiProperty()
|
||||
discountsTotal: number;
|
||||
|
||||
@ApiProperty()
|
||||
finalTotalMinor: number;
|
||||
|
||||
@ApiProperty()
|
||||
breakdown: Array<{
|
||||
step: string;
|
||||
description: string;
|
||||
amount: number;
|
||||
runningTotal: number;
|
||||
}>;
|
||||
|
||||
@ApiProperty()
|
||||
currency: string;
|
||||
|
||||
@ApiProperty()
|
||||
calculationTimestamp: Date;
|
||||
}
|
||||
|
||||
export class MigrateLegacyDto {
|
||||
@ApiPropertyOptional({ default: false })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
dryRun?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
migrateScheduleFares?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
migrateSegmentFares?: boolean;
|
||||
}
|
||||
|
||||
export class CreateNewFormulaDto {
|
||||
@ApiProperty({ example: 'Default Formula Configuration' })
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'System-generated default configuration' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
activateImmediately?: boolean;
|
||||
}
|
||||
|
||||
export class ToggleFeatureDto {
|
||||
@ApiProperty({ example: 'USE_CONFIGURABLE_FARES' })
|
||||
@IsString()
|
||||
featureName: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsBoolean()
|
||||
enabled: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: { rollout_percentage: 50 } })
|
||||
@IsOptional()
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigurableFareService } from './configurable-fare.service';
|
||||
import { ConfigurableFareController, FareMigrationController, FareSystemController } from './configurable-fare.controller';
|
||||
import { PrismaModule } from '../../common/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ConfigurableFareController, FareMigrationController, FareSystemController],
|
||||
providers: [ConfigurableFareService],
|
||||
exports: [ConfigurableFareService],
|
||||
})
|
||||
export class ConfigurableFareModule {}
|
||||
@@ -0,0 +1,623 @@
|
||||
import { Injectable, BadRequestException, NotFoundException, ConflictException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import {
|
||||
CreateFareConfigurationDto,
|
||||
UpdateFareConfigurationDto,
|
||||
FareTestScenarioDto,
|
||||
FareCalculationResultDto,
|
||||
MigrateLegacyDto,
|
||||
CreateNewFormulaDto,
|
||||
ToggleFeatureDto,
|
||||
NationalityType,
|
||||
CoachType,
|
||||
BedPosition,
|
||||
ComponentType,
|
||||
CalculationMethod,
|
||||
AppliesTo,
|
||||
PricingType
|
||||
} from './configurable-fare.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigurableFareService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
// Helper method to map nationality to type
|
||||
private mapNationalityToType(nationality: string): NationalityType {
|
||||
const upperNationality = nationality.toUpperCase();
|
||||
if (upperNationality === 'ETHIOPIAN' || upperNationality === 'DJIBOUTIAN') {
|
||||
return NationalityType.LOCAL;
|
||||
}
|
||||
return NationalityType.INTERNATIONAL;
|
||||
}
|
||||
|
||||
// Generate unique IDs
|
||||
private generateId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
// Configuration Management
|
||||
async getAllConfigurations() {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT
|
||||
fc.*,
|
||||
COUNT(DISTINCT frr.id) as rate_rules_count,
|
||||
COUNT(DISTINCT fcmp.id) as components_count,
|
||||
COUNT(DISTINCT apr.id) as age_rules_count
|
||||
FROM fare_configurations fc
|
||||
LEFT JOIN fare_rate_rules frr ON fc.id = frr.fare_config_id AND frr.is_active = true
|
||||
LEFT JOIN fare_components fcmp ON fc.id = fcmp.fare_config_id AND fcmp.is_active = true
|
||||
LEFT JOIN age_pricing_rules apr ON fc.id = apr.fare_config_id AND apr.is_active = true
|
||||
GROUP BY fc.id, fc.name, fc.description, fc.effective_date, fc.expiry_date,
|
||||
fc.is_active, fc.is_default, fc.created_by, fc.approved_by,
|
||||
fc.approved_at, fc.created_at, fc.updated_at
|
||||
ORDER BY fc.created_at DESC
|
||||
`;
|
||||
}
|
||||
|
||||
async getConfigurationById(id: string) {
|
||||
const config = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_configurations WHERE id = ${id}
|
||||
`;
|
||||
|
||||
if (!Array.isArray(config) || config.length === 0) {
|
||||
throw new NotFoundException(`Fare configuration ${id} not found`);
|
||||
}
|
||||
|
||||
const rateRules = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_rate_rules WHERE fare_config_id = ${id} ORDER BY nationality_type, coach_type, bed_position
|
||||
`;
|
||||
|
||||
const components = await this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_components WHERE fare_config_id = ${id} ORDER BY apply_order
|
||||
`;
|
||||
|
||||
const ageRules = await this.prisma.$queryRaw`
|
||||
SELECT * FROM age_pricing_rules WHERE fare_config_id = ${id} ORDER BY min_age
|
||||
`;
|
||||
|
||||
return {
|
||||
...config[0],
|
||||
rateRules,
|
||||
components,
|
||||
ageRules
|
||||
};
|
||||
}
|
||||
|
||||
async createConfiguration(dto: CreateFareConfigurationDto, createdBy?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const configId = this.generateId('fc');
|
||||
|
||||
// Validate no overlapping active configurations
|
||||
if (dto.isDefault) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations SET is_default = false WHERE is_default = true
|
||||
`;
|
||||
}
|
||||
|
||||
// Create main configuration
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_configurations (
|
||||
id, name, description, effective_date, expiry_date,
|
||||
is_active, is_default, created_by, created_at, updated_at
|
||||
) VALUES (
|
||||
${configId}, ${dto.name}, ${dto.description}, ${dto.effectiveDate},
|
||||
${dto.expiryDate}, false, ${dto.isDefault || false}, ${createdBy},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
|
||||
// Create rate rules
|
||||
for (const rule of dto.rateRules) {
|
||||
const ruleId = this.generateId('frr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_rate_rules (
|
||||
id, fare_config_id, nationality_type, coach_type, bed_position,
|
||||
rate_per_km_minor, is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ruleId}, ${configId}, ${rule.nationalityType}, ${rule.coachType},
|
||||
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Create components
|
||||
for (const component of dto.components) {
|
||||
const componentId = this.generateId('fcmp');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_components (
|
||||
id, fare_config_id, component_type, component_name, calculation_method,
|
||||
value_minor, percentage_value, applies_to, apply_order, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
${componentId}, ${configId}, ${component.componentType}, ${component.componentName},
|
||||
${component.calculationMethod}, ${component.valueMinor || null},
|
||||
${component.percentageValue || null}, ${component.appliesTo},
|
||||
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Create age rules
|
||||
for (const ageRule of dto.ageRules) {
|
||||
const ageRuleId = this.generateId('apr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO age_pricing_rules (
|
||||
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
|
||||
discount_percentage, max_free_passengers, applies_to_components,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ageRuleId}, ${configId}, ${ageRule.ruleName}, ${ageRule.minAge},
|
||||
${ageRule.maxAge || null}, ${ageRule.pricingType},
|
||||
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
|
||||
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
// Log audit entry
|
||||
await this.createAuditEntry(tx, configId, 'CREATED', createdBy, { action: 'Configuration created' });
|
||||
|
||||
return this.getConfigurationById(configId);
|
||||
});
|
||||
}
|
||||
|
||||
async updateConfiguration(id: string, dto: UpdateFareConfigurationDto, updatedBy?: string) {
|
||||
await this.getConfigurationById(id); // Validate exists
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Update main configuration
|
||||
if (dto.name || dto.description !== undefined || dto.effectiveDate || dto.expiryDate !== undefined) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations
|
||||
SET
|
||||
name = COALESCE(${dto.name}, name),
|
||||
description = COALESCE(${dto.description}, description),
|
||||
effective_date = COALESCE(${dto.effectiveDate}, effective_date),
|
||||
expiry_date = COALESCE(${dto.expiryDate}, expiry_date),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
}
|
||||
|
||||
// Update rate rules if provided
|
||||
if (dto.rateRules) {
|
||||
await tx.$executeRaw`DELETE FROM fare_rate_rules WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const rule of dto.rateRules) {
|
||||
const ruleId = this.generateId('frr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_rate_rules (
|
||||
id, fare_config_id, nationality_type, coach_type, bed_position,
|
||||
rate_per_km_minor, is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ruleId}, ${id}, ${rule.nationalityType}, ${rule.coachType},
|
||||
${rule.bedPosition || null}, ${rule.ratePerKmMinor}, ${rule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update components if provided
|
||||
if (dto.components) {
|
||||
await tx.$executeRaw`DELETE FROM fare_components WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const component of dto.components) {
|
||||
const componentId = this.generateId('fcmp');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_components (
|
||||
id, fare_config_id, component_type, component_name, calculation_method,
|
||||
value_minor, percentage_value, applies_to, apply_order, is_active,
|
||||
created_at, updated_at
|
||||
) VALUES (
|
||||
${componentId}, ${id}, ${component.componentType}, ${component.componentName},
|
||||
${component.calculationMethod}, ${component.valueMinor || null},
|
||||
${component.percentageValue || null}, ${component.appliesTo},
|
||||
${component.applyOrder}, ${component.isActive !== false}, NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update age rules if provided
|
||||
if (dto.ageRules) {
|
||||
await tx.$executeRaw`DELETE FROM age_pricing_rules WHERE fare_config_id = ${id}`;
|
||||
|
||||
for (const ageRule of dto.ageRules) {
|
||||
const ageRuleId = this.generateId('apr');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO age_pricing_rules (
|
||||
id, fare_config_id, rule_name, min_age, max_age, pricing_type,
|
||||
discount_percentage, max_free_passengers, applies_to_components,
|
||||
is_active, created_at, updated_at
|
||||
) VALUES (
|
||||
${ageRuleId}, ${id}, ${ageRule.ruleName}, ${ageRule.minAge},
|
||||
${ageRule.maxAge || null}, ${ageRule.pricingType},
|
||||
${ageRule.discountPercentage || null}, ${ageRule.maxFreePassengers || null},
|
||||
${ageRule.appliesToComponents !== false}, ${ageRule.isActive !== false},
|
||||
NOW(), NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
await this.createAuditEntry(tx, id, 'UPDATED', updatedBy, { changes: dto });
|
||||
|
||||
return this.getConfigurationById(id);
|
||||
});
|
||||
}
|
||||
|
||||
async activateConfiguration(id: string, activatedBy?: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Deactivate all other configurations
|
||||
await tx.$executeRaw`UPDATE fare_configurations SET is_active = false`;
|
||||
|
||||
// Activate this one
|
||||
await tx.$executeRaw`
|
||||
UPDATE fare_configurations
|
||||
SET is_active = true, approved_by = ${activatedBy}, approved_at = NOW()
|
||||
WHERE id = ${id}
|
||||
`;
|
||||
|
||||
await this.createAuditEntry(tx, id, 'ACTIVATED', activatedBy, {});
|
||||
|
||||
return { success: true, message: `Configuration ${id} activated successfully` };
|
||||
});
|
||||
}
|
||||
|
||||
async deleteConfiguration(id: string, deletedBy?: string) {
|
||||
const config = await this.getConfigurationById(id);
|
||||
|
||||
if ((config as any).is_active) {
|
||||
throw new ConflictException('Cannot delete active configuration. Deactivate first.');
|
||||
}
|
||||
|
||||
await this.prisma.$executeRaw`DELETE FROM fare_configurations WHERE id = ${id}`;
|
||||
|
||||
return { success: true, message: `Configuration ${id} deleted successfully` };
|
||||
}
|
||||
|
||||
// Fare Calculation
|
||||
async testConfiguration(id: string, scenario: FareTestScenarioDto): Promise<FareCalculationResultDto> {
|
||||
const config = await this.getConfigurationById(id);
|
||||
|
||||
// Find matching rate rule
|
||||
const nationalityType = this.mapNationalityToType(scenario.nationality);
|
||||
|
||||
const rateRule = (config.rateRules as any[]).find((rule: any) =>
|
||||
rule.nationality_type === nationalityType &&
|
||||
rule.coach_type === scenario.coachType &&
|
||||
(scenario.bedPosition ? rule.bed_position === scenario.bedPosition : !rule.bed_position)
|
||||
);
|
||||
|
||||
if (!rateRule) {
|
||||
throw new BadRequestException(`No rate rule found for ${nationalityType}/${scenario.coachType}${scenario.bedPosition ? `/${scenario.bedPosition}` : ''}`);
|
||||
}
|
||||
|
||||
// Calculate base fare
|
||||
const baseFareMinor = scenario.distanceKm * rateRule.rate_per_km_minor;
|
||||
const breakdown = [
|
||||
{
|
||||
step: '1',
|
||||
description: `Base fare: ${scenario.distanceKm}km × ${rateRule.rate_per_km_minor} minor units/km`,
|
||||
amount: baseFareMinor,
|
||||
runningTotal: baseFareMinor
|
||||
}
|
||||
];
|
||||
|
||||
let runningTotal = baseFareMinor;
|
||||
|
||||
// Apply age-based pricing
|
||||
const { adultCount = 1, childCount = 0 } = scenario;
|
||||
let totalPassengerFare = 0;
|
||||
|
||||
// Process adults
|
||||
totalPassengerFare += adultCount * baseFareMinor;
|
||||
breakdown.push({
|
||||
step: '2a',
|
||||
description: `Adult passengers: ${adultCount} × ${baseFareMinor}`,
|
||||
amount: adultCount * baseFareMinor,
|
||||
runningTotal: adultCount * baseFareMinor
|
||||
});
|
||||
|
||||
// Process children with age rules
|
||||
if (childCount > 0) {
|
||||
const childRule = (config.ageRules as any[]).find((rule: any) =>
|
||||
rule.pricing_type === PricingType.FREE && rule.max_free_passengers > 0
|
||||
);
|
||||
|
||||
if (childRule) {
|
||||
const freeChildren = Math.min(childCount, childRule.max_free_passengers);
|
||||
const paidChildren = Math.max(0, childCount - freeChildren);
|
||||
|
||||
if (freeChildren > 0) {
|
||||
breakdown.push({
|
||||
step: '2b',
|
||||
description: `Free children: ${freeChildren} × 0 (first ${childRule.max_free_passengers} free)`,
|
||||
amount: 0,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
|
||||
if (paidChildren > 0) {
|
||||
const paidChildrenFare = paidChildren * baseFareMinor;
|
||||
totalPassengerFare += paidChildrenFare;
|
||||
breakdown.push({
|
||||
step: '2c',
|
||||
description: `Paid children: ${paidChildren} × ${baseFareMinor}`,
|
||||
amount: paidChildrenFare,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// All children pay
|
||||
const childrenFare = childCount * baseFareMinor;
|
||||
totalPassengerFare += childrenFare;
|
||||
breakdown.push({
|
||||
step: '2b',
|
||||
description: `Child passengers: ${childCount} × ${baseFareMinor}`,
|
||||
amount: childrenFare,
|
||||
runningTotal: totalPassengerFare
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
runningTotal = totalPassengerFare;
|
||||
|
||||
// Apply components in order
|
||||
let componentsTotal = 0;
|
||||
const components = (config.components as any[])
|
||||
.filter((c: any) => c.is_active)
|
||||
.sort((a: any, b: any) => a.apply_order - b.apply_order);
|
||||
|
||||
for (const component of components) {
|
||||
let componentAmount = 0;
|
||||
let baseAmount = runningTotal;
|
||||
|
||||
if (component.applies_to === 'BASE_FARE') {
|
||||
baseAmount = baseFareMinor;
|
||||
} else if (component.applies_to === 'SUBTOTAL') {
|
||||
baseAmount = runningTotal;
|
||||
}
|
||||
|
||||
switch (component.calculation_method) {
|
||||
case 'PERCENTAGE':
|
||||
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
|
||||
break;
|
||||
case 'MULTIPLIER':
|
||||
componentAmount = Math.round(baseAmount * (component.percentage_value || 0));
|
||||
break;
|
||||
case 'FIXED_AMOUNT':
|
||||
componentAmount = component.value_minor || 0;
|
||||
break;
|
||||
}
|
||||
|
||||
componentsTotal += componentAmount;
|
||||
runningTotal += componentAmount;
|
||||
|
||||
breakdown.push({
|
||||
step: `3${String.fromCharCode(97 + component.apply_order - 1)}`,
|
||||
description: `${component.component_name}: ${component.calculation_method} on ${component.applies_to}`,
|
||||
amount: componentAmount,
|
||||
runningTotal
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
baseFareMinor,
|
||||
componentsTotal,
|
||||
totalBeforeDiscounts: runningTotal,
|
||||
discountsTotal: 0, // TODO: Implement promo code discounts
|
||||
finalTotalMinor: runningTotal,
|
||||
breakdown,
|
||||
currency: 'ETB',
|
||||
calculationTimestamp: new Date()
|
||||
};
|
||||
}
|
||||
|
||||
// Migration Methods
|
||||
async migrateLegacySystem(dto: MigrateLegacyDto) {
|
||||
const results = {
|
||||
scheduleFareRules: 0,
|
||||
segmentFareRules: 0,
|
||||
configurationsCreated: 0,
|
||||
dryRun: dto.dryRun || false
|
||||
};
|
||||
|
||||
if (dto.dryRun) {
|
||||
// Count what would be migrated
|
||||
const scheduleFares = await this.prisma.$queryRaw`
|
||||
SELECT COUNT(*) as count FROM "FareRule" WHERE migrated_to_config_id IS NULL
|
||||
`;
|
||||
|
||||
const segmentFares = await this.prisma.$queryRaw`
|
||||
SELECT COUNT(*) as count FROM "SegmentFareRule" WHERE migrated_to_config_id IS NULL
|
||||
`;
|
||||
|
||||
results.scheduleFareRules = Number((scheduleFares as any[])[0]?.count || 0);
|
||||
results.segmentFareRules = Number((segmentFares as any[])[0]?.count || 0);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// Create a migration configuration based on existing rules
|
||||
const migrationConfig: CreateFareConfigurationDto = {
|
||||
name: 'Legacy Migration Configuration',
|
||||
description: 'Automatically migrated from existing fare rules',
|
||||
effectiveDate: new Date().toISOString(),
|
||||
rateRules: [
|
||||
// Default rates based on current system
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentType: ComponentType.INSURANCE,
|
||||
componentName: 'Travel Insurance',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.02,
|
||||
appliesTo: AppliesTo.BASE_FARE,
|
||||
applyOrder: 1
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.TAX,
|
||||
componentName: 'Government Tax',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.05,
|
||||
appliesTo: AppliesTo.SUBTOTAL,
|
||||
applyOrder: 2
|
||||
}
|
||||
],
|
||||
ageRules: [
|
||||
{
|
||||
ruleName: 'Adult Passengers',
|
||||
minAge: 5,
|
||||
pricingType: PricingType.FULL_FARE
|
||||
},
|
||||
{
|
||||
ruleName: 'Child Passengers (First Free)',
|
||||
minAge: 0,
|
||||
maxAge: 4,
|
||||
pricingType: PricingType.FREE,
|
||||
maxFreePassengers: 1
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const newConfig = await this.createConfiguration(migrationConfig, 'system-migration');
|
||||
results.configurationsCreated = 1;
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
async createNewFormulaConfiguration(dto: CreateNewFormulaDto) {
|
||||
const config = await this.createConfiguration({
|
||||
name: dto.name,
|
||||
description: dto.description || 'System-generated default configuration',
|
||||
effectiveDate: new Date().toISOString(),
|
||||
rateRules: [
|
||||
// Default rates for all combinations
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 3000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 4000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 5500 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.LOCAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 8000 },
|
||||
// International rates (2x local)
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.REGULAR_SEAT, ratePerKmMinor: 6000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.UPPER, ratePerKmMinor: 8000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.MIDDLE, ratePerKmMinor: 11000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.ECONOMY_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 12000 },
|
||||
{ nationalityType: NationalityType.INTERNATIONAL, coachType: CoachType.VIP_BED, bedPosition: BedPosition.LOWER, ratePerKmMinor: 16000 },
|
||||
],
|
||||
components: [
|
||||
{
|
||||
componentType: ComponentType.INSURANCE,
|
||||
componentName: 'Travel Insurance',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.02,
|
||||
appliesTo: AppliesTo.BASE_FARE,
|
||||
applyOrder: 1
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.SERVICE_CHARGE,
|
||||
componentName: 'Service Charge',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.03,
|
||||
appliesTo: AppliesTo.SUBTOTAL,
|
||||
applyOrder: 2
|
||||
},
|
||||
{
|
||||
componentType: ComponentType.TAX,
|
||||
componentName: 'Government Tax',
|
||||
calculationMethod: CalculationMethod.PERCENTAGE,
|
||||
percentageValue: 0.05,
|
||||
appliesTo: AppliesTo.TOTAL,
|
||||
applyOrder: 3
|
||||
}
|
||||
],
|
||||
ageRules: [
|
||||
{
|
||||
ruleName: 'Adult Passengers',
|
||||
minAge: 5,
|
||||
pricingType: PricingType.FULL_FARE
|
||||
},
|
||||
{
|
||||
ruleName: 'Child Passengers (First Free)',
|
||||
minAge: 0,
|
||||
maxAge: 4,
|
||||
pricingType: PricingType.FREE,
|
||||
maxFreePassengers: 1
|
||||
}
|
||||
],
|
||||
isDefault: true
|
||||
}, 'system');
|
||||
|
||||
if (dto.activateImmediately) {
|
||||
await this.activateConfiguration((config as any).id, 'system');
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
// Feature Management
|
||||
async toggleFeature(dto: ToggleFeatureDto) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO system_features (id, feature_name, is_enabled, config, created_at, updated_at)
|
||||
VALUES (${this.generateId('sf')}, ${dto.featureName}, ${dto.enabled},
|
||||
${JSON.stringify(dto.config || {})}, NOW(), NOW())
|
||||
ON CONFLICT (feature_name) DO UPDATE SET
|
||||
is_enabled = ${dto.enabled},
|
||||
config = ${JSON.stringify(dto.config || {})},
|
||||
updated_at = NOW()
|
||||
`;
|
||||
|
||||
return { success: true, message: `Feature ${dto.featureName} ${dto.enabled ? 'enabled' : 'disabled'}` };
|
||||
});
|
||||
}
|
||||
|
||||
async getFeatureStatus(featureName: string) {
|
||||
const result = await this.prisma.$queryRaw`
|
||||
SELECT * FROM system_features WHERE feature_name = ${featureName}
|
||||
`;
|
||||
|
||||
if (!Array.isArray(result) || result.length === 0) {
|
||||
return { enabled: false, config: {} };
|
||||
}
|
||||
|
||||
const feature = result[0] as any;
|
||||
return {
|
||||
enabled: feature.is_enabled,
|
||||
config: feature.config || {}
|
||||
};
|
||||
}
|
||||
|
||||
async getAuditTrail(configId: string) {
|
||||
return this.prisma.$queryRaw`
|
||||
SELECT * FROM fare_configuration_audit
|
||||
WHERE fare_config_id = ${configId}
|
||||
ORDER BY timestamp DESC
|
||||
`;
|
||||
}
|
||||
|
||||
// Private helper methods
|
||||
private async createAuditEntry(tx: any, configId: string, action: string, changedBy?: string, changes?: any) {
|
||||
const auditId = this.generateId('fca');
|
||||
await tx.$executeRaw`
|
||||
INSERT INTO fare_configuration_audit (
|
||||
id, fare_config_id, action, changed_by, changes, timestamp
|
||||
) VALUES (
|
||||
${auditId}, ${configId}, ${action}, ${changedBy || 'system'},
|
||||
${JSON.stringify(changes || {})}, NOW()
|
||||
)
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export class DashboardService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true, liveStatus: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } }, take: 1 },
|
||||
ticket: true,
|
||||
tickets: { take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
}),
|
||||
@@ -42,16 +42,16 @@ export class DashboardService {
|
||||
const name = iamRows[0]?.name;
|
||||
firstName = (name?.en ?? name?.am ?? '').split(' ')[0];
|
||||
}
|
||||
const seat = upcomingBooking?.seats[0];
|
||||
const seat = (upcomingBooking as any)?.seats?.[0];
|
||||
|
||||
return {
|
||||
user: { firstName, greetingKey },
|
||||
upcomingTicket: upcomingBooking ? {
|
||||
ticketId: upcomingBooking.ticket?.id, bookingRef: upcomingBooking.bookingRef,
|
||||
from: upcomingBooking.schedule.originStation.name, to: upcomingBooking.schedule.destinationStation.name,
|
||||
trainName: upcomingBooking.schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
|
||||
departureAt: upcomingBooking.schedule.departureAt,
|
||||
punctualityLabel: (upcomingBooking.schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||
ticketId: (upcomingBooking as any).tickets?.[0]?.id, bookingRef: upcomingBooking.bookingRef,
|
||||
from: (upcomingBooking as any).schedule.originStation.name, to: (upcomingBooking as any).schedule.destinationStation.name,
|
||||
trainName: (upcomingBooking as any).schedule.train.name, coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber,
|
||||
departureAt: (upcomingBooking as any).schedule.departureAt,
|
||||
punctualityLabel: ((upcomingBooking as any).schedule.liveStatus?.delayMinutes ?? 0) > 0 ? 'DELAYED' : 'ON_TIME',
|
||||
} : null,
|
||||
wallet: wallet ? { balanceMinor: wallet.balanceMinor, currency: wallet.currency } : null,
|
||||
activePromotionsCount: promos,
|
||||
|
||||
@@ -91,6 +91,12 @@ export class ExcessBaggageAgentController {
|
||||
deleteAllowance(@Param('id') id: string) {
|
||||
return this.service.deleteAllowance(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete excess baggage charge (admin only)' })
|
||||
deleteCharge(@Param('id') id: string) {
|
||||
return this.service.deleteCharge(id);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public pay-by-token routes (passenger self-service) ──────────────────────
|
||||
|
||||
@@ -252,6 +252,15 @@ export class ExcessBaggageService {
|
||||
};
|
||||
}
|
||||
|
||||
// Mark expired charges before fetching
|
||||
await this.prisma.excessBaggageCharge.updateMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
expiresAt: { lt: new Date() },
|
||||
},
|
||||
data: { status: 'EXPIRED' },
|
||||
});
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.excessBaggageCharge.findMany({
|
||||
where,
|
||||
@@ -291,4 +300,12 @@ export class ExcessBaggageService {
|
||||
await this.prisma.baggageAllowance.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
async deleteCharge(id: string) {
|
||||
const charge = await this.prisma.excessBaggageCharge.findUnique({ where: { id } });
|
||||
if (!charge) throw new NotFoundException('Charge not found');
|
||||
|
||||
await this.prisma.excessBaggageCharge.delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,14 +28,16 @@ export class FareEngineService {
|
||||
if (originStop.sequence >= destStop.sequence)
|
||||
throw new BadRequestException('Origin must come before destination in the route sequence');
|
||||
|
||||
const legStops = route.stops.filter(
|
||||
s => s.sequence > originStop.sequence && s.sequence <= destStop.sequence,
|
||||
);
|
||||
|
||||
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');
|
||||
|
||||
// Calculate distance: distanceKm represents cumulative distance from route origin
|
||||
// For a segment, distance = destination.distanceKm - origin.distanceKm
|
||||
const totalDistanceKm = destStop.distanceKm! - originStop.distanceKm!;
|
||||
if (totalDistanceKm < 0 || isNaN(totalDistanceKm))
|
||||
throw new BadRequestException('Invalid distance calculation - check route stop distances');
|
||||
|
||||
// Resolve fare: FareRule (schedule-scoped → route-scoped) takes precedence over distance×rate
|
||||
const now = new Date();
|
||||
const [originStation, destStation] = await Promise.all([
|
||||
@@ -64,23 +66,15 @@ export class FareEngineService {
|
||||
|
||||
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';
|
||||
|
||||
@@ -174,13 +174,13 @@ export class FleetController {
|
||||
example: [
|
||||
{
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
number: 'HSC-0001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
coachType: {
|
||||
id: 'coach-type-uuid',
|
||||
code: 'sleeper',
|
||||
name: 'Sleeper Coach'
|
||||
code: 'HSC',
|
||||
name: 'Hard Seat Coach'
|
||||
},
|
||||
arrangement: '2+2',
|
||||
capacity: 60,
|
||||
@@ -215,13 +215,13 @@ export class FleetController {
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
number: 'HSC-0001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
coachType: {
|
||||
id: 'coach-type-uuid',
|
||||
code: 'sleeper',
|
||||
name: 'Sleeper Coach'
|
||||
code: 'HSC',
|
||||
name: 'Hard Seat Coach'
|
||||
},
|
||||
arrangement: '2+2',
|
||||
capacity: 60,
|
||||
@@ -257,7 +257,7 @@ export class FleetController {
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
number: 'HSC-0001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
arrangement: '2+2',
|
||||
@@ -283,7 +283,7 @@ export class FleetController {
|
||||
schema: {
|
||||
example: {
|
||||
id: '550e8400-e29b-41d4-a716-446655440000',
|
||||
number: 'A-001',
|
||||
number: 'HSC-0001',
|
||||
sequence: 1,
|
||||
coachTypeId: 'coach-type-uuid',
|
||||
arrangement: '2+2',
|
||||
|
||||
@@ -11,7 +11,7 @@ export class CreateTrainDto {
|
||||
}
|
||||
|
||||
export class CreateCoachDto {
|
||||
@ApiProperty({ example: 'A-001', description: 'Unique coach number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'HSC-0001', description: 'Unique coach number' }) @IsString() number: string;
|
||||
@ApiProperty({ example: 'coach-type-uuid', description: 'Coach Type UUID' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: '2+2', description: 'Seat arrangement for regular coaches (e.g., "2+2", "3+2"). Ignored for bed coaches.' }) @IsString() arrangement: string;
|
||||
@ApiProperty({ example: 60, description: 'Total seat/bed capacity' }) @IsInt() capacity: number;
|
||||
@@ -53,22 +53,22 @@ export class ListCoachesDto {
|
||||
|
||||
// Legacy DTO types for backward compatibility
|
||||
export class CreateCoachTypeDto {
|
||||
@ApiProperty({ example: 'sleeper' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Sleeper Coach' }) @IsString() name: string;
|
||||
@ApiProperty({ example: 'HSC' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Hard Seat Coach' }) @IsString() name: string;
|
||||
@IsOptional() @IsString() type?: string;
|
||||
}
|
||||
|
||||
export class UpdateCoachTypeDto {
|
||||
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() code?: string;
|
||||
@ApiPropertyOptional({ example: 'Sleeper Coach' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional({ example: 'sleeper' }) @IsOptional() @IsString() type?: string;
|
||||
@ApiPropertyOptional({ example: 'HSC' }) @IsOptional() @IsString() code?: string;
|
||||
@ApiPropertyOptional({ example: 'Hard Seat Coach' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional({ example: 'Regular Seat' }) @IsOptional() @IsString() type?: string;
|
||||
}
|
||||
|
||||
export class CreateClassDto {
|
||||
@ApiProperty({ example: 'coach-type-uuid' }) @IsString() coachTypeId: string;
|
||||
@ApiProperty({ example: 'Economy' }) @IsString() name: string;
|
||||
@IsOptional() @IsString() description?: string;
|
||||
@ApiProperty({ example: 5000 }) @IsInt() baseFareMinor: number;
|
||||
@ApiProperty({ example: 500 }) @IsInt() baseFareMinor: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() premiumMinor?: number;
|
||||
@ApiPropertyOptional({ example: 0 }) @IsOptional() @IsInt() insuranceFeeMinor?: number;
|
||||
@ApiPropertyOptional({ example: true }) @IsOptional() @IsBoolean() isActive?: boolean;
|
||||
@@ -78,7 +78,7 @@ export class UpdateClassDto {
|
||||
@ApiPropertyOptional({ example: 'coach-type-uuid' }) @IsOptional() @IsString() coachTypeId?: string;
|
||||
@ApiPropertyOptional({ example: 'Economy' }) @IsOptional() @IsString() name?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() description?: string;
|
||||
@ApiPropertyOptional({ example: 5000 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: 500 }) @IsOptional() @IsInt() baseFareMinor?: number;
|
||||
@ApiPropertyOptional({ example: true })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/comm
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTrainDto, CreateCoachDto, UpdateCoachDto, AssignCoachDto, ListCoachesDto, CreateCoachTypeDto, UpdateCoachTypeDto, CreateClassDto, UpdateClassDto, GenerateSeatMapDto } from './fleet.dto';
|
||||
import { SeatKind } from '@prisma/client';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
// Parses '2+2' → [2, 2], '2+2+2' → [2, 2, 2]
|
||||
function parseArrangement(arrangement: string): number[] {
|
||||
@@ -41,7 +42,7 @@ const DEFAULT_BEDS_PER_ROOM: Record<'ECONOMY_BED' | 'VIP_BED', number> = {
|
||||
ECONOMY_BED: 6,
|
||||
};
|
||||
|
||||
// Name-based fallback: checks if 'vip' is present for any bed/sleeper coach type
|
||||
// Name-based fallback: checks if 'vip' is present for any bed coach type
|
||||
function detectBedCategory(coachTypeName: string): BedCategory {
|
||||
const name = coachTypeName.toLowerCase();
|
||||
const isBed = name.includes('bed') || name.includes('berth') || name.includes('sleeper') || name.includes('couchette');
|
||||
@@ -201,17 +202,25 @@ export class FleetService {
|
||||
});
|
||||
if (!coachType) throw new NotFoundException('Coach type not found');
|
||||
|
||||
// Check for related records
|
||||
const constraints = [];
|
||||
if (coachType.coaches.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach type. ${coachType.coaches.length} coach(es) are still using this coach type. Please reassign or delete the coaches first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'coach',
|
||||
count: coachType.coaches.length,
|
||||
action: 'reassign' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (coachType.seatClasses.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach type. ${coachType.seatClasses.length} seat class(es) are still using this coach type. Please reassign or delete the seat classes first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'seat class',
|
||||
count: coachType.seatClasses.length,
|
||||
action: 'reassign' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Coach Type', coachType.name, constraints);
|
||||
}
|
||||
|
||||
return this.prisma.coachType.delete({ where: { id } });
|
||||
@@ -273,17 +282,19 @@ export class FleetService {
|
||||
});
|
||||
if (!seatClass) throw new NotFoundException('Seat class not found');
|
||||
|
||||
// Check for related records
|
||||
const relatedRecords = [
|
||||
...seatClass.fareRules,
|
||||
...seatClass.routeFareRules,
|
||||
...seatClass.segmentFares,
|
||||
];
|
||||
const totalFareRules = seatClass.fareRules.length + seatClass.routeFareRules.length + seatClass.segmentFares.length;
|
||||
const constraints = [];
|
||||
|
||||
if (relatedRecords.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete seat class. ${relatedRecords.length} fare rule(s) are still using this seat class. Please delete the fare rules first.`
|
||||
);
|
||||
if (totalFareRules > 0) {
|
||||
constraints.push({
|
||||
entityName: 'fare rule',
|
||||
count: totalFareRules,
|
||||
action: 'delete' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Seat Class', seatClass.name, constraints);
|
||||
}
|
||||
|
||||
return this.prisma.seatClass.delete({ where: { id } });
|
||||
@@ -344,11 +355,20 @@ export class FleetService {
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (!train) throw new NotFoundException('Train not found');
|
||||
|
||||
const constraints = [];
|
||||
if (train.schedules.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete train. This train has ${train.schedules.length} schedule(s). Please delete the schedules first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'schedule',
|
||||
count: train.schedules.length,
|
||||
action: 'delete' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Train', `${train.number} (${train.name})`, constraints);
|
||||
}
|
||||
|
||||
return this.prisma.train.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -460,45 +480,54 @@ export class FleetService {
|
||||
include: {
|
||||
bookingSeats: true,
|
||||
blocks: true,
|
||||
ticketSeats: true,
|
||||
tickets: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!coach) throw new NotFoundException('Coach not found');
|
||||
|
||||
// Check for active assignments
|
||||
if (coach.assignments.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. This coach is assigned to ${coach.assignments.length} schedule(s). Please remove the assignments first.`
|
||||
);
|
||||
const constraints = [];
|
||||
|
||||
if ((coach as any).assignments.length > 0) {
|
||||
constraints.push({
|
||||
entityName: 'schedule assignment',
|
||||
count: (coach as any).assignments.length,
|
||||
action: 'reassign' as const
|
||||
});
|
||||
}
|
||||
|
||||
// Check for booked seats
|
||||
const bookedSeats = coach.seats.filter(seat => seat.bookingSeats.length > 0);
|
||||
const bookedSeats = (coach as any).seats.filter((seat: any) => seat.bookingSeats.length > 0);
|
||||
if (bookedSeats.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${bookedSeats.length} seat(s) have active bookings. Please wait for bookings to complete or cancel them first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'booked seat',
|
||||
count: bookedSeats.length,
|
||||
action: 'complete' as const
|
||||
});
|
||||
}
|
||||
|
||||
// Check for blocked seats
|
||||
const blockedSeats = coach.seats.filter(seat => seat.blocks.length > 0);
|
||||
const blockedSeats = (coach as any).seats.filter((seat: any) => seat.blocks.length > 0);
|
||||
if (blockedSeats.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${blockedSeats.length} seat(s) are blocked. Please unblock them first.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'blocked seat',
|
||||
count: blockedSeats.length,
|
||||
action: 'delete' as const
|
||||
});
|
||||
}
|
||||
|
||||
// Check for tickets
|
||||
const seatsWithTickets = coach.seats.filter(seat => seat.ticketSeats.length > 0);
|
||||
const seatsWithTickets = (coach as any).seats.filter((seat: any) => seat.tickets.length > 0);
|
||||
if (seatsWithTickets.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete coach. ${seatsWithTickets.length} seat(s) have issued tickets. Please wait for travel completion.`
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'seat with issued ticket',
|
||||
count: seatsWithTickets.length,
|
||||
action: 'complete' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Coach', coach.number, constraints);
|
||||
}
|
||||
|
||||
// Delete related seats first (now safe to do)
|
||||
await this.prisma.seat.deleteMany({ where: { coachId: id } });
|
||||
|
||||
return this.prisma.coach.delete({ where: { id } });
|
||||
|
||||
@@ -280,7 +280,7 @@ export class NotificationsService {
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
},
|
||||
});
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId } });
|
||||
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId } });
|
||||
|
||||
const ref = booking?.bookingRef ?? payload.booking.bookingRef;
|
||||
const amount = this.formatAmount(booking ?? payload.booking);
|
||||
|
||||
@@ -21,22 +21,152 @@ export class PassengersController {
|
||||
@Get()
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: 'List all passengers with filters (Admin/Agent)',
|
||||
description: 'Returns paginated list of passengers with search filters'
|
||||
summary: 'List all travelers with filters (Admin/Agent)',
|
||||
description: `**Returns paginated list of all travelers in the system**
|
||||
|
||||
---
|
||||
|
||||
### Data Source
|
||||
- Fetches from **TravelerProfile** table (created during booking)
|
||||
- Shows ALL passengers from ALL bookings (including guest bookings)
|
||||
- Each row represents a unique traveler, not a user account
|
||||
|
||||
---
|
||||
|
||||
### Features
|
||||
- Search by name, email, phone
|
||||
- Filter by gender
|
||||
- Date range filtering (createdAt)
|
||||
- Pagination support (page, pageSize)
|
||||
- Includes loyalty and wallet info if linked to user account
|
||||
- Shows booking count per traveler
|
||||
|
||||
---
|
||||
|
||||
### Response Fields
|
||||
- **id**: TravelerProfile ID
|
||||
- **fullName**: Passenger name
|
||||
- **email/phone**: Contact info (from linked user or booking)
|
||||
- **gender**: Male/Female/Other (from Verifayda or manual entry)
|
||||
- **dateOfBirth**: Birth date in YYYY-MM-DD format
|
||||
- **nationality**: Passenger nationality
|
||||
- **faydaVerified**: Whether verified via Verifayda
|
||||
- **loyaltyTier/loyaltyPoints**: If linked to user account
|
||||
- **totalBookings**: Number of bookings
|
||||
- **createdAt**: When traveler was first added to system`
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false, description: 'Search by name, email, or phone' })
|
||||
@ApiQuery({ name: 'gender', required: false, description: 'Filter by gender (Male, Female, Other)' })
|
||||
@ApiQuery({ name: 'dateFrom', required: false, description: 'Filter by creation date from (YYYY-MM-DD)' })
|
||||
@ApiQuery({ name: 'dateTo', required: false, description: 'Filter by creation date to (YYYY-MM-DD)' })
|
||||
@ApiQuery({ name: 'page', required: false, description: 'Page number (default: 1)' })
|
||||
@ApiQuery({ name: 'pageSize', required: false, description: 'Items per page (default: 20)' })
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Travelers retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
id: 'uuid-123',
|
||||
fullName: 'Abebe Kebede',
|
||||
email: 'abebe@example.com',
|
||||
phone: '+251911234567',
|
||||
gender: 'Male',
|
||||
dateOfBirth: '1985-03-15',
|
||||
nationality: 'Ethiopian',
|
||||
faydaVerified: true,
|
||||
loyaltyTier: 'SILVER',
|
||||
loyaltyPoints: 1500,
|
||||
totalBookings: 5,
|
||||
createdAt: '2024-01-10T12:00:00.000Z'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 150,
|
||||
totalPages: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description: 'Travelers retrieved successfully',
|
||||
schema: {
|
||||
example: {
|
||||
items: [
|
||||
{
|
||||
id: 'uuid-123',
|
||||
fullName: 'Abebe Kebede',
|
||||
email: 'abebe@example.com',
|
||||
phone: '+251911234567',
|
||||
gender: 'Male',
|
||||
dateOfBirth: '1985-03-15',
|
||||
nationality: 'Ethiopian',
|
||||
nationalityCode: 'ET',
|
||||
faydaVerified: true,
|
||||
faydaVerifiedAt: '2024-01-15T10:30:00.000Z',
|
||||
passportNumber: null,
|
||||
passportCountry: null,
|
||||
passportExpiryDate: null,
|
||||
idDocumentType: 'NATIONAL_ID',
|
||||
verified: true,
|
||||
lastLoginAt: '2024-01-20T08:15:00.000Z',
|
||||
role: 'PASSENGER',
|
||||
loyalty: {
|
||||
tier: 'SILVER',
|
||||
pointsBalance: 1500,
|
||||
lifetimePoints: 3000
|
||||
},
|
||||
wallet: {
|
||||
balanceMinor: 50000,
|
||||
currency: 'ETB'
|
||||
},
|
||||
loyaltyTier: 'SILVER',
|
||||
loyaltyPoints: 1500,
|
||||
totalBookings: 5,
|
||||
createdAt: '2024-01-10T12:00:00.000Z'
|
||||
},
|
||||
{
|
||||
id: 'uuid-456',
|
||||
fullName: 'Sara Ketsela',
|
||||
email: null,
|
||||
phone: null,
|
||||
gender: 'Female',
|
||||
dateOfBirth: '1990-08-22',
|
||||
nationality: 'Ethiopian',
|
||||
nationalityCode: null,
|
||||
faydaVerified: false,
|
||||
faydaVerifiedAt: null,
|
||||
passportNumber: null,
|
||||
passportCountry: null,
|
||||
passportExpiryDate: null,
|
||||
idDocumentType: null,
|
||||
verified: false,
|
||||
lastLoginAt: null,
|
||||
role: null,
|
||||
loyalty: null,
|
||||
wallet: null,
|
||||
loyaltyTier: 'BRONZE',
|
||||
loyaltyPoints: 0,
|
||||
totalBookings: 1,
|
||||
createdAt: '2024-01-18T14:30:00.000Z'
|
||||
}
|
||||
],
|
||||
meta: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
total: 150,
|
||||
totalPages: 8
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@ApiQuery({ name: 'search', required: false })
|
||||
@ApiQuery({ name: 'verified', required: false })
|
||||
@ApiQuery({ name: 'gender', required: false })
|
||||
@ApiQuery({ name: 'nationality', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'page', required: false })
|
||||
@ApiQuery({ name: 'pageSize', required: false })
|
||||
findAll(
|
||||
@Query('search') search?: string,
|
||||
@Query('verified') verified?: string,
|
||||
@Query('gender') gender?: string,
|
||||
@Query('nationality') nationality?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@@ -44,9 +174,7 @@ export class PassengersController {
|
||||
) {
|
||||
return this.service.findAll({
|
||||
search,
|
||||
verified: verified ? verified === 'true' : undefined,
|
||||
gender,
|
||||
nationality,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
|
||||
@@ -8,6 +8,7 @@ export class CreateTravelerProfileDto {
|
||||
@ApiProperty({ example: 'SPOUSE' }) @IsString() relationship: string;
|
||||
@ApiPropertyOptional({ example: '1998-04-01' }) @IsOptional() @IsDateString() dateOfBirth?: string;
|
||||
@ApiPropertyOptional({ example: 'ET-1234-5678' }) @IsOptional() @IsString() nationalId?: string;
|
||||
@ApiPropertyOptional({ example: 'Female' }) @IsOptional() @IsString() gender?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() notes?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,12 +4,11 @@ import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateTravelerProfileDto, CreateSavedRouteDto, RegisterPassengerDto } from './passengers.dto';
|
||||
import { VerifaydaService } from '../verifayda/verifayda.service';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
interface PassengerFilters {
|
||||
search?: string;
|
||||
verified?: boolean;
|
||||
gender?: string;
|
||||
nationality?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
@@ -33,31 +32,21 @@ export class PassengersService {
|
||||
) {}
|
||||
|
||||
async findAll(filters: PassengerFilters = {}) {
|
||||
const { search, verified, gender, nationality, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const { search, gender, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const where: any = {};
|
||||
|
||||
if (search) {
|
||||
where.user = {
|
||||
OR: [
|
||||
{ email: { contains: search, mode: 'insensitive' } },
|
||||
{ phone: { contains: search, mode: 'insensitive' } },
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (verified !== undefined) {
|
||||
where.user = { ...(where.user ?? {}), faydaVerified: verified };
|
||||
where.OR = [
|
||||
{ fullName: { contains: search, mode: 'insensitive' } },
|
||||
{ passenger: { user: { email: { contains: search, mode: 'insensitive' } } } },
|
||||
{ passenger: { user: { phone: { contains: search, mode: 'insensitive' } } } },
|
||||
];
|
||||
}
|
||||
|
||||
if (gender) {
|
||||
where.user = { ...(where.user ?? {}), gender };
|
||||
}
|
||||
|
||||
if (nationality) {
|
||||
where.user = { ...(where.user ?? {}), nationality: { contains: nationality, mode: 'insensitive' } };
|
||||
where.gender = gender;
|
||||
}
|
||||
|
||||
if (dateFrom || dateTo) {
|
||||
@@ -68,34 +57,43 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.passenger.findMany({
|
||||
this.prisma.travelerProfile.findMany({
|
||||
where,
|
||||
skip,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: { select: { bookings: true } },
|
||||
bookings: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
select: {
|
||||
contactEmail: true,
|
||||
contactPhone: true,
|
||||
seats: { take: 1, orderBy: { id: 'asc' }, select: {
|
||||
passengerName: true, dateOfBirth: true, passportNumber: true,
|
||||
passportCountry: true, idDocumentType: true, verifaydaVerified: true, faydaVerifiedAt: true,
|
||||
}},
|
||||
passenger: {
|
||||
include: {
|
||||
user: true,
|
||||
loyalty: true,
|
||||
wallet: true,
|
||||
_count: { select: { bookings: true } },
|
||||
bookings: {
|
||||
take: 1,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
contactPhone: true,
|
||||
contactEmail: true,
|
||||
seats: {
|
||||
take: 1,
|
||||
orderBy: { id: 'asc' },
|
||||
select: {
|
||||
passengerName: true,
|
||||
passportNumber: true,
|
||||
passportCountry: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.passenger.count({ where }),
|
||||
this.prisma.travelerProfile.count({ where }),
|
||||
]);
|
||||
|
||||
const iamUserIds = items.map(p => p.iamUserId).filter(Boolean) as string[];
|
||||
const iamUserIds = items.map(p => p.passenger?.iamUserId).filter(Boolean) as string[];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<IamUserRow[]>(
|
||||
`SELECT id, email, name, phone_number, metadata FROM iam.users WHERE id = ANY($1)`,
|
||||
@@ -104,72 +102,51 @@ export class PassengersService {
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
// Collect guest contact details for bulk SavedPassengerProfile lookup
|
||||
const guestContacts = items
|
||||
.filter(p => !(p as any).user && !p.iamUserId)
|
||||
.map(p => (p as any).bookings?.[0])
|
||||
.filter(Boolean);
|
||||
const guestEmails = guestContacts.map((b: any) => b.contactEmail).filter(Boolean) as string[];
|
||||
const guestPhones = guestContacts.map((b: any) => b.contactPhone).filter(Boolean) as string[];
|
||||
|
||||
const savedProfiles = (guestEmails.length || guestPhones.length)
|
||||
? await this.prisma.savedPassengerProfile.findMany({
|
||||
where: { OR: [
|
||||
...(guestEmails.length ? [{ email: { in: guestEmails } }] : []),
|
||||
...(guestPhones.length ? [{ phone: { in: guestPhones } }] : []),
|
||||
]},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
: [];
|
||||
|
||||
// Index by email then phone for O(1) lookup
|
||||
const profileByEmail = new Map(savedProfiles.filter(s => s.email).map(s => [s.email!, s]));
|
||||
const profileByPhone = new Map(savedProfiles.filter(s => s.phone).map(s => [s.phone!, s]));
|
||||
|
||||
return {
|
||||
items: items.map(passenger => {
|
||||
const localUser = (passenger as any).user ?? null;
|
||||
const iam = passenger.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
items: items.map(profile => {
|
||||
const passenger = profile.passenger;
|
||||
const localUser = (passenger as any)?.user ?? null;
|
||||
const iam = passenger?.iamUserId ? iamMap.get(passenger.iamUserId) : undefined;
|
||||
const faydaVerified = localUser?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === true
|
||||
|| iam?.metadata?.faydaVerified === 'true';
|
||||
const guestBooking = !localUser && !iam ? (passenger as any).bookings?.[0] : null;
|
||||
|
||||
// Get additional data from bookings for guest passengers
|
||||
const guestBooking = (passenger as any)?.bookings?.[0] ?? null;
|
||||
const guestSeat = guestBooking?.seats?.[0] ?? null;
|
||||
const savedProfile = guestBooking
|
||||
? (profileByEmail.get(guestBooking.contactEmail) ?? profileByPhone.get(guestBooking.contactPhone) ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: passenger.id,
|
||||
fullName: localUser?.fullName ?? iam?.name?.en ?? iam?.name?.am ?? savedProfile?.passengerName ?? guestSeat?.passengerName ?? null,
|
||||
email: localUser?.email ?? iam?.email ?? savedProfile?.email ?? guestBooking?.contactEmail ?? null,
|
||||
phone: localUser?.phone ?? iam?.phone_number ?? savedProfile?.phone ?? guestBooking?.contactPhone ?? null,
|
||||
gender: localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||
dateOfBirth: localUser?.dateOfBirth
|
||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||
: (iam?.metadata?.dateOfBirth ?? (savedProfile?.dateOfBirth
|
||||
? new Date(savedProfile.dateOfBirth).toISOString().split('T')[0]
|
||||
: (guestSeat?.dateOfBirth ? new Date(guestSeat.dateOfBirth).toISOString().split('T')[0] : null))),
|
||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? savedProfile?.nationality ?? null,
|
||||
id: profile.id,
|
||||
fullName: profile.fullName,
|
||||
email: localUser?.email ?? iam?.email ?? guestBooking?.contactEmail ?? null,
|
||||
phone: localUser?.phone ?? iam?.phone_number ?? guestBooking?.contactPhone ?? null,
|
||||
gender: profile.gender ?? localUser?.gender ?? iam?.metadata?.gender ?? null,
|
||||
dateOfBirth: profile.dateOfBirth
|
||||
? new Date(profile.dateOfBirth).toISOString().split('T')[0]
|
||||
: (localUser?.dateOfBirth
|
||||
? (localUser.dateOfBirth instanceof Date ? localUser.dateOfBirth.toISOString().split('T')[0] : localUser.dateOfBirth)
|
||||
: iam?.metadata?.dateOfBirth ?? null),
|
||||
nationality: localUser?.nationality ?? iam?.metadata?.nationality ?? (guestSeat?.passportCountry ? (guestSeat.passportCountry === 'Ethiopia' ? 'Ethiopian' : guestSeat.passportCountry) : null),
|
||||
nationalityCode: localUser?.nationalityCode ?? iam?.metadata?.nationalityCode ?? null,
|
||||
faydaVerified,
|
||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? guestSeat?.faydaVerifiedAt ?? null,
|
||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? savedProfile?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? savedProfile?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||
faydaVerifiedAt: localUser?.faydaVerifiedAt ?? iam?.metadata?.faydaVerifiedAt ?? null,
|
||||
passportNumber: localUser?.passportNumber ?? iam?.metadata?.passportNumber ?? guestSeat?.passportNumber ?? null,
|
||||
passportCountry: localUser?.passportCountry ?? iam?.metadata?.passportCountry ?? guestSeat?.passportCountry ?? null,
|
||||
passportExpiryDate: localUser?.passportExpiryDate ?? iam?.metadata?.passportExpiryDate ?? null,
|
||||
idDocumentType: savedProfile?.idDocumentType ?? guestSeat?.idDocumentType ?? null,
|
||||
idDocumentType: profile.nationalId ? 'NATIONAL_ID' : null,
|
||||
verified: faydaVerified,
|
||||
lastLoginAt: localUser?.lastLoginAt ?? null,
|
||||
role: localUser?.role ?? null,
|
||||
loyalty: passenger.loyalty
|
||||
loyalty: passenger?.loyalty
|
||||
? { tier: passenger.loyalty.tier, pointsBalance: passenger.loyalty.pointsBalance, lifetimePoints: (passenger.loyalty as any).lifetimePoints ?? 0 }
|
||||
: null,
|
||||
wallet: (passenger as any).wallet
|
||||
wallet: (passenger as any)?.wallet
|
||||
? { balanceMinor: (passenger as any).wallet.balanceMinor, currency: (passenger as any).wallet.currency ?? 'ETB' }
|
||||
: null,
|
||||
loyaltyTier: passenger.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger._count.bookings,
|
||||
createdAt: passenger.createdAt,
|
||||
loyaltyTier: passenger?.loyalty?.tier || 'BRONZE',
|
||||
loyaltyPoints: passenger?.loyalty?.pointsBalance || 0,
|
||||
totalBookings: passenger?._count?.bookings || 0,
|
||||
createdAt: profile.createdAt,
|
||||
};
|
||||
}),
|
||||
meta: {
|
||||
@@ -299,8 +276,13 @@ export class PassengersService {
|
||||
createTravelerProfile(dto: CreateTravelerProfileDto) {
|
||||
return this.prisma.travelerProfile.create({
|
||||
data: {
|
||||
...dto,
|
||||
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null
|
||||
passengerId: dto.passengerId,
|
||||
fullName: dto.fullName,
|
||||
relationship: dto.relationship,
|
||||
dateOfBirth: dto.dateOfBirth ? new Date(dto.dateOfBirth) : null,
|
||||
nationalId: dto.nationalId || null,
|
||||
gender: dto.gender || null,
|
||||
notes: dto.notes || null,
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -440,9 +422,21 @@ export class PassengersService {
|
||||
}
|
||||
|
||||
async deletePassenger(id: string) {
|
||||
const passenger = await this.prisma.passenger.findUnique({ where: { id } });
|
||||
const passenger = await this.prisma.passenger.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
user: true
|
||||
}
|
||||
});
|
||||
if (!passenger) throw new NotFoundException('Passenger not found');
|
||||
|
||||
// Check usage before allowing deletion
|
||||
const usage = await this.checkPassengerUsage(id);
|
||||
if (usage.isInUse && usage.constraints) {
|
||||
const passengerName = (passenger as any).user?.fullName || `Passenger ${id.slice(-8)}`;
|
||||
throw new DeleteOperationException('Passenger', passengerName, usage.constraints);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.loyaltyLedgerEntry.deleteMany({ where: { account: { passengerId: id } } }),
|
||||
this.prisma.loyaltyAccount.deleteMany({ where: { passengerId: id } }),
|
||||
@@ -470,14 +464,15 @@ export class PassengersService {
|
||||
this.prisma.walletAccount.findUnique({ where: { passengerId: id } }),
|
||||
]);
|
||||
|
||||
const usage = [];
|
||||
if (bookingCount > 0) usage.push(`${bookingCount} booking(s)`);
|
||||
if (loyaltyAccount) usage.push('Loyalty account');
|
||||
if (walletAccount) usage.push('Wallet account');
|
||||
const constraints = [];
|
||||
if (bookingCount > 0) constraints.push({ entityName: 'booking', count: bookingCount, action: 'complete' as const });
|
||||
if (loyaltyAccount) constraints.push({ entityName: 'loyalty account', count: 1, action: 'delete' as const });
|
||||
if (walletAccount) constraints.push({ entityName: 'wallet account', count: 1, action: 'delete' as const });
|
||||
|
||||
return {
|
||||
isInUse: usage.length > 0,
|
||||
affectedModules: usage,
|
||||
isInUse: constraints.length > 0,
|
||||
affectedModules: constraints.map(c => `${c.count} ${c.entityName}${c.count > 1 ? 's' : ''}`),
|
||||
constraints
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Get,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Res,
|
||||
@@ -123,6 +124,16 @@ export class PaymentsController {
|
||||
return this.service.addPaymentMethod(dto);
|
||||
}
|
||||
|
||||
@Patch("methods/:id")
|
||||
@PassengerStaff([PASSENGER_PERMS.payments.manageMethods, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
@ApiOperation({
|
||||
summary: "Update a payment method configuration (admin only)",
|
||||
})
|
||||
updateMethod(@Param("id") id: string, @Body() dto: Partial<AddPaymentMethodDto>) {
|
||||
return this.service.updatePaymentMethod(id, dto);
|
||||
}
|
||||
|
||||
@Get("methods")
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
|
||||
@@ -473,6 +473,24 @@ export class PaymentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async updatePaymentMethod(id: string, dto: Partial<AddPaymentMethodDto>) {
|
||||
const existing = await this.prisma.paymentMethod.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('Payment method not found');
|
||||
|
||||
const updateData: any = {};
|
||||
if (dto.displayName !== undefined) updateData.displayName = dto.displayName;
|
||||
if (dto.region !== undefined) updateData.region = dto.region as unknown as PaymentRegion;
|
||||
if (dto.currency !== undefined) updateData.currency = dto.currency;
|
||||
if (dto.providerId !== undefined) updateData.providerId = dto.providerId;
|
||||
if (dto.enabled !== undefined) updateData.enabled = dto.enabled;
|
||||
if (dto.sortOrder !== undefined) updateData.sortOrder = dto.sortOrder;
|
||||
|
||||
return this.prisma.paymentMethod.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
}
|
||||
|
||||
getSupportedPaymentMethods(region?: PaymentRegionEnum, currency?: string) {
|
||||
return this.prisma.paymentMethod.findMany({
|
||||
where: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException, ConflictException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { CreateRouteDto, AddRouteStopDto, UpdateRouteDto } from './routes.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
|
||||
@Injectable()
|
||||
export class RoutesService {
|
||||
@@ -93,8 +94,28 @@ export class RoutesService {
|
||||
}
|
||||
|
||||
async deleteRoute(id: string) {
|
||||
const route = await this.prisma.route.findUnique({ where: { id } });
|
||||
const route = await this.prisma.route.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
schedules: true,
|
||||
stops: true
|
||||
}
|
||||
});
|
||||
if (!route) throw new NotFoundException('Route not found');
|
||||
|
||||
const constraints = [];
|
||||
if (route.schedules.length > 0) {
|
||||
constraints.push({
|
||||
entityName: 'schedule',
|
||||
count: route.schedules.length,
|
||||
action: 'delete' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
throw new DeleteOperationException('Route', `${route.code} (${route.name})`, constraints);
|
||||
}
|
||||
|
||||
await this.prisma.route.delete({ where: { id } });
|
||||
return { deleted: true, id };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import { PrismaService } from '../../common/prisma.service';
|
||||
import { RoutesService } from './routes.service';
|
||||
import { FareEngineService } from '../fare-engine/fare-engine.service';
|
||||
import { CreateScheduleDto, UpdateScheduleDto, CreateFareRuleDto, UpdateScheduleStatusDto, UpdateStopTimeDto, ListSchedulesDto, BulkCreateSchedulesDto } from './schedules.dto';
|
||||
import { DeleteOperationException } from '../../common/exceptions/delete-operation.exception';
|
||||
import { parseEthiopianTime, startOfDayEAT, startOfNextDayEAT } from '../../common/utils/timezone.utils';
|
||||
|
||||
@Injectable()
|
||||
export class SchedulesService {
|
||||
@@ -13,7 +15,7 @@ export class SchedulesService {
|
||||
) { }
|
||||
|
||||
async bulkGenerateSchedules(dto: BulkCreateSchedulesDto) {
|
||||
const startDate = new Date(dto.startDateTime);
|
||||
const startDate = parseEthiopianTime(dto.startDateTime);
|
||||
const endDate = new Date(startDate.getTime() + dto.forNextDays * 24 * 60 * 60 * 1000);
|
||||
const errors: string[] = [];
|
||||
const scheduleIds: string[] = [];
|
||||
@@ -66,8 +68,8 @@ export class SchedulesService {
|
||||
const where: any = {};
|
||||
|
||||
if (dto.date) {
|
||||
const date = new Date(dto.date);
|
||||
const nextDay = new Date(date.getTime() + 86_400_000);
|
||||
const date = parseEthiopianTime(dto.date);
|
||||
const nextDay = startOfNextDayEAT(date);
|
||||
where.departureAt = { gte: date, lt: nextDay };
|
||||
}
|
||||
if (dto.routeId) where.routeId = dto.routeId;
|
||||
@@ -93,8 +95,9 @@ export class SchedulesService {
|
||||
}
|
||||
|
||||
async createSchedule(dto: CreateScheduleDto) {
|
||||
const dep = new Date(dto.departureAt);
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
// Parse dates in local Ethiopian time (EAT - UTC+3)
|
||||
const dep = parseEthiopianTime(dto.departureAt);
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
const route = await this.prisma.route.findUnique({
|
||||
@@ -105,10 +108,9 @@ 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');
|
||||
|
||||
const depDate = new Date(dep);
|
||||
depDate.setHours(0, 0, 0, 0);
|
||||
const nextDay = new Date(depDate);
|
||||
nextDay.setDate(nextDay.getDate() + 1);
|
||||
// Check for existing schedule on the same day (local Ethiopian time)
|
||||
const depDate = startOfDayEAT(dep);
|
||||
const nextDay = startOfNextDayEAT(dep);
|
||||
|
||||
const existingSchedule = await this.prisma.trainSchedule.findFirst({
|
||||
where: { trainId: dto.trainId, routeId: dto.routeId, departureAt: { gte: depDate, lt: nextDay } },
|
||||
@@ -240,8 +242,9 @@ export class SchedulesService {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const dep = new Date(dto.departureAt);
|
||||
const arr = new Date(dto.arrivalAt);
|
||||
// Parse dates in local Ethiopian time (EAT - UTC+3)
|
||||
const dep = parseEthiopianTime(dto.departureAt);
|
||||
const arr = parseEthiopianTime(dto.arrivalAt);
|
||||
if (arr <= dep) throw new BadRequestException('arrivalAt must be after departureAt');
|
||||
|
||||
const route = await this.prisma.route.findUnique({
|
||||
@@ -308,16 +311,59 @@ export class SchedulesService {
|
||||
async deleteSchedule(id: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id },
|
||||
include: { _count: { select: { bookings: true } } },
|
||||
include: {
|
||||
_count: { select: { bookings: true } },
|
||||
train: true,
|
||||
originStation: true,
|
||||
destinationStation: true
|
||||
},
|
||||
});
|
||||
if (!schedule) throw new NotFoundException('Schedule not found');
|
||||
|
||||
const constraints = [];
|
||||
if ((schedule as any)._count.bookings > 0) {
|
||||
throw new BadRequestException(
|
||||
`Cannot delete schedule. It has ${(schedule as any)._count.bookings} booking(s). Cancel all bookings before deleting.`,
|
||||
);
|
||||
constraints.push({
|
||||
entityName: 'booking',
|
||||
count: (schedule as any)._count.bookings,
|
||||
action: 'cancel' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (constraints.length > 0) {
|
||||
const scheduleName = `${schedule.train.number} (${schedule.originStation.name} → ${schedule.destinationStation.name})`;
|
||||
throw new DeleteOperationException('Schedule', scheduleName, constraints);
|
||||
}
|
||||
|
||||
await this.prisma.tripStopTime.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.coachAssignment.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.tripLiveStatus.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.menuItem.deleteMany({ where: { scheduleId: id } });
|
||||
await this.prisma.journeySegment.deleteMany({ where: { scheduleId: id } });
|
||||
|
||||
// Delete travel packages that reference this schedule (required fields cannot be nulled)
|
||||
// First get packages that reference this schedule
|
||||
const packagesToDelete = await this.prisma.travelPackage.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ outboundScheduleId: id },
|
||||
{ returnScheduleId: id }
|
||||
]
|
||||
},
|
||||
select: { id: true }
|
||||
});
|
||||
|
||||
// Delete price tiers first (they have foreign key to packages)
|
||||
if (packagesToDelete.length > 0) {
|
||||
const packageIds = packagesToDelete.map(p => p.id);
|
||||
await this.prisma.packagePriceTier.deleteMany({
|
||||
where: { packageId: { in: packageIds } }
|
||||
});
|
||||
|
||||
// Now delete the packages
|
||||
await this.prisma.travelPackage.deleteMany({
|
||||
where: { id: { in: packageIds } }
|
||||
});
|
||||
}
|
||||
return this.prisma.trainSchedule.delete({ where: { id } });
|
||||
}
|
||||
|
||||
@@ -338,8 +384,8 @@ export class SchedulesService {
|
||||
return this.prisma.tripStopTime.update({
|
||||
where: { scheduleId_sequence: { scheduleId, sequence } },
|
||||
data: {
|
||||
plannedArrivalAt: dto.plannedArrivalAt ? new Date(dto.plannedArrivalAt) : undefined,
|
||||
plannedDepartureAt: dto.plannedDepartureAt ? new Date(dto.plannedDepartureAt) : undefined,
|
||||
plannedArrivalAt: dto.plannedArrivalAt ? parseEthiopianTime(dto.plannedArrivalAt) : undefined,
|
||||
plannedDepartureAt: dto.plannedDepartureAt ? parseEthiopianTime(dto.plannedDepartureAt) : undefined,
|
||||
status: dto.status,
|
||||
},
|
||||
include: { station: true },
|
||||
@@ -353,8 +399,8 @@ export class SchedulesService {
|
||||
...rest,
|
||||
tripId: scheduleId,
|
||||
nationality,
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
validFrom: parseEthiopianTime(validFrom),
|
||||
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
@@ -371,8 +417,8 @@ export class SchedulesService {
|
||||
...rest,
|
||||
...(scheduleId !== undefined && { tripId: scheduleId }),
|
||||
...(nationality !== undefined && { nationality }),
|
||||
...(validFrom && { validFrom: new Date(validFrom) }),
|
||||
...(validUntil !== undefined && { validUntil: validUntil ? new Date(validUntil) : null }),
|
||||
...(validFrom && { validFrom: parseEthiopianTime(validFrom) }),
|
||||
...(validUntil !== undefined && { validUntil: validUntil ? parseEthiopianTime(validUntil) : null }),
|
||||
},
|
||||
include: { seatClass: true },
|
||||
});
|
||||
@@ -390,8 +436,8 @@ export class SchedulesService {
|
||||
return this.prisma.segmentFareRule.create({
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: new Date(validFrom),
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
validFrom: parseEthiopianTime(validFrom),
|
||||
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
@@ -415,8 +461,8 @@ export class SchedulesService {
|
||||
where: { id },
|
||||
data: {
|
||||
...rest,
|
||||
validFrom: validFrom ? new Date(validFrom) : undefined,
|
||||
validUntil: validUntil ? new Date(validUntil) : null,
|
||||
validFrom: validFrom ? parseEthiopianTime(validFrom) : undefined,
|
||||
validUntil: validUntil ? parseEthiopianTime(validUntil) : null,
|
||||
},
|
||||
include: { seatClass: true, route: true },
|
||||
});
|
||||
@@ -523,8 +569,8 @@ export class SchedulesService {
|
||||
const updateData: any = {};
|
||||
|
||||
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);
|
||||
const dep = dto.departureAt ? parseEthiopianTime(dto.departureAt) : new Date(schedule.departureAt);
|
||||
const arr = dto.arrivalAt ? parseEthiopianTime(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;
|
||||
|
||||
@@ -33,7 +33,7 @@ export class SeatsController {
|
||||
@SetMetadata('isPublic', true)
|
||||
@ApiOperation({
|
||||
summary: "Get seat map filtered by coach type",
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches.`,
|
||||
description: `Returns all coaches of the given coachTypeId assigned to the schedule, each with their full seat list and real-time availability. Origin and destination are derived from the schedule. Omit coachTypeId to get all coaches. Use journeyDirection to filter seat holds (OUTBOUND vs RETURN for round-trip bookings).`,
|
||||
})
|
||||
@ApiParam({ name: "scheduleId", description: "TrainSchedule UUID" })
|
||||
@ApiQuery({
|
||||
@@ -42,6 +42,23 @@ export class SeatsController {
|
||||
description:
|
||||
"Filter by CoachType UUID — returns all coaches of that type (e.g. all Economy coaches)",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "journeyDirection",
|
||||
required: false,
|
||||
enum: ['ONE_WAY', 'OUTBOUND', 'RETURN'],
|
||||
description:
|
||||
"Journey direction for round-trip bookings. Filters seat holds to show only conflicting holds. Use OUTBOUND for outbound leg, RETURN for return leg. Defaults to ONE_WAY (shows all holds).",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "originStationId",
|
||||
required: false,
|
||||
description: "Origin station UUID for segment-specific seat availability",
|
||||
})
|
||||
@ApiQuery({
|
||||
name: "destinationStationId",
|
||||
required: false,
|
||||
description: "Destination station UUID for segment-specific seat availability",
|
||||
})
|
||||
@ApiResponse({
|
||||
status: 200,
|
||||
description:
|
||||
@@ -50,8 +67,17 @@ export class SeatsController {
|
||||
getSeatMap(
|
||||
@Param("scheduleId") scheduleId: string,
|
||||
@Query("coachTypeId") coachTypeId?: string,
|
||||
@Query("journeyDirection") journeyDirection?: string,
|
||||
@Query("originStationId") originStationId?: string,
|
||||
@Query("destinationStationId") destinationStationId?: string,
|
||||
) {
|
||||
return this.service.getSeatMap(scheduleId, coachTypeId);
|
||||
return this.service.getSeatMap(
|
||||
scheduleId,
|
||||
coachTypeId,
|
||||
journeyDirection as any,
|
||||
originStationId,
|
||||
destinationStationId
|
||||
);
|
||||
}
|
||||
|
||||
// ── Hold / Release ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { IsString, IsArray, ValidateNested } from 'class-validator';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsString, IsArray, ValidateNested, IsOptional, IsEnum } from 'class-validator';
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export enum JourneyDirection {
|
||||
ONE_WAY = 'ONE_WAY',
|
||||
OUTBOUND = 'OUTBOUND',
|
||||
RETURN = 'RETURN'
|
||||
}
|
||||
|
||||
export class PassengerSeatDto {
|
||||
@ApiProperty({ example: 'passenger-uuid', description: 'Passenger UUID' })
|
||||
@IsString() passengerId: string;
|
||||
@@ -20,6 +26,15 @@ export class HoldSeatsDto {
|
||||
@ApiProperty({ example: 'station-uuid', description: 'Destination station UUID for this leg' })
|
||||
@IsString() destinationStationId: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: JourneyDirection,
|
||||
example: JourneyDirection.OUTBOUND,
|
||||
description: 'Journey direction for round-trip bookings. ONE_WAY for single journeys, OUTBOUND/RETURN for round-trip legs. Allows same seats to be held for different directions.'
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(JourneyDirection)
|
||||
journeyDirection?: JourneyDirection;
|
||||
|
||||
@ApiProperty({
|
||||
type: [PassengerSeatDto],
|
||||
description: 'One entry per passenger. Each passenger is assigned exactly one seat. Duplicate passengerId or seatId within the same request is rejected.',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, ConflictException, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { HoldSeatsDto } from './seats.dto';
|
||||
import { HoldSeatsDto, JourneyDirection } from './seats.dto';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { SegmentsService } from '../segments/segments.service';
|
||||
import { SystemConfigService, CONFIG_KEYS } from '../system-config/system-config.service';
|
||||
@@ -13,7 +13,7 @@ export class SeatsService {
|
||||
private systemConfig: SystemConfigService,
|
||||
) {}
|
||||
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string) {
|
||||
async getSeatMap(scheduleId: string, coachTypeId?: string, journeyDirection?: JourneyDirection, originStationId?: string, destinationStationId?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
select: { originStationId: true, destinationStationId: true },
|
||||
@@ -37,7 +37,13 @@ export class SeatsService {
|
||||
});
|
||||
|
||||
const allSeatIds = assignments.flatMap((a: any) => a.coach.seats.map((s: any) => s.id));
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(scheduleId, allSeatIds, schedule.originStationId, schedule.destinationStationId);
|
||||
const effectiveStatuses = await this.resolveEffectiveStatuses(
|
||||
scheduleId,
|
||||
allSeatIds,
|
||||
originStationId ?? schedule.originStationId,
|
||||
destinationStationId ?? schedule.destinationStationId,
|
||||
journeyDirection
|
||||
);
|
||||
|
||||
return {
|
||||
coaches: assignments.map((a) => {
|
||||
@@ -179,6 +185,7 @@ export class SeatsService {
|
||||
seatIds: string[],
|
||||
originStationId?: string,
|
||||
destinationStationId?: string,
|
||||
journeyDirection?: JourneyDirection,
|
||||
): Promise<Map<string, string>> {
|
||||
const statusMap = new Map<string, string>();
|
||||
if (seatIds.length === 0) return statusMap;
|
||||
@@ -211,9 +218,13 @@ export class SeatsService {
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
const reqDirection = journeyDirection || JourneyDirection.ONE_WAY;
|
||||
|
||||
for (const hold of activeHolds) {
|
||||
let holdFrom: number | undefined;
|
||||
let holdTo: number | undefined;
|
||||
let holdDirection = JourneyDirection.ONE_WAY;
|
||||
|
||||
try {
|
||||
if (hold.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(hold.createdBy);
|
||||
@@ -221,16 +232,26 @@ export class SeatsService {
|
||||
const seqOf = (id: string) => stops.find(s => s.stationId === id)?.sequence;
|
||||
holdFrom = seqOf(meta.originStationId);
|
||||
holdTo = seqOf(meta.destinationStationId);
|
||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const seatId of hold.seatIds) {
|
||||
if (!seatIds.includes(seatId)) continue;
|
||||
if (reqFrom !== undefined && reqTo !== undefined && holdFrom !== undefined && holdTo !== undefined) {
|
||||
if (holdFrom < reqTo && reqFrom < holdTo) statusMap.set(seatId, 'HELD');
|
||||
} else {
|
||||
statusMap.set(seatId, 'HELD');
|
||||
}
|
||||
|
||||
// Check leg overlap
|
||||
const legsOverlap =
|
||||
reqFrom === undefined || reqTo === undefined ||
|
||||
holdFrom === undefined || holdTo === undefined ||
|
||||
(holdFrom < reqTo && reqFrom < holdTo);
|
||||
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
// Check direction conflict
|
||||
const directionsConflict = this.checkDirectionConflict(reqDirection, holdDirection);
|
||||
if (!directionsConflict) continue;
|
||||
|
||||
statusMap.set(seatId, 'HELD');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +287,36 @@ export class SeatsService {
|
||||
return statusMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two journey directions conflict (should not be allowed simultaneously)
|
||||
* For round-trip bookings: OUTBOUND and RETURN should NOT conflict on same schedule
|
||||
*/
|
||||
private checkDirectionConflict(current: JourneyDirection, existing: JourneyDirection): boolean {
|
||||
// OUTBOUND and RETURN are allowed simultaneously (round-trip on different schedules)
|
||||
if ((current === JourneyDirection.OUTBOUND && existing === JourneyDirection.RETURN) ||
|
||||
(current === JourneyDirection.RETURN && existing === JourneyDirection.OUTBOUND)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Same directions conflict (e.g., two OUTBOUND or two RETURN bookings)
|
||||
if (current === existing) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ONE_WAY conflicts with other ONE_WAY bookings only
|
||||
if (current === JourneyDirection.ONE_WAY && existing === JourneyDirection.ONE_WAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ONE_WAY with OUTBOUND/RETURN: conflict (to maintain safety for legacy bookings)
|
||||
if (current === JourneyDirection.ONE_WAY || existing === JourneyDirection.ONE_WAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Default: no conflict
|
||||
return false;
|
||||
}
|
||||
|
||||
async holdSeats(dto: HoldSeatsDto) {
|
||||
const passengerIds = dto.passengers.map(p => p.passengerId);
|
||||
const seatIds = dto.passengers.map(p => p.seatId);
|
||||
@@ -307,19 +358,16 @@ export class SeatsService {
|
||||
throw new NotFoundException(`Seat(s) not found: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED' || s.status === 'HELD');
|
||||
const blocked = seats.filter(s => s.status === 'BLOCKED' || s.status === 'BOOKED');
|
||||
if (blocked.length > 0)
|
||||
throw new ConflictException(`Seat(s) ${blocked.map(s => s.seatNumber).join(', ')} are already taken`);
|
||||
|
||||
const seatLabelById = Object.fromEntries(seats.map(s => [s.id, s.seatNumber]));
|
||||
|
||||
const stopTimes = await tx.tripStopTime.findMany({
|
||||
where: { scheduleId: dto.scheduleId },
|
||||
select: { stationId: true, sequence: true },
|
||||
});
|
||||
const seqOf = (stationId: string) =>
|
||||
stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||
|
||||
const seqOf = (stationId: string) => stopTimes.find(s => s.stationId === stationId)?.sequence;
|
||||
const reqFrom = seqOf(dto.originStationId);
|
||||
const reqTo = seqOf(dto.destinationStationId);
|
||||
|
||||
@@ -328,57 +376,46 @@ export class SeatsService {
|
||||
if (reqFrom >= reqTo)
|
||||
throw new BadRequestException('Origin must come before destination');
|
||||
|
||||
// ── Check existing holds for overlap ────────────────────────────────────
|
||||
const currentDirection = dto.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
const activeHolds = await tx.seatHold.findMany({
|
||||
where: { scheduleId: dto.scheduleId, expiresAt: { gt: new Date() } },
|
||||
select: { seatIds: true, createdBy: true },
|
||||
});
|
||||
|
||||
const parsedHolds: { seatIds: string[]; from: number; to: number; passengerIds: string[]; legUnknown: boolean }[] = [];
|
||||
for (const h of activeHolds) {
|
||||
const rawSeatIds = h.seatIds as string[];
|
||||
let holdDirection = JourneyDirection.ONE_WAY;
|
||||
let holdFrom = 0, holdTo = Number.MAX_SAFE_INTEGER;
|
||||
let passengerIds: string[] = [];
|
||||
let legUnknown = true;
|
||||
|
||||
try {
|
||||
if (h.createdBy?.trimStart().startsWith('{')) {
|
||||
const meta = JSON.parse(h.createdBy);
|
||||
const holdFrom = seqOf(meta.originStationId);
|
||||
const holdTo = seqOf(meta.destinationStationId);
|
||||
parsedHolds.push({
|
||||
seatIds: rawSeatIds,
|
||||
from: holdFrom ?? 0,
|
||||
to: holdTo ?? Number.MAX_SAFE_INTEGER,
|
||||
passengerIds: (meta.passengers ?? []).map((p: any) => p.passengerId),
|
||||
legUnknown: holdFrom === undefined || holdTo === undefined,
|
||||
});
|
||||
} else {
|
||||
// Legacy plain-string createdBy — can't determine leg; block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
holdFrom = seqOf(meta.originStationId) ?? 0;
|
||||
holdTo = seqOf(meta.destinationStationId) ?? Number.MAX_SAFE_INTEGER;
|
||||
holdDirection = meta.journeyDirection || JourneyDirection.ONE_WAY;
|
||||
passengerIds = (meta.passengers ?? []).map((p: any) => p.passengerId);
|
||||
legUnknown = !meta.originStationId || !meta.destinationStationId;
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON — block conservatively.
|
||||
parsedHolds.push({ seatIds: rawSeatIds, from: 0, to: Number.MAX_SAFE_INTEGER, passengerIds: [], legUnknown: true });
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
for (const hold of parsedHolds) {
|
||||
const legsOverlap = hold.legUnknown || (hold.from < reqTo && reqFrom < hold.to);
|
||||
if (!legsOverlap) continue;
|
||||
const legsOverlap = legUnknown || (holdFrom < reqTo && reqFrom < holdTo);
|
||||
if (!legsOverlap) continue;
|
||||
|
||||
if (hold.seatIds.includes(seatId)) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seatId]} is already held for this leg`,
|
||||
);
|
||||
const directionsConflict = this.checkDirectionConflict(currentDirection, holdDirection);
|
||||
if (!directionsConflict) continue;
|
||||
|
||||
for (const { passengerId, seatId } of dto.passengers) {
|
||||
if (rawSeatIds.includes(seatId)) {
|
||||
throw new ConflictException(`Seat ${seatLabelById[seatId]} is already held for this leg`);
|
||||
}
|
||||
|
||||
if (!hold.legUnknown && hold.passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(
|
||||
`Passenger already holds a seat on this journey leg`,
|
||||
);
|
||||
if (!legUnknown && passengerIds.includes(passengerId)) {
|
||||
throw new ConflictException(`Passenger already holds a seat on this journey leg`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Check confirmed JourneySegments for overlap ──────────────────────────
|
||||
const bookedSegments = await tx.journeySegment.findMany({
|
||||
where: {
|
||||
scheduleId: dto.scheduleId,
|
||||
@@ -392,25 +429,19 @@ export class SeatsService {
|
||||
if (!seg.seatId) continue;
|
||||
const segFrom = seqOf(seg.departureStationId);
|
||||
const segTo = seqOf(seg.arrivalStationId);
|
||||
// If stations can't be resolved, assume overlap (conservative) to prevent double-booking.
|
||||
const overlaps = (segFrom === undefined || segTo === undefined)
|
||||
? true
|
||||
: segFrom < reqTo && reqFrom < segTo;
|
||||
const overlaps = (segFrom === undefined || segTo === undefined) ? true : segFrom < reqTo && reqFrom < segTo;
|
||||
if (overlaps) {
|
||||
throw new ConflictException(
|
||||
`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`,
|
||||
);
|
||||
throw new ConflictException(`Seat ${seatLabelById[seg.seatId]} is already booked for this leg`);
|
||||
}
|
||||
}
|
||||
|
||||
const holdMeta = {
|
||||
originStationId: dto.originStationId,
|
||||
originStationId: dto.originStationId,
|
||||
destinationStationId: dto.destinationStationId,
|
||||
journeyDirection: currentDirection,
|
||||
passengers: dto.passengers.map(p => ({ passengerId: p.passengerId, seatId: p.seatId })),
|
||||
};
|
||||
|
||||
// Mark seats as HELD so the status check catches them immediately on any
|
||||
// subsequent hold attempt (avoids relying solely on the SeatHold table scan).
|
||||
await tx.seat.updateMany({
|
||||
where: { id: { in: seatIds } },
|
||||
data: { status: 'HELD' },
|
||||
@@ -418,10 +449,10 @@ export class SeatsService {
|
||||
|
||||
return tx.seatHold.create({
|
||||
data: {
|
||||
scheduleId: dto.scheduleId,
|
||||
scheduleId: dto.scheduleId,
|
||||
passengerId: dto.passengers[0].passengerId,
|
||||
seatIds,
|
||||
createdBy: JSON.stringify(holdMeta),
|
||||
createdBy: JSON.stringify(holdMeta),
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ export class CreateStationDto {
|
||||
@ApiProperty({ example: 'ADD' }) @IsString() code: string;
|
||||
@ApiProperty({ example: 'Addis Ababa' }) @IsString() name: string;
|
||||
@ApiProperty({ example: 'Addis Ababa' }) @IsString() city: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() timezone?: string;
|
||||
@ApiPropertyOptional() @IsOptional() @IsString() countryCode?: string;
|
||||
@ApiPropertyOptional({ example: 9.0054 }) @IsOptional() @IsNumber() lat?: number;
|
||||
@ApiPropertyOptional({ example: 38.7636 }) @IsOptional() @IsNumber() lng?: number;
|
||||
|
||||
@@ -68,8 +68,8 @@ export class StationsService {
|
||||
|
||||
async update(id: string, dto: Partial<CreateStationDto>) {
|
||||
const oldStation = await this.findOne(id);
|
||||
const { code, name, city, timezone, lat, lng } = dto;
|
||||
const data: any = { code, name, city, timezone, lat, lng };
|
||||
const { code, name, city, lat, lng } = dto;
|
||||
const data: any = { code, name, city, lat, lng };
|
||||
if ('countryCode' in dto) data.countryCode = (dto as any).countryCode;
|
||||
if ('sequence' in dto) data.sequence = (dto as any).sequence;
|
||||
if ('isOperational' in dto) data.isOperational = (dto as any).isOperational;
|
||||
|
||||
@@ -39,6 +39,7 @@ export class TicketsController {
|
||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||
@ApiQuery({ name: 'dateFrom', required: false })
|
||||
@ApiQuery({ name: 'dateTo', required: false })
|
||||
@ApiQuery({ name: 'coachId', required: false })
|
||||
@ApiQuery({ name: 'skip', required: false })
|
||||
@ApiQuery({ name: 'take', required: false })
|
||||
listTickets(
|
||||
@@ -49,6 +50,7 @@ export class TicketsController {
|
||||
@Query('arrivalDate') arrivalDate?: string,
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('dateTo') dateTo?: string,
|
||||
@Query('coachId') coachId?: string,
|
||||
@Query('skip') skip?: string,
|
||||
@Query('take') take?: string,
|
||||
) {
|
||||
@@ -60,6 +62,7 @@ export class TicketsController {
|
||||
arrivalDate,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
coachId,
|
||||
skip: skip ? parseInt(skip) : 0,
|
||||
take: take ? parseInt(take) : 50,
|
||||
});
|
||||
@@ -83,6 +86,31 @@ export class TicketsController {
|
||||
return this.service.getByRef(ref);
|
||||
}
|
||||
|
||||
@Post('scan-board/:qrCodeOrRef')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiOperation({
|
||||
summary: 'Scan QR code or booking ref and automatically board ticket',
|
||||
description: 'Scans ticket QR code or booking reference and automatically boards the passenger. Handles errors like expired tickets, already used tickets, etc. Designed for mobile boarding interface.'
|
||||
})
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['validatorId'],
|
||||
properties: {
|
||||
validatorId: { type: 'string', example: 'agent-uuid' },
|
||||
gateId: { type: 'string', example: 'gate-01' },
|
||||
},
|
||||
},
|
||||
})
|
||||
scanAndBoard(
|
||||
@Param('qrCodeOrRef') qrCodeOrRef: string,
|
||||
@Body('validatorId') validatorId: string,
|
||||
@Body('gateId') gateId?: string,
|
||||
) {
|
||||
return this.service.scanAndBoard(qrCodeOrRef, validatorId, gateId);
|
||||
}
|
||||
|
||||
@Post(':bookingRef/validate')
|
||||
@UseGuards(JwtGuard)
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
|
||||
@@ -23,12 +23,13 @@ export class TicketsService {
|
||||
@InjectDataSource() private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; skip: number; take: number }) {
|
||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||
const where: any = {};
|
||||
if (filters.search) {
|
||||
where.OR = [
|
||||
{ bookingRef: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ barcodePayload: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ passengerName: { contains: filters.search, mode: 'insensitive' } },
|
||||
{ booking: { bookingRef: { contains: filters.search, mode: 'insensitive' } } },
|
||||
];
|
||||
}
|
||||
@@ -53,6 +54,12 @@ export class TicketsService {
|
||||
...(filters.dateTo ? { lte: new Date(new Date(filters.dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
// if (filters.coachId) {
|
||||
// where.seat = {
|
||||
// coachId: filters.coachId
|
||||
// };
|
||||
// }
|
||||
|
||||
const [tickets, total] = await Promise.all([
|
||||
this.prisma.ticket.findMany({
|
||||
where,
|
||||
@@ -61,19 +68,20 @@ export class TicketsService {
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { select: { departureAt: true, arrivalAt: true, originStation: true, destinationStation: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
seat: { include: { coach: { include: { coachType: true } } } },
|
||||
} as any,
|
||||
skip: filters.skip,
|
||||
take: filters.take,
|
||||
orderBy: { issuedAt: 'desc' },
|
||||
}),
|
||||
}) as any,
|
||||
this.prisma.ticket.count({ where }),
|
||||
]);
|
||||
|
||||
const iamUserIds = tickets.map(t => t.booking.passenger?.iamUserId).filter(Boolean) as string[];
|
||||
const iamUserIds = tickets.map((t: any) => t.booking?.passenger?.iamUserId).filter(Boolean) as string[];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<{ id: string; email: string; name: any; phone_number: string | null }[]>(
|
||||
`SELECT id, email, name, phone_number FROM iam.users WHERE id = ANY($1)`,
|
||||
@@ -83,34 +91,49 @@ export class TicketsService {
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
|
||||
return {
|
||||
items: tickets.map((t) => {
|
||||
const iam = t.booking.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
||||
items: tickets.map((t: any) => {
|
||||
const iam = t.booking?.passenger?.iamUserId ? iamMap.get(t.booking.passenger.iamUserId) : undefined;
|
||||
const passengerInfo = iam
|
||||
? { fullName: iam.name?.en ?? iam.name?.am ?? null, email: iam.email, phone: iam.phone_number }
|
||||
: { fullName: 'Guest', email: t.booking.contactEmail, phone: null };
|
||||
: { fullName: 'Guest', email: t.booking?.contactEmail, phone: null };
|
||||
return {
|
||||
id: t.id,
|
||||
ticketNumber: t.barcodePayload,
|
||||
bookingRef: t.bookingRef,
|
||||
passengerName: t.passengerName,
|
||||
leg: t.leg,
|
||||
booking: {
|
||||
id: t.booking.id,
|
||||
bookingRef: t.booking.bookingRef,
|
||||
status: t.booking.status,
|
||||
bookingType: t.booking.bookingType,
|
||||
returnLegStatus: (t.booking as any).returnLegStatus ?? null,
|
||||
outboundBoardedAt: (t.booking as any).outboundBoardedAt ?? null,
|
||||
returnBoardedAt: (t.booking as any).returnBoardedAt ?? null,
|
||||
totalMinor: t.booking.totalMinor,
|
||||
currency: t.booking.currency,
|
||||
displayCurrency: t.booking.displayCurrency,
|
||||
displayTotalMinor: t.booking.displayTotalMinor,
|
||||
id: t.booking?.id,
|
||||
bookingRef: t.booking?.bookingRef,
|
||||
status: t.booking?.status,
|
||||
bookingType: t.booking?.bookingType,
|
||||
returnLegStatus: t.booking?.returnLegStatus ?? null,
|
||||
outboundBoardedAt: t.booking?.outboundBoardedAt ?? null,
|
||||
returnBoardedAt: t.booking?.returnBoardedAt ?? null,
|
||||
totalMinor: t.booking?.totalMinor,
|
||||
currency: t.booking?.currency,
|
||||
displayCurrency: t.booking?.displayCurrency,
|
||||
displayTotalMinor: t.booking?.displayTotalMinor,
|
||||
passenger: passengerInfo,
|
||||
contactEmail: t.booking.contactEmail,
|
||||
contactPhone: t.booking.contactPhone,
|
||||
returnSchedule: (t.booking as any).returnSchedule ?? null,
|
||||
contactEmail: t.booking?.contactEmail,
|
||||
contactPhone: t.booking?.contactPhone,
|
||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||
seats: t.booking?.seats ?? [],
|
||||
},
|
||||
schedule: t.booking.schedule,
|
||||
seat: t.booking.seats[0]?.seat,
|
||||
schedule: t.booking?.schedule,
|
||||
seat: t.seat ? {
|
||||
id: t.seat.id,
|
||||
seatNumber: t.seat.seatNumber,
|
||||
coach: t.seat.coach ? {
|
||||
id: t.seat.coach.id,
|
||||
number: t.seat.coach.number,
|
||||
coachType: t.seat.coach.coachType ? {
|
||||
id: t.seat.coach.coachType.id,
|
||||
name: t.seat.coach.coachType.name,
|
||||
type: t.seat.coach.coachType.type,
|
||||
} : null,
|
||||
} : null,
|
||||
} : null,
|
||||
status: t.status,
|
||||
validatedAt: t.validatedAt,
|
||||
createdAt: t.issuedAt,
|
||||
@@ -136,7 +159,7 @@ export class TicketsService {
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
|
||||
// No payment intent record at all
|
||||
if (!booking.paymentIntent) {
|
||||
if (!(booking as any).paymentIntent) {
|
||||
throw new HttpException(
|
||||
{ status: 'error', message: 'Payment not completed', code: 400 },
|
||||
HttpStatus.BAD_REQUEST,
|
||||
@@ -144,21 +167,19 @@ export class TicketsService {
|
||||
}
|
||||
|
||||
// Payment intent exists but not yet succeeded
|
||||
if (booking.paymentIntent.status !== 'SUCCEEDED') {
|
||||
if ((booking as any).paymentIntent.status !== 'SUCCEEDED') {
|
||||
throw new HttpException(
|
||||
{
|
||||
status: 'error',
|
||||
message: 'Payment not completed',
|
||||
code: 400,
|
||||
detail: `Payment status: ${booking.paymentIntent.status}`,
|
||||
detail: `Payment status: ${(booking as any).paymentIntent.status}`,
|
||||
},
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
|
||||
// Booking not in CONFIRMED state — could be a webhook delivery failure.
|
||||
// If the intent already SUCCEEDED but the booking is still PENDING_PAYMENT,
|
||||
// self-heal here rather than rejecting a legitimately paid booking.
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
if (booking.status === 'PENDING_PAYMENT') {
|
||||
this.logger.warn(
|
||||
@@ -181,154 +202,282 @@ export class TicketsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Build a compact multi-leg payload for the QR so gate scanners see all legs
|
||||
const legSummary = this.buildLegSummary(booking);
|
||||
const qrData = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
type: booking.bookingType,
|
||||
legs: legSummary,
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `${booking.bookingRef}${booking.id.substring(0, 8).toUpperCase()}`;
|
||||
// Delete existing tickets if any
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
const ticket = await this.prisma.ticket.upsert({
|
||||
where: { bookingId },
|
||||
update: { qrPayload, barcodePayload },
|
||||
create: { bookingId, bookingRef: booking.bookingRef, qrPayload, barcodePayload },
|
||||
});
|
||||
// Generate one ticket per unique passenger (grouped by passengerName)
|
||||
const tickets = [];
|
||||
const seatIds = (booking as any).seats.map((bs: any) => bs.seatId);
|
||||
|
||||
// Group seats by passenger
|
||||
const passengerSeatsMap = new Map<string, any[]>();
|
||||
for (const bookingSeat of (booking as any).seats) {
|
||||
const key = bookingSeat.passengerName;
|
||||
if (!passengerSeatsMap.has(key)) {
|
||||
passengerSeatsMap.set(key, []);
|
||||
}
|
||||
passengerSeatsMap.get(key)!.push(bookingSeat);
|
||||
}
|
||||
|
||||
// Create one ticket per passenger
|
||||
for (const [passengerName, passengerSeats] of passengerSeatsMap.entries()) {
|
||||
// Use first seat for primary data
|
||||
const primarySeat = passengerSeats[0];
|
||||
|
||||
// Build passenger QR data with all legs included
|
||||
const qrData = JSON.stringify({
|
||||
ref: booking.bookingRef,
|
||||
type: booking.bookingType,
|
||||
passenger: passengerName,
|
||||
seats: passengerSeats.map(ps => ({
|
||||
seat: ps.seat.seatNumber,
|
||||
coach: ps.seat.coach.number,
|
||||
leg: ps.leg || 1,
|
||||
scheduleId: ps.scheduleId || booking.scheduleId,
|
||||
})),
|
||||
});
|
||||
const qrPayload = await QRCode.toDataURL(qrData);
|
||||
const barcodePayload = `${booking.bookingRef}${primarySeat.seatId.substring(0, 8).toUpperCase()}`;
|
||||
|
||||
const ticket = await this.prisma.ticket.create({
|
||||
data: {
|
||||
bookingId,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName,
|
||||
seatId: primarySeat.seatId,
|
||||
leg: primarySeat.leg || 1,
|
||||
scheduleId: primarySeat.scheduleId || booking.scheduleId,
|
||||
qrPayload,
|
||||
barcodePayload,
|
||||
} as any,
|
||||
});
|
||||
tickets.push(ticket);
|
||||
}
|
||||
|
||||
// Block all seats across all legs
|
||||
const seatIds = booking.seats.map(bs => bs.seatId);
|
||||
for (const seatId of seatIds) {
|
||||
await this.prisma.seat.update({ where: { id: seatId }, data: { status: 'BOOKED' } });
|
||||
await this.prisma.seatBlock.create({
|
||||
data: { seatId, reason: `Booked in ticket ${ticket.id}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
|
||||
data: { seatId, reason: `Booked in tickets ${tickets.map(t => t.id).join(', ')}`, blockedBy: 'SYSTEM', approvedBy: 'SYSTEM' },
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
return { ...ticket, legs: legSummary };
|
||||
return { tickets, totalTickets: tickets.length };
|
||||
}
|
||||
|
||||
private buildLegSummary(booking: any) {
|
||||
const seatsByLeg = new Map<number, any[]>();
|
||||
for (const bs of booking.seats) {
|
||||
const leg = bs.leg ?? 1;
|
||||
if (!seatsByLeg.has(leg)) seatsByLeg.set(leg, []);
|
||||
seatsByLeg.get(leg)!.push(bs);
|
||||
}
|
||||
return Array.from(seatsByLeg.entries())
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([leg, seats]) => ({
|
||||
leg,
|
||||
scheduleId: (seats[0] as any).scheduleId ?? booking.scheduleId,
|
||||
passengers: seats.map(bs => ({
|
||||
name: bs.passengerName,
|
||||
category: bs.passengerCategory,
|
||||
coach: bs.seat?.coach?.number,
|
||||
seat: bs.seat?.seatNumber,
|
||||
fareMinor: bs.fareMinor,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
async updateSeats(bookingId: string, newSeatIds: string[]) {
|
||||
async updateSeats(bookingId: string, seatIds: string[]) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: { seats: true, ticket: true },
|
||||
include: { tickets: true, seats: true } as any
|
||||
});
|
||||
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
|
||||
if (!booking.ticket) throw new BadRequestException('No ticket found for this booking');
|
||||
|
||||
// Remove old seat blocks
|
||||
const oldSeatIds = booking.seats.map(bs => bs.seatId);
|
||||
for (const seatId of oldSeatIds) {
|
||||
for (const ticket of (booking as any).tickets) {
|
||||
await this.prisma.seatBlock.deleteMany({
|
||||
where: {
|
||||
seatId,
|
||||
reason: { contains: booking.ticket.id }
|
||||
}
|
||||
where: { reason: { contains: ticket.id } }
|
||||
});
|
||||
}
|
||||
|
||||
// Remove old booking seats
|
||||
// Delete existing tickets
|
||||
await this.prisma.ticket.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Update booking seats
|
||||
await this.prisma.bookingSeat.deleteMany({ where: { bookingId } });
|
||||
|
||||
// Create new seat blocks
|
||||
for (const seatId of newSeatIds) {
|
||||
await this.prisma.seatBlock.create({
|
||||
data: {
|
||||
seatId,
|
||||
reason: `Permanently booked in ticket ${booking.ticket.id}`,
|
||||
blockedBy: 'SYSTEM',
|
||||
approvedBy: 'SYSTEM',
|
||||
}
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
// Create new booking seats (placeholder with minimal data)
|
||||
for (let i = 0; i < newSeatIds.length; i++) {
|
||||
|
||||
// Create new seat assignments (simplified)
|
||||
for (let i = 0; i < seatIds.length; i++) {
|
||||
await this.prisma.bookingSeat.create({
|
||||
data: {
|
||||
bookingId,
|
||||
seatId: newSeatIds[i],
|
||||
seatId: seatIds[i],
|
||||
passengerName: `Passenger ${i + 1}`,
|
||||
}
|
||||
leg: 1
|
||||
} as any
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, updatedSeats: newSeatIds.length };
|
||||
// Generate new tickets
|
||||
return this.generate(bookingId);
|
||||
}
|
||||
|
||||
async getByMerchantOrderId(merchantOrderId: string) {
|
||||
const intent = await this.prisma.paymentIntent.findUnique({
|
||||
const paymentIntent = await this.prisma.paymentIntent.findUnique({
|
||||
where: { merchantOrderId },
|
||||
select: { bookingId: true },
|
||||
include: { booking: { include: { tickets: true } as any } } as any
|
||||
});
|
||||
if (!intent) throw new NotFoundException(`No payment intent found for order ${merchantOrderId}`);
|
||||
if (!paymentIntent) throw new NotFoundException('Payment not found');
|
||||
|
||||
const booking = (paymentIntent as any).booking;
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
return this.getByRef(booking.bookingRef);
|
||||
}
|
||||
|
||||
async getByRef(ref: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: intent.bookingId },
|
||||
include: { schedule: { include: { originStation: true, destinationStation: true, train: true } }, seats: { include: { seat: { include: { coach: true } } } }, ticket: true },
|
||||
where: { bookingRef: ref },
|
||||
include: {
|
||||
tickets: true,
|
||||
schedule: { include: { originStation: true, destinationStation: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } }
|
||||
} as any
|
||||
});
|
||||
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
|
||||
const seat = booking.seats[0];
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
|
||||
return {
|
||||
id: booking.ticket.id, bookingId: booking.id, bookingRef: booking.bookingRef, status: booking.status,
|
||||
fromStationName: booking.schedule.originStation.name, toStationName: booking.schedule.destinationStation.name,
|
||||
departureAt: booking.schedule.departureAt, trainName: booking.schedule.train.name,
|
||||
coachLabel: seat?.seat.coach.number, seatLabel: seat?.seat.seatNumber, passengerName: seat?.passengerName,
|
||||
priceMinor: booking.totalMinor, currency: booking.currency, qrPayload: booking.ticket.qrPayload,
|
||||
barcodePayload: booking.ticket.barcodePayload,
|
||||
booking: {
|
||||
id: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
bookingType: booking.bookingType,
|
||||
totalMinor: booking.totalMinor,
|
||||
currency: booking.currency
|
||||
},
|
||||
tickets: (booking as any).tickets,
|
||||
schedule: (booking as any).schedule,
|
||||
returnSchedule: (booking as any).returnSchedule,
|
||||
seats: (booking as any).seats
|
||||
};
|
||||
}
|
||||
|
||||
async getByRef(bookingRef: string) {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
ticket: true
|
||||
},
|
||||
});
|
||||
if (!booking?.ticket) throw new NotFoundException('Ticket not found');
|
||||
const seat = booking.seats[0];
|
||||
return {
|
||||
id: booking.ticket.id,
|
||||
bookingId: booking.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
status: booking.status,
|
||||
fromStationName: booking.schedule.originStation.name,
|
||||
toStationName: booking.schedule.destinationStation.name,
|
||||
departureAt: booking.schedule.departureAt,
|
||||
trainName: booking.schedule.train.name,
|
||||
coachLabel: seat?.seat.coach.number,
|
||||
seatLabel: seat?.seat.seatNumber,
|
||||
passengerName: seat?.passengerName,
|
||||
priceMinor: booking.totalMinor,
|
||||
currency: booking.currency,
|
||||
qrPayload: booking.ticket.qrPayload,
|
||||
barcodePayload: booking.ticket.barcodePayload
|
||||
};
|
||||
async scanAndBoard(qrCodeOrRef: string, validatorId: string, gateId?: string) {
|
||||
try {
|
||||
// Extract booking reference from QR code if it's JSON
|
||||
let bookingRef = qrCodeOrRef;
|
||||
try {
|
||||
const qrData = JSON.parse(qrCodeOrRef);
|
||||
if (qrData.ref) {
|
||||
bookingRef = qrData.ref;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, treat as booking reference
|
||||
}
|
||||
|
||||
// Get booking and ticket info
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { bookingRef },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||
tickets: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new NotFoundException('Ticket not found');
|
||||
}
|
||||
|
||||
if (booking.status !== 'CONFIRMED') {
|
||||
throw new BadRequestException('Ticket is not confirmed');
|
||||
}
|
||||
|
||||
const ticket = (booking as any).tickets[0];
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('No ticket found for this booking');
|
||||
}
|
||||
|
||||
// Check if ticket date matches today
|
||||
const today = new Date();
|
||||
const todayDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD format
|
||||
|
||||
if ((booking as any).schedule?.departureAt) {
|
||||
const departureDate = new Date((booking as any).schedule.departureAt);
|
||||
const departureDateStr = departureDate.toISOString().split('T')[0];
|
||||
|
||||
// Check if ticket is for today
|
||||
if (departureDateStr !== todayDateStr) {
|
||||
if (departureDateStr < todayDateStr) {
|
||||
throw new BadRequestException('Ticket has expired - departure date has passed');
|
||||
} else {
|
||||
throw new BadRequestException('Ticket is for a future date - cannot board early');
|
||||
}
|
||||
}
|
||||
|
||||
// Additional check: ticket expires 4 hours after departure time
|
||||
const departureTime = new Date((booking as any).schedule.departureAt);
|
||||
const expiryTime = new Date(departureTime.getTime() + 4 * 60 * 60 * 1000); // 4 hours after departure
|
||||
if (today > expiryTime) {
|
||||
throw new BadRequestException('Ticket has expired - boarding window closed');
|
||||
}
|
||||
}
|
||||
|
||||
// Use existing validation logic to handle round trips properly
|
||||
const result = await this.validate(bookingRef, validatorId, gateId);
|
||||
|
||||
// Get seat information
|
||||
const seatInfo = (booking as any).seats[0];
|
||||
const seatNumber = seatInfo?.seat?.seatNumber || 'N/A';
|
||||
const coachNumber = seatInfo?.seat?.coach?.number || 'N/A';
|
||||
|
||||
// Send notifications after successful boarding
|
||||
await this.sendBoardingNotifications(booking, ticket, result.leg || 'OUTBOUND');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Passenger boarded successfully (${result.leg || 'OUTBOUND'} leg)`,
|
||||
boarding: {
|
||||
ticketId: ticket.id,
|
||||
bookingRef: booking.bookingRef,
|
||||
passengerName: seatInfo?.passengerName || ticket.passengerName || 'N/A',
|
||||
route: `${(booking as any).schedule?.originStation?.name || 'N/A'} → ${(booking as any).schedule?.destinationStation?.name || 'N/A'}`,
|
||||
seat: seatNumber,
|
||||
coach: coachNumber,
|
||||
trainName: (booking as any).schedule?.train?.name || (booking as any).schedule?.train?.number || 'N/A',
|
||||
departureTime: (booking as any).schedule?.departureAt,
|
||||
boardedAt: result.validatedAt,
|
||||
leg: result.leg || 'OUTBOUND',
|
||||
bookingType: booking.bookingType,
|
||||
isRoundTrip: booking.bookingType === 'ROUND_TRIP' || booking.bookingType === 'ROUND_TRIP_TRANSIT',
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// Return structured error for the UI
|
||||
const errorMessage = error instanceof Error ? error.message : 'Boarding failed';
|
||||
const errorCode = error instanceof BadRequestException ? 'VALIDATION_ERROR'
|
||||
: error instanceof NotFoundException ? 'NOT_FOUND'
|
||||
: 'SYSTEM_ERROR';
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
errorCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async sendBoardingNotifications(booking: any, ticket: any, leg: string) {
|
||||
try {
|
||||
const passengerName = booking.seats?.[0]?.passengerName || ticket.passengerName || 'Passenger';
|
||||
const contactEmail = booking.contactEmail;
|
||||
const contactPhone = booking.contactPhone;
|
||||
|
||||
if (!contactEmail && !contactPhone) {
|
||||
this.logger.warn(`No contact details found for booking ${booking.bookingRef}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const routeInfo = `${booking.schedule?.originStation?.name} → ${booking.schedule?.destinationStation?.name}`;
|
||||
const trainName = booking.schedule?.train?.name || booking.schedule?.train?.number;
|
||||
const departureTime = booking.schedule?.departureAt ? new Date(booking.schedule.departureAt).toLocaleString() : 'N/A';
|
||||
const legText = leg === 'RETURN' ? 'Return' : 'Outbound';
|
||||
|
||||
// Use the existing sendBoardingPassNotification method
|
||||
await this.notifications.sendBoardingPassNotification({
|
||||
passengerId: booking.passengerId || null,
|
||||
contactEmail,
|
||||
contactPhone,
|
||||
bookingRef: booking.bookingRef,
|
||||
leg,
|
||||
booking,
|
||||
ticket,
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
this.logger.error('Error sending boarding notifications:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async validate(ticketIdOrRef: string, validatorId: string, gateId?: string, leg?: string) {
|
||||
@@ -343,11 +492,11 @@ export class TicketsService {
|
||||
const resolvedValidatorId = validatorId || 'BACKOFFICE';
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef } });
|
||||
if (!booking) throw new NotFoundException('Booking not found');
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
|
||||
const ticket = await this.prisma.ticket.findFirst({ where: { bookingId: booking.id } });
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
|
||||
const type = booking.bookingType;
|
||||
const now = new Date();
|
||||
const now = new Date();
|
||||
|
||||
// ── ONE_WAY / TRANSIT (single scan) ───────────────────────────────────
|
||||
if (type === 'ONE_WAY') {
|
||||
@@ -389,92 +538,34 @@ export class TicketsService {
|
||||
if (resolvedLeg === 'OUTBOUND') {
|
||||
if ((booking as any).outboundBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'OUTBOUND_ALREADY_USED' } as any });
|
||||
throw new BadRequestException('Outbound leg already used');
|
||||
throw new BadRequestException('Outbound leg already validated');
|
||||
}
|
||||
bookingData.outboundBoardedAt = now;
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
} else if (resolvedLeg === 'RETURN') {
|
||||
if ((booking as any).returnBoardedAt) {
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'REJECTED', reason: 'RETURN_ALREADY_USED' } as any });
|
||||
throw new BadRequestException('Return leg already used');
|
||||
throw new BadRequestException('Return leg already validated');
|
||||
}
|
||||
bookingData.returnBoardedAt = now;
|
||||
} else {
|
||||
throw new BadRequestException('For ROUND_TRIP bookings supply leg=OUTBOUND or leg=RETURN');
|
||||
}
|
||||
const outboundUsed = resolvedLeg === 'OUTBOUND' ? true : !!(booking as any).outboundBoardedAt;
|
||||
const returnUsed = resolvedLeg === 'RETURN' ? true : !!(booking as any).returnBoardedAt;
|
||||
if (outboundUsed && returnUsed) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (outboundUsed && !returnUsed) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!outboundUsed && returnUsed) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
if (!ticket.validatedAt) {
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
}
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// ── ROUND_TRIP_TRANSIT — leg=OUTBOUND_LEG1|OUTBOUND_LEG2|RETURN_LEG1|RETURN_LEG2
|
||||
if (type === 'ROUND_TRIP_TRANSIT') {
|
||||
const validLegs = ['OUTBOUND_LEG1', 'OUTBOUND_LEG2', 'RETURN_LEG1', 'RETURN_LEG2'];
|
||||
const resolvedLeg = (leg ?? '').toUpperCase();
|
||||
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' } }) 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`);
|
||||
}
|
||||
const bookingData: Record<string, any> = {};
|
||||
if (resolvedLeg.startsWith('OUTBOUND') && !logs.some(l => l.leg?.startsWith('OUTBOUND') && l.status === 'APPROVED')) {
|
||||
bookingData.outboundBoardedAt = now;
|
||||
}
|
||||
if (resolvedLeg.startsWith('RETURN') && !logs.some(l => l.leg?.startsWith('RETURN') && l.status === 'APPROVED')) {
|
||||
bookingData.returnBoardedAt = now;
|
||||
}
|
||||
const allOutboundDone = ['OUTBOUND_LEG1','OUTBOUND_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
|
||||
const allReturnDone = ['RETURN_LEG1','RETURN_LEG2'].every(l => l === resolvedLeg || logs.some(x => x.leg === l && x.status === 'APPROVED'));
|
||||
if (allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'BOTH_USED';
|
||||
else if (allOutboundDone && !allReturnDone) bookingData.returnLegStatus = 'OUTBOUND_ONLY';
|
||||
else if (!allOutboundDone && allReturnDone) bookingData.returnLegStatus = 'INBOUND_ONLY';
|
||||
if (Object.keys(bookingData).length) await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
if (!ticket.validatedAt) await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, leg: resolvedLeg, status: 'APPROVED' } as any });
|
||||
this.fireBoardingPassNotification(booking, ticket, resolvedLeg);
|
||||
return { validated: true, ticketId: ticket.id, leg: resolvedLeg, validatedAt: now };
|
||||
}
|
||||
|
||||
// Fallback for unknown booking types — single scan
|
||||
if (ticket.validatedAt) {
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: ticket.validatedAt, alreadyValidated: true };
|
||||
}
|
||||
await this.prisma.ticket.update({ where: { id: ticket.id }, data: { validatedAt: now, validatorId: resolvedValidatorId } });
|
||||
await this.prisma.gateValidationLog.create({ data: { ticketId: ticket.id, validatorId: resolvedValidatorId, gateId, status: 'APPROVED' } });
|
||||
this.fireBoardingPassNotification(booking, ticket, null);
|
||||
return { validated: true, ticketId: ticket.id, validatedAt: now };
|
||||
throw new BadRequestException(`Unsupported booking type: ${type}`);
|
||||
}
|
||||
|
||||
/** Fire-and-forget — enriches booking with schedule+seats then sends email+SMS boarding pass. */
|
||||
private fireBoardingPassNotification(booking: any, ticket: any, leg: string | null): void {
|
||||
this.prisma.booking.findUnique({
|
||||
where: { id: booking.id },
|
||||
include: {
|
||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
||||
seats: { include: { seat: { include: { coach: { include: { coachType: true } } } } } },
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
},
|
||||
}).then((enriched) => {
|
||||
if (!enriched) return;
|
||||
this.notifications.sendBoardingPassNotification({
|
||||
passengerId: enriched.passenger?.iamUserId ?? enriched.passenger?.id ?? null,
|
||||
contactEmail: (enriched as any).contactEmail ?? null,
|
||||
contactPhone: (enriched as any).contactPhone ?? null,
|
||||
bookingRef: enriched.bookingRef,
|
||||
leg,
|
||||
booking: enriched,
|
||||
ticket,
|
||||
}).catch(() => null);
|
||||
}).catch(() => null);
|
||||
private async fireBoardingPassNotification(booking: any, ticket: any, leg: string | null) {
|
||||
// TODO: Implement notification logic
|
||||
console.log(`Boarding pass notification for booking ${booking.bookingRef}, leg: ${leg}`);
|
||||
}
|
||||
|
||||
async getValidationLogs(ticketId: string) {
|
||||
@@ -488,111 +579,57 @@ export class TicketsService {
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: { scheduleId: tripId, status: 'CONFIRMED' },
|
||||
include: {
|
||||
ticket: true,
|
||||
tickets: true,
|
||||
seats: { include: { seat: { include: { coach: true } } } },
|
||||
passenger: { select: { id: true, iamUserId: true } },
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
|
||||
return bookings.map((b) => ({
|
||||
bookingRef: b.bookingRef,
|
||||
ticketId: b.ticket?.id,
|
||||
passengerName: b.seats[0]?.passengerName,
|
||||
seatLabel: b.seats[0]?.seat.seatNumber,
|
||||
coachLabel: b.seats[0]?.seat.coach.number,
|
||||
qrPayload: b.ticket?.qrPayload,
|
||||
ticketId: (b as any).tickets?.[0]?.id,
|
||||
passengerName: (b as any).seats[0]?.passengerName,
|
||||
seatLabel: (b as any).seats[0]?.seat.seatNumber,
|
||||
coachLabel: (b as any).seats[0]?.seat.coach.number,
|
||||
qrPayload: (b as any).tickets?.[0]?.qrPayload,
|
||||
status: b.status,
|
||||
bookingType: b.bookingType,
|
||||
returnLegStatus: (b as any).returnLegStatus ?? null,
|
||||
validatedAt: b.ticket?.validatedAt,
|
||||
validatedAt: (b as any).tickets?.[0]?.validatedAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async validateOfflineBatch(validations: OfflineValidation[]) {
|
||||
const results = { success: 0, failed: 0, duplicate: 0, errors: [] as string[] };
|
||||
const processedRefs = new Set<string>();
|
||||
|
||||
for (const v of validations) {
|
||||
const offlineLeg = v.leg;
|
||||
const dedupKey = offlineLeg ? `${v.bookingRef}:${offlineLeg}` : v.bookingRef;
|
||||
if (processedRefs.has(dedupKey)) {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
processedRefs.add(dedupKey);
|
||||
|
||||
const results = [];
|
||||
|
||||
for (const validation of validations) {
|
||||
try {
|
||||
const booking = await this.prisma.booking.findUnique({ where: { bookingRef: v.bookingRef } });
|
||||
if (!booking) {
|
||||
results.failed++;
|
||||
results.errors.push(`Booking ${v.bookingRef} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ticket = await this.prisma.ticket.findUnique({ where: { bookingId: booking.id } });
|
||||
if (!ticket) {
|
||||
results.failed++;
|
||||
results.errors.push(`Ticket for ${v.bookingRef} not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ticket.validatedAt && booking.bookingType !== 'ROUND_TRIP' &&
|
||||
booking.bookingType !== 'TRANSIT' && booking.bookingType !== 'ROUND_TRIP_TRANSIT') {
|
||||
results.duplicate++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For multi-leg bookings, check per-leg duplication
|
||||
const isMultiLeg = booking.bookingType === 'ROUND_TRIP' ||
|
||||
booking.bookingType === 'TRANSIT' ||
|
||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isMultiLeg && offlineLeg) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
await this.prisma.ticket.update({
|
||||
where: { id: ticket.id },
|
||||
data: { validatedAt: new Date(v.validatedAt), validatorId: v.validatorId },
|
||||
const result = await this.validate(
|
||||
validation.bookingRef,
|
||||
validation.validatorId,
|
||||
validation.gateId,
|
||||
validation.leg
|
||||
);
|
||||
results.push({
|
||||
bookingRef: validation.bookingRef,
|
||||
success: true,
|
||||
result
|
||||
});
|
||||
|
||||
await this.prisma.gateValidationLog.create({
|
||||
data: {
|
||||
ticketId: ticket.id,
|
||||
validatorId: v.validatorId,
|
||||
gateId: v.gateId,
|
||||
leg: v.leg ?? null,
|
||||
status: 'APPROVED',
|
||||
validatedAt: new Date(v.validatedAt),
|
||||
} as any,
|
||||
} catch (error) {
|
||||
results.push({
|
||||
bookingRef: validation.bookingRef,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Validation failed'
|
||||
});
|
||||
|
||||
// update boarding timestamps for multi-leg bookings
|
||||
const isMultiLegBooking = booking.bookingType === 'ROUND_TRIP' ||
|
||||
booking.bookingType === 'TRANSIT' ||
|
||||
booking.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||
if (isMultiLegBooking && offlineLeg) {
|
||||
const bookingData: Record<string, any> = {};
|
||||
const isOutbound = (offlineLeg as string) === 'OUTBOUND' || (offlineLeg as string) === 'OUTBOUND_LEG1' || (offlineLeg as string) === 'LEG1';
|
||||
const isReturn = (offlineLeg as string) === 'RETURN' || (offlineLeg as string) === 'RETURN_LEG1' || (offlineLeg as string) === 'RETURN_LEG2';
|
||||
if (isOutbound && !(booking as any).outboundBoardedAt) bookingData.outboundBoardedAt = new Date(v.validatedAt);
|
||||
if (isReturn && !(booking as any).returnBoardedAt) bookingData.returnBoardedAt = new Date(v.validatedAt);
|
||||
if (Object.keys(bookingData).length) {
|
||||
await this.prisma.booking.update({ where: { id: booking.id }, data: bookingData });
|
||||
}
|
||||
}
|
||||
|
||||
results.success++;
|
||||
} catch (err) {
|
||||
results.failed++;
|
||||
results.errors.push(`Error processing ${v.bookingRef}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
|
||||
return {
|
||||
processed: results.length,
|
||||
successful: results.filter(r => r.success).length,
|
||||
failed: results.filter(r => !r.success).length,
|
||||
results
|
||||
};
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
@@ -618,4 +655,4 @@ export class TicketsService {
|
||||
if (!ticket) throw new NotFoundException('Ticket not found');
|
||||
return this.prisma.ticket.update({ where: { id }, data: { status: 'ACTIVE' } });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,14 @@ The Passenger Backoffice Application is a comprehensive management system for th
|
||||
- **Live Tracking**: Monitor trip status and real-time updates
|
||||
- **Security Monitoring**: Fraud detection and audit logging
|
||||
- **Comprehensive Analytics**: Revenue, occupancy, and performance reports
|
||||
- **🆕 Excess Baggage Management**: Handle boarding baggage charges with agent tools
|
||||
- **🆕 Travel Packages**: Manage pilgrimage and group travel packages with tiered pricing
|
||||
- **🆕 System Health Monitoring**: Real-time API health checks and system status
|
||||
- **🆕 Advanced Fare Configuration**: Dynamic pricing with segment-based rules
|
||||
- **🆕 Boarding Management**: Gate operations and passenger processing
|
||||
- **🆕 Payment Methods Configuration**: Multi-provider payment setup
|
||||
- **🆕 Package Inquiries**: Lead management for travel package bookings
|
||||
- **🆕 Centralized Configuration**: Feature flags and operational controls
|
||||
|
||||
### Supported Roles
|
||||
- **Agent**: Counter booking and basic operations
|
||||
@@ -83,7 +91,16 @@ The application is organized into 8 main sections:
|
||||
└── System
|
||||
├── Agent Operations
|
||||
├── User Management
|
||||
├── System Config
|
||||
└── Settings
|
||||
└── Enhanced Features
|
||||
├── Excess Baggage
|
||||
├── Travel Packages
|
||||
├── Package Inquiries
|
||||
├── Health Monitoring
|
||||
├── Boarding Management
|
||||
├── Advanced Fare Config
|
||||
└── Payment Methods
|
||||
```
|
||||
|
||||
### Theme & Personalization
|
||||
@@ -2843,14 +2860,21 @@ Action: Block user
|
||||
|
||||
## Change Log
|
||||
|
||||
### Version 1.0.0 (June 15, 2026)
|
||||
- Initial release
|
||||
- All core modules implemented
|
||||
- Multi-currency support added
|
||||
- Verifayda 2.0 integration complete
|
||||
- Premium and insurance fees added to fares
|
||||
- Age-based pricing fully functional
|
||||
- Segment fare rules implemented
|
||||
### Version 1.0.0 (January 15, 2026)
|
||||
- **Complete Platform Release** - Full-featured passenger management system
|
||||
- **Excess Baggage Management** - Complete boarding baggage handling with agent tools and passenger self-pay options
|
||||
- **Travel Packages** - Pilgrimage and group travel packages with tiered pricing, capacity management, and inquiry handling
|
||||
- **System Health Monitoring** - Real-time API health checks with liveness, readiness, and performance metrics
|
||||
- **System Configuration** - Centralized config management with feature flags, rate limiting, and operational controls
|
||||
- **Package Inquiries** - Dedicated management for package booking inquiries with status tracking
|
||||
- **Boarding Management** - Gate operations, passenger processing, and boarding workflow tools
|
||||
- **Advanced Fare Configuration** - Dynamic fare management with complex pricing rules and segment-based pricing
|
||||
- **Payment Methods Configuration** - Multi-provider payment setup and management interface
|
||||
- **Enhanced Settings** - Improved settings interface with tabbed sections and live configuration updates
|
||||
- **Multi-currency support** - ETB, DJF, USD with real-time exchange rates
|
||||
- **Verifayda 2.0 integration** - Ethiopian national ID verification
|
||||
- **Age-based pricing** - ADULT/CHILD categories with free first child policy
|
||||
- **Segment fare rules** - Complex pricing with nationality-specific rates
|
||||
|
||||
---
|
||||
|
||||
@@ -2892,6 +2916,902 @@ Action: Block user
|
||||
|
||||
**For more information or feedback, please contact the development team or visit the support portal.**
|
||||
|
||||
**Last Updated**: January 15, 2026
|
||||
**Document Version**: 1.0.0
|
||||
**Maintained By**: EDR Development Team
|
||||
---
|
||||
|
||||
## Enhanced Features
|
||||
|
||||
### Excess Baggage
|
||||
|
||||
**Purpose**: Manage excess baggage charges at boarding with agent tools and passenger self-pay
|
||||
**Access Level**: Agent, Supervisor, Admin
|
||||
**Icon**: Package
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ EXCESS BAGGAGE MANAGEMENT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ View Excess Charges │
|
||||
│ ✓ Search by Booking Ref │
|
||||
│ ✓ Track Payment Status │
|
||||
│ ✓ Waive Charges │
|
||||
│ ✓ Resend Payment Links │
|
||||
│ ✓ Agent Cash Collection │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Excess Baggage Process
|
||||
|
||||
1. **At Boarding**: Agent weighs passenger baggage
|
||||
2. **If Excess**: Agent creates charge in system
|
||||
3. **Payment Options**:
|
||||
- Passenger self-pay via mobile link
|
||||
- Agent collects cash on-the-spot
|
||||
4. **Completion**: Passenger boards after payment
|
||||
|
||||
#### Charge Statuses
|
||||
|
||||
- **PENDING**: Awaiting passenger payment (5-minute link expiry)
|
||||
- **PAID**: Successfully paid via mobile payment
|
||||
- **CASH_COLLECTED**: Agent collected cash payment
|
||||
- **EXPIRED**: Payment link expired
|
||||
- **WAIVED**: Supervisor waived the charge
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### READ (List Charges)
|
||||
|
||||
1. **Access Excess Baggage Page**:
|
||||
- Click **Excess Baggage** in Enhanced Features section
|
||||
- Shows all baggage charges
|
||||
|
||||
2. **Search & Filter**:
|
||||
- **Search Box**: Filter by booking reference
|
||||
- **Status Filter**: PENDING, PAID, CASH_COLLECTED, EXPIRED, WAIVED
|
||||
- **Date Range**: Filter by creation date
|
||||
|
||||
3. **Charge Information**:
|
||||
- Booking Reference
|
||||
- Excess Weight (kg)
|
||||
- Fee per kg
|
||||
- Total Amount (ETB)
|
||||
- Payment Status
|
||||
- Contact Information
|
||||
- Expiry Time
|
||||
|
||||
##### MANAGE CHARGES
|
||||
|
||||
**Resend Payment Link**:
|
||||
1. **For PENDING charges**: Click "Resend Link"
|
||||
2. **New SMS/Email**: Sent to passenger
|
||||
3. **Fresh 5-minute**: New expiry timer
|
||||
|
||||
**Waive Charge**:
|
||||
1. **Click "Waive"** on pending/expired charge
|
||||
2. **Enter Reason**: Medical exemption, scale error, etc.
|
||||
3. **Confirm**: Charge marked as waived
|
||||
4. **Audit**: Action logged for compliance
|
||||
|
||||
**Delete Charge**:
|
||||
1. **Only for EXPIRED/WAIVED**: Click "Delete"
|
||||
2. **Confirm**: Permanent removal from system
|
||||
3. **Note**: Cannot delete active or paid charges
|
||||
|
||||
#### Agent Workflow
|
||||
|
||||
1. **Weigh Baggage**: Use station scales
|
||||
2. **Check Allowance**: Compare to passenger's seat class allowance
|
||||
3. **Create Charge**: If excess weight found
|
||||
4. **Offer Payment Options**:
|
||||
- Mobile payment link (passenger's phone)
|
||||
- Cash payment (agent collection)
|
||||
5. **Process Boarding**: After payment completed
|
||||
|
||||
---
|
||||
|
||||
### Travel Packages
|
||||
|
||||
**Purpose**: Manage pilgrimage and group travel packages with tiered pricing
|
||||
**Access Level**: Supervisor, Admin
|
||||
**Icon**: Package
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ TRAVEL PACKAGES MANAGEMENT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Create Packages │
|
||||
│ ✓ Multi-tier Pricing │
|
||||
│ ✓ Capacity Management │
|
||||
│ ✓ Schedule Integration │
|
||||
│ ✓ Bus Transfer Coordination │
|
||||
│ ✓ Package Status Control │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Package Information
|
||||
|
||||
- **Code**: Unique package identifier (e.g., "KULUBBI-2025")
|
||||
- **Name**: Package display name
|
||||
- **Origin/Destination**: Station pairs
|
||||
- **Schedules**: Outbound and return train schedules
|
||||
- **Capacity**: Total seats available
|
||||
- **Coach Configuration**: Train composition
|
||||
- **Included Services**: List of package inclusions
|
||||
- **Bus Transfer**: Optional bus coordination
|
||||
- **Validity Period**: Package booking window
|
||||
|
||||
#### Package Statuses
|
||||
|
||||
- **DRAFT**: Created but not yet active
|
||||
- **ACTIVE**: Available for booking
|
||||
- **SOLD_OUT**: All seats booked
|
||||
- **EXPIRED**: Past validity period
|
||||
- **CANCELLED**: No longer offered
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### CREATE
|
||||
|
||||
1. **Click "New Package"** button
|
||||
2. **Fill Package Form**:
|
||||
- **Code** (required): Unique identifier
|
||||
- **Name** (required): Display name
|
||||
- **Description**: Optional details
|
||||
- **Origin/Destination Stations**: Select from dropdown
|
||||
- **Outbound/Return Schedules**: Link to existing schedules
|
||||
- **Boarding/Departure/Arrival Times**: Package timeline
|
||||
- **Total Capacity**: Available seats
|
||||
- **Coach Configuration**: Train setup description
|
||||
- **Included Services**: One service per line
|
||||
- **Bus Transfer**: Enable and specify route
|
||||
- **Valid From/Until**: Booking window
|
||||
3. **Save**: Click "Create Package"
|
||||
4. **Next Step**: Add price tiers
|
||||
|
||||
##### READ (List Packages)
|
||||
|
||||
1. **View Packages**:
|
||||
- Table shows all packages
|
||||
- Search by name or code
|
||||
- Filter by status
|
||||
- Filter by validity dates
|
||||
|
||||
2. **Package Details**:
|
||||
- Click "View" to see full information
|
||||
- Shows all configuration details
|
||||
- Lists price tiers with booking status
|
||||
- Displays included services
|
||||
|
||||
##### UPDATE
|
||||
|
||||
1. **Click "Edit"** on package
|
||||
2. **Modify Details**:
|
||||
- Update package information
|
||||
- Change validity periods
|
||||
- Adjust capacity (if no bookings)
|
||||
3. **Save**: Click "Update Package"
|
||||
|
||||
##### MANAGE PRICE TIERS
|
||||
|
||||
1. **Click "Tiers"** on package
|
||||
2. **View Existing Tiers**:
|
||||
- Shows seat type, label, price, capacity
|
||||
- Displays booking progress
|
||||
3. **Add New Tier**:
|
||||
- **Seat Type**: HSC, VIP, etc.
|
||||
- **Label**: Display name (e.g., "Regular Seat (HSC)")
|
||||
- **Price (minor)**: Amount in cents/minor units
|
||||
- **Available Seats**: Tier capacity
|
||||
4. **Edit Existing Tier**:
|
||||
- Click "Edit" on tier
|
||||
- Modify details (limited if bookings exist)
|
||||
5. **Delete Tier**:
|
||||
- Click "Delete" (only if no bookings)
|
||||
|
||||
##### ACTIVATE/DEACTIVATE
|
||||
|
||||
**Activate Package**:
|
||||
1. **For DRAFT packages**: Click "Activate"
|
||||
2. **Confirmation**: Package becomes publicly bookable
|
||||
3. **Status Change**: DRAFT → ACTIVE
|
||||
|
||||
**Deactivate Package**:
|
||||
1. **For ACTIVE packages**: Click "Deactivate"
|
||||
2. **Confirmation**: Removes from public booking
|
||||
3. **Existing Bookings**: Remain valid
|
||||
|
||||
##### DELETE
|
||||
|
||||
1. **Click "Delete"** on package
|
||||
2. **Warning**: Shows impact on existing bookings
|
||||
3. **Confirm**: Permanent removal
|
||||
4. **Cascade**: Also removes price tiers
|
||||
|
||||
#### Example Package
|
||||
|
||||
```
|
||||
Code: KULUBBI-2025
|
||||
Name: Kulubbi Pilgrimage Package
|
||||
Route: Addis Ababa → Awash (return)
|
||||
Capacity: 912 passengers
|
||||
Includes:
|
||||
- Round trip train ticket
|
||||
- Bus transfer to Kulubbi site
|
||||
- Meal on board
|
||||
- Guided tour
|
||||
|
||||
Price Tiers:
|
||||
- Regular Seat (HSC): 10,232 ETB
|
||||
- Premium Seat (VIP): 15,348 ETB
|
||||
- Sleeper Bed (BED): 20,464 ETB
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Package Inquiries
|
||||
|
||||
**Purpose**: Manage incoming package booking inquiries and lead conversion
|
||||
**Access Level**: Agent, Supervisor, Admin
|
||||
**Icon**: MessageSquare
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ PACKAGE INQUIRIES MANAGEMENT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ View All Inquiries │
|
||||
│ ✓ Filter by Package │
|
||||
│ ✓ Track Inquiry Status │
|
||||
│ ✓ Contact Information │
|
||||
│ ✓ Lead Conversion │
|
||||
│ ✓ Delete Inquiries │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Inquiry Information
|
||||
|
||||
- **Contact Name**: Inquirer's name
|
||||
- **Contact Email/Phone**: Contact details
|
||||
- **Package**: Requested package
|
||||
- **Price Tier**: Selected tier (if specified)
|
||||
- **Traveler Count**: Number of passengers
|
||||
- **Inquiry Date**: When submitted
|
||||
- **Notes**: Additional comments
|
||||
- **Status**: Current inquiry status
|
||||
|
||||
#### Inquiry Statuses
|
||||
|
||||
- **NEW**: Just received, not yet contacted
|
||||
- **CONTACTED**: Agent has reached out
|
||||
- **CONVERTED**: Successfully converted to booking
|
||||
- **CLOSED**: Not converted, inquiry closed
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### READ (List Inquiries)
|
||||
|
||||
1. **Access Package Inquiries**:
|
||||
- Click **Package Inquiries** in Enhanced Features
|
||||
- Shows all inquiries
|
||||
|
||||
2. **Filter Options**:
|
||||
- **Package Filter**: Select specific package
|
||||
- **Status Filter**: NEW, CONTACTED, CONVERTED, CLOSED
|
||||
|
||||
3. **Inquiry Details**:
|
||||
- Contact information
|
||||
- Requested package and tier
|
||||
- Traveler count
|
||||
- Price information
|
||||
- Inquiry notes
|
||||
|
||||
##### UPDATE STATUS
|
||||
|
||||
1. **Status Dropdown**: In each inquiry row
|
||||
2. **Change Status**: Select new status
|
||||
3. **Auto-save**: Updates immediately
|
||||
4. **Track Progress**: Monitor conversion funnel
|
||||
|
||||
**Status Workflow**:
|
||||
```
|
||||
NEW → CONTACTED → CONVERTED/CLOSED
|
||||
```
|
||||
|
||||
##### DELETE
|
||||
|
||||
1. **Click "Delete"** on inquiry
|
||||
2. **Confirmation**: Cannot be undone
|
||||
3. **Use Case**: Remove spam or duplicate inquiries
|
||||
|
||||
#### Lead Management
|
||||
|
||||
**Best Practices**:
|
||||
1. **Respond Quickly**: Contact NEW inquiries within 24 hours
|
||||
2. **Follow Up**: Move to CONTACTED after first contact
|
||||
3. **Track Conversion**: Mark as CONVERTED when booked
|
||||
4. **Close Non-converts**: Mark as CLOSED if not interested
|
||||
|
||||
---
|
||||
|
||||
### Health Monitoring
|
||||
|
||||
**Purpose**: Monitor EDR Passenger API health and system performance
|
||||
**Access Level**: Admin, Supervisor
|
||||
**Icon**: Activity
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ HEALTH MONITORING MGMT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Liveness Checks │
|
||||
│ ✓ Readiness Probes │
|
||||
│ ✓ Database Health │
|
||||
│ ✓ Application Info │
|
||||
│ ✓ Real-time Status │
|
||||
│ ✓ Rate Limit Overview │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Health Check Types
|
||||
|
||||
**Liveness Probe** (`GET /health`):
|
||||
- Confirms API process is alive
|
||||
- Quick response check
|
||||
- Auto-refreshes every 30 seconds
|
||||
|
||||
**Readiness Probe** (`GET /health/ready`):
|
||||
- Database connectivity test
|
||||
- Live database ping with latency
|
||||
- Indicates if API can serve traffic
|
||||
|
||||
**Application Info** (`GET /health/info`):
|
||||
- App version and environment
|
||||
- System uptime
|
||||
- Refreshes every 60 seconds
|
||||
|
||||
#### Health Status Indicators
|
||||
|
||||
**Overall Status Banner**:
|
||||
- **Green**: All systems operational
|
||||
- **Red**: Service degraded
|
||||
- **Gray**: Checking status...
|
||||
|
||||
**Individual Probe Cards**:
|
||||
- Status dot (green/red/gray)
|
||||
- Health badge (Healthy/Degraded/Checking...)
|
||||
- Last check timestamp
|
||||
- Error details (if failed)
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### READ (Monitor Health)
|
||||
|
||||
1. **Access Health Page**:
|
||||
- Click **Health Monitoring** in Enhanced Features
|
||||
- Auto-refreshing dashboard
|
||||
|
||||
2. **System Overview**:
|
||||
- Overall health banner
|
||||
- Individual probe status
|
||||
- Real-time updates
|
||||
|
||||
3. **Detailed Metrics**:
|
||||
- Database latency (ms)
|
||||
- Application uptime
|
||||
- Version information
|
||||
- Environment details
|
||||
|
||||
##### REFRESH STATUS
|
||||
|
||||
1. **Manual Refresh**: Click "Refresh" button
|
||||
2. **Auto-refresh**:
|
||||
- Health probes: 30 seconds
|
||||
- App info: 60 seconds
|
||||
3. **Loading States**: Shows during refresh
|
||||
|
||||
#### Rate Limits Reference
|
||||
|
||||
The health page includes a rate limits table:
|
||||
|
||||
| Tier | Limit | Applied to |
|
||||
|------|-------|------------|
|
||||
| **auth** | 5 req/min | `/auth`, `/fayda/verification` |
|
||||
| **strict** | 20 req/min | `/bookings`, `/passengers`, `/payments`, `/wallet` |
|
||||
| **default** | 100 req/min | All other endpoints |
|
||||
| **exempt** | No limit | `/health/*`, payment webhooks |
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
**Common Issues**:
|
||||
|
||||
1. **Database Connectivity**:
|
||||
- Check network connection
|
||||
- Verify database server status
|
||||
- Review connection string
|
||||
|
||||
2. **High Latency**:
|
||||
- Monitor database performance
|
||||
- Check server resources
|
||||
- Review query optimization
|
||||
|
||||
3. **Failed Health Checks**:
|
||||
- Review API server logs
|
||||
- Check system resources
|
||||
- Verify service configuration
|
||||
|
||||
---
|
||||
|
||||
### System Config
|
||||
|
||||
**Purpose**: Centralized system configuration management with feature flags
|
||||
**Access Level**: Admin
|
||||
**Icon**: Settings
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ SYSTEM CONFIG MANAGEMENT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Rate Limit Configuration │
|
||||
│ ✓ Seat Booking Settings │
|
||||
│ ✓ Feature Flags │
|
||||
│ ✓ Operational Controls │
|
||||
│ ✓ Live Configuration Updates │
|
||||
│ ✓ Validation & Saving │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Configuration Categories
|
||||
|
||||
**Rate Limiting** (requests per minute per IP):
|
||||
- **Auth endpoints**: Login, register, OTP (default: 5)
|
||||
- **Strict endpoints**: Sensitive operations (default: 20)
|
||||
- **Default endpoints**: All other endpoints (default: 100)
|
||||
|
||||
**Seat Booking**:
|
||||
- **Hold Duration**: How long seats stay held (default: 5 minutes)
|
||||
- **Hold Cutoff**: Stop accepting holds X hours before departure (default: 2 hours)
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### READ (View Configuration)
|
||||
|
||||
1. **Access System Config**:
|
||||
- Click **System Config** in System section
|
||||
- Loads current configuration values
|
||||
|
||||
2. **Configuration Display**:
|
||||
- Rate limiting settings with descriptions
|
||||
- Seat booking parameters
|
||||
- Current values shown
|
||||
|
||||
##### UPDATE CONFIGURATION
|
||||
|
||||
1. **Modify Settings**:
|
||||
- **Auth Limit**: Adjust authentication rate limit
|
||||
- **Strict Limit**: Change sensitive operations limit
|
||||
- **Default Limit**: Update general rate limit
|
||||
- **Hold Duration**: Set seat hold time in minutes
|
||||
- **Hold Cutoff**: Set cutoff hours before departure
|
||||
|
||||
2. **Validation**:
|
||||
- Minimum values enforced
|
||||
- Reasonable maximums suggested
|
||||
- Input validation on save
|
||||
|
||||
3. **Save Changes**:
|
||||
- Click "Save Changes" button
|
||||
- Configuration applied immediately
|
||||
- Success/error feedback shown
|
||||
|
||||
#### Configuration Examples
|
||||
|
||||
**Rate Limiting Tiers**:
|
||||
```
|
||||
Auth endpoints (5/min):
|
||||
- /auth/login
|
||||
- /auth/register
|
||||
- /fayda/verification
|
||||
|
||||
Strict endpoints (20/min):
|
||||
- /bookings/*
|
||||
- /passengers/*
|
||||
- /payments/*
|
||||
- /wallet/*
|
||||
|
||||
Default endpoints (100/min):
|
||||
- /search/*
|
||||
- /stations/*
|
||||
- All other public endpoints
|
||||
```
|
||||
|
||||
**Seat Management**:
|
||||
```
|
||||
Hold Duration: 5 minutes
|
||||
- Passenger has 5 minutes to complete booking
|
||||
- After expiry, seats released automatically
|
||||
|
||||
Hold Cutoff: 2 hours
|
||||
- No new holds accepted within 2 hours of departure
|
||||
- Prevents last-minute booking complications
|
||||
```
|
||||
|
||||
#### System Impact
|
||||
|
||||
**Rate Limit Changes**:
|
||||
- Applied immediately to new requests
|
||||
- Existing connections not affected
|
||||
- Monitor for performance impact
|
||||
|
||||
**Seat Booking Changes**:
|
||||
- New holds use updated duration
|
||||
- Existing holds retain original expiry
|
||||
- Cutoff affects future booking attempts
|
||||
|
||||
#### Best Practices
|
||||
|
||||
1. **Monitor Impact**: Watch system performance after changes
|
||||
2. **Conservative Adjustments**: Make incremental changes
|
||||
3. **Peak Periods**: Consider higher limits during busy times
|
||||
4. **Security Balance**: Balance usability with abuse prevention
|
||||
5. **Documentation**: Document reasons for configuration changes
|
||||
|
||||
---
|
||||
|
||||
### Boarding Management
|
||||
|
||||
**Purpose**: Manage gate operations and passenger boarding processes
|
||||
**Access Level**: Agent, Supervisor, Admin
|
||||
**Icon**: Users
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ BOARDING MANAGEMENT MGMT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Gate Operations │
|
||||
│ ✓ Passenger Check-in │
|
||||
│ ✓ Boarding Pass Validation │
|
||||
│ ✓ Seat Assignment Verification│
|
||||
│ ✓ Boarding Status Tracking │
|
||||
│ ✓ Real-time Updates │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### READ (Monitor Boarding)
|
||||
|
||||
1. **Access Boarding Page**:
|
||||
- Click **Boarding Management** in Enhanced Features
|
||||
- Select active trip/schedule
|
||||
- View real-time boarding status
|
||||
|
||||
2. **Boarding Dashboard**:
|
||||
- Total passengers expected
|
||||
- Passengers boarded
|
||||
- Boarding progress tracking
|
||||
|
||||
---
|
||||
|
||||
### Advanced Fare Configuration
|
||||
|
||||
**Purpose**: Manage complex fare rules and dynamic pricing strategies
|
||||
**Access Level**: Admin, Supervisor
|
||||
**Icon**: Calculator
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ADVANCED FARE CONFIG MGMT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Dynamic Fare Rules │
|
||||
│ ✓ Segment-based Pricing │
|
||||
│ ✓ Nationality-specific Rates │
|
||||
│ ✓ Seasonal Adjustments │
|
||||
│ ✓ Fare Engine Integration │
|
||||
│ ✓ Real-time Calculations │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### CREATE FARE RULES
|
||||
|
||||
1. **Access Fare Management**:
|
||||
- Click **Advanced Fare Config** in Enhanced Features
|
||||
- Choose between Schedule Fares or Segment Fares
|
||||
|
||||
2. **Configure Rules**:
|
||||
- Set fare amounts and validity periods
|
||||
- Define passenger categories and nationalities
|
||||
- Apply to specific routes or schedules
|
||||
|
||||
---
|
||||
|
||||
### Payment Methods Configuration
|
||||
|
||||
**Purpose**: Configure and manage payment provider integrations
|
||||
**Access Level**: Admin
|
||||
**Icon**: CreditCard
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ PAYMENT METHODS CONFIG MGMT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Provider Setup │
|
||||
│ ✓ API Configuration │
|
||||
│ ✓ Enable/Disable Methods │
|
||||
│ ✓ Webhook Management │
|
||||
│ ✓ Test Transactions │
|
||||
│ ✓ Fee Configuration │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Supported Providers
|
||||
|
||||
- **Telebirr**: Ethiopian mobile payment
|
||||
- **CBE Birr**: Commercial Bank of Ethiopia
|
||||
- **eBirr**: Electronic wallet service
|
||||
- **Card Payments**: VISA, Mastercard
|
||||
- **WAAFI**: Money transfer service
|
||||
- **Agent Cash**: Counter collection
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### UPDATE CONFIGURATION
|
||||
|
||||
1. **Provider Setup**:
|
||||
- Configure API credentials
|
||||
- Set transaction fees and limits
|
||||
- Enable/disable providers
|
||||
|
||||
2. **Test & Validate**:
|
||||
- Run test transactions
|
||||
- Validate webhook endpoints
|
||||
- Monitor connectivity
|
||||
|
||||
---
|
||||
|
||||
### Boarding Management
|
||||
|
||||
**Purpose**: Manage gate operations and passenger boarding processes
|
||||
**Access Level**: Agent, Supervisor, Admin
|
||||
**Icon**: Users
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ BOARDING MANAGEMENT MGMT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Gate Operations │
|
||||
│ ✓ Passenger Check-in │
|
||||
│ ✓ Boarding Pass Validation │
|
||||
│ ✓ Seat Assignment Verification│
|
||||
│ ✓ Boarding Status Tracking │
|
||||
│ ✓ Real-time Updates │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Boarding Process
|
||||
|
||||
1. **Pre-boarding Setup**: Configure gates and boarding times
|
||||
2. **Passenger Check-in**: Validate tickets and documents
|
||||
3. **Boarding Queue**: Manage passenger flow and priority boarding
|
||||
4. **Seat Verification**: Confirm seat assignments and resolve conflicts
|
||||
5. **Boarding Completion**: Final passenger count and departure clearance
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### READ (Monitor Boarding)
|
||||
|
||||
1. **Access Boarding Page**:
|
||||
- Click **Boarding Management** in Enhanced Features
|
||||
- Select active trip/schedule
|
||||
- View real-time boarding status
|
||||
|
||||
2. **Boarding Dashboard**:
|
||||
- Total passengers expected
|
||||
- Passengers boarded
|
||||
- Remaining passengers
|
||||
- Boarding progress percentage
|
||||
- Gate status and alerts
|
||||
|
||||
##### MANAGE BOARDING PROCESS
|
||||
|
||||
**Start Boarding**:
|
||||
1. **Select Trip**: Choose scheduled departure
|
||||
2. **Open Gates**: Activate boarding process
|
||||
3. **Scan Tickets**: Validate passenger tickets and documents
|
||||
4. **Update Status**: Track boarding progress in real-time
|
||||
|
||||
**Handle Issues**:
|
||||
1. **Seat Conflicts**: Resolve duplicate seat assignments
|
||||
2. **Missing Passengers**: Mark no-shows
|
||||
3. **Late Arrivals**: Process last-minute passengers
|
||||
4. **Special Assistance**: Handle wheelchair, elderly, child passengers
|
||||
|
||||
---
|
||||
|
||||
### Advanced Fare Configuration
|
||||
|
||||
**Purpose**: Manage complex fare rules and dynamic pricing strategies
|
||||
**Access Level**: Admin, Supervisor
|
||||
**Icon**: Calculator
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ ADVANCED FARE CONFIG MGMT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Dynamic Fare Rules │
|
||||
│ ✓ Segment-based Pricing │
|
||||
│ ✓ Nationality-specific Rates │
|
||||
│ ✓ Seasonal Adjustments │
|
||||
│ ✓ Fare Engine Integration │
|
||||
│ ✓ Real-time Calculations │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Fare Rule Types
|
||||
|
||||
**Schedule-specific Fares**:
|
||||
- Fixed rates for specific train schedules
|
||||
- Override default fare calculations
|
||||
- Temporary promotional pricing
|
||||
|
||||
**Route Segment Fares**:
|
||||
- Different pricing for route segments
|
||||
- Origin-destination specific rates
|
||||
- Distance-based calculations
|
||||
|
||||
**Passenger Category Fares**:
|
||||
- ADULT vs CHILD pricing
|
||||
- Nationality-based rates (Ethiopian, Djiboutian, Other)
|
||||
- Group discounts
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### CREATE FARE RULES
|
||||
|
||||
1. **Access Fare Management**:
|
||||
- Click **Advanced Fare Config** in Enhanced Features
|
||||
- Choose between Schedule Fares or Segment Fares
|
||||
|
||||
2. **Schedule Fare Rule**:
|
||||
- **Schedule**: Select specific trip
|
||||
- **Seat Class**: Choose class (Economy, VIP, etc.)
|
||||
- **Fare Amount**: Set price in ETB
|
||||
- **Passenger Type**: ADULT or CHILD (optional)
|
||||
- **Nationality**: Specific nationality or All
|
||||
- **Valid Period**: Start and end dates
|
||||
|
||||
3. **Segment Fare Rule**:
|
||||
- **Route**: Select route
|
||||
- **Origin/Destination**: Choose station pair
|
||||
- **Seat Class**: Select class
|
||||
- **Fare Amount**: Set segment price
|
||||
- **Passenger Type**: ADULT/CHILD filter
|
||||
- **Valid Period**: Effective dates
|
||||
|
||||
##### READ (View Fare Rules)
|
||||
|
||||
1. **Schedule Fares Tab**:
|
||||
- Select schedule to view calculated fares
|
||||
- See all active seat classes
|
||||
- View fare breakdown by category
|
||||
|
||||
2. **Segment Fares Tab**:
|
||||
- Select route to view segment rules
|
||||
- See origin-destination combinations
|
||||
- Filter by fare rule criteria
|
||||
|
||||
##### UPDATE/DELETE FARE RULES
|
||||
|
||||
1. **Edit Rules**: Click "Edit" on existing fare rule
|
||||
2. **Delete Rules**: Click "Delete" to remove rule
|
||||
3. **Validation**: Changes affect future bookings only
|
||||
|
||||
#### Fare Calculation Priority
|
||||
|
||||
```
|
||||
1. Segment Fare (nationality-specific)
|
||||
2. Segment Fare (generic)
|
||||
3. Schedule Fare (nationality-specific)
|
||||
4. Schedule Fare (generic)
|
||||
5. Default Class Base Fare
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Payment Methods Configuration
|
||||
|
||||
**Purpose**: Configure and manage payment provider integrations
|
||||
**Access Level**: Admin
|
||||
**Icon**: CreditCard
|
||||
|
||||
#### Features Overview
|
||||
|
||||
```
|
||||
┌──────────────────────────────┐
|
||||
│ PAYMENT METHODS CONFIG MGMT │
|
||||
├──────────────────────────────┤
|
||||
│ ✓ Provider Setup │
|
||||
│ ✓ API Configuration │
|
||||
│ ✓ Enable/Disable Methods │
|
||||
│ ✓ Webhook Management │
|
||||
│ ✓ Test Transactions │
|
||||
│ ✓ Fee Configuration │
|
||||
└──────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Supported Payment Providers
|
||||
|
||||
- **Telebirr**: Ethiopian mobile payment
|
||||
- **CBE Birr**: Commercial Bank of Ethiopia
|
||||
- **eBirr**: Electronic wallet service
|
||||
- **Card Payments**: VISA, Mastercard via gateway
|
||||
- **WAAFI**: Money transfer service
|
||||
- **Agent Cash**: Counter cash collection
|
||||
|
||||
#### CRUD Operations
|
||||
|
||||
##### READ (View Payment Methods)
|
||||
|
||||
1. **Access Payment Methods**:
|
||||
- Click **Payment Methods** in Enhanced Features
|
||||
- View all configured providers
|
||||
- See status and configuration
|
||||
|
||||
2. **Provider Status**:
|
||||
- Enabled/Disabled toggle
|
||||
- Configuration status
|
||||
- Last transaction test
|
||||
- Error logs (if any)
|
||||
|
||||
##### UPDATE CONFIGURATION
|
||||
|
||||
**Provider Setup**:
|
||||
1. **API Credentials**:
|
||||
- Base URL
|
||||
- API Key/Secret
|
||||
- Merchant ID
|
||||
- Webhook endpoints
|
||||
|
||||
2. **Settings**:
|
||||
- Enable/disable provider
|
||||
- Transaction fees
|
||||
- Minimum/maximum amounts
|
||||
- Currency support
|
||||
|
||||
3. **Test Configuration**:
|
||||
- Run test transactions
|
||||
- Validate webhook endpoints
|
||||
- Check API connectivity
|
||||
|
||||
**Webhook Management**:
|
||||
1. **Endpoint URLs**: Configure callback URLs
|
||||
2. **Security**: Set webhook secrets
|
||||
3. **Event Types**: Select events to receive
|
||||
4. **Retry Logic**: Configure retry attempts
|
||||
|
||||
##### TROUBLESHOOTING
|
||||
|
||||
**Common Issues**:
|
||||
1. **API Connectivity**: Check network and credentials
|
||||
2. **Webhook Failures**: Verify endpoint accessibility
|
||||
3. **Transaction Failures**: Review provider logs
|
||||
4. **Configuration Errors**: Validate API settings
|
||||
|
||||
452
apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx
Normal file
452
apps/edr-passenger-web/backoffice/src/app/boarding/page.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { QrCode, Camera, RotateCcw, CheckCircle, XCircle, User, MapPin, Clock, Train, CameraOff } from 'lucide-react';
|
||||
import { ticketsApi, apiClient } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Header from '@/components/layout/Header';
|
||||
|
||||
// Add QR Scanner component
|
||||
function QRScanner({ onScan, onError }: { onScan: (data: string) => void; onError: (error: string) => void }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [cameraError, setCameraError] = useState<string | null>(null);
|
||||
const scanIntervalRef = useRef<number | null>(null);
|
||||
|
||||
const startCamera = async () => {
|
||||
try {
|
||||
setCameraError(null);
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: 'environment', // Use back camera
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 }
|
||||
}
|
||||
});
|
||||
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = mediaStream;
|
||||
await videoRef.current.play();
|
||||
setStream(mediaStream);
|
||||
setIsScanning(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
const errorMsg = 'Camera access denied. Please enable camera permissions in browser settings.';
|
||||
setCameraError(errorMsg);
|
||||
onError(errorMsg);
|
||||
console.error('Camera error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
if (scanIntervalRef.current) {
|
||||
clearInterval(scanIntervalRef.current);
|
||||
scanIntervalRef.current = null;
|
||||
}
|
||||
if (stream) {
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
setStream(null);
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
setIsScanning(false);
|
||||
setCameraError(null);
|
||||
}, [stream]);
|
||||
|
||||
// QR code scanning with jsqr
|
||||
const scanFrame = useCallback(() => {
|
||||
if (!videoRef.current || !canvasRef.current || !isScanning) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
if (ctx && video.readyState === video.HAVE_ENOUGH_DATA) {
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
try {
|
||||
// Try to use jsqr if available
|
||||
const jsQR = (window as any).jsQR;
|
||||
if (jsQR) {
|
||||
const code = jsQR(imageData.data, imageData.width, imageData.height, {
|
||||
inversionAttempts: 'dontInvert',
|
||||
});
|
||||
|
||||
if (code) {
|
||||
onScan(code.data);
|
||||
stopCamera();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('QR scan error:', err);
|
||||
}
|
||||
}
|
||||
}, [isScanning, onScan, stopCamera]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isScanning) {
|
||||
scanIntervalRef.current = window.setInterval(scanFrame, 100); // Scan every 100ms
|
||||
}
|
||||
return () => {
|
||||
if (scanIntervalRef.current) {
|
||||
clearInterval(scanIntervalRef.current);
|
||||
}
|
||||
stopCamera();
|
||||
};
|
||||
}, [isScanning, scanFrame, stopCamera]);
|
||||
|
||||
// Load jsqr from CDN
|
||||
useEffect(() => {
|
||||
if (!(window as any).jsQR) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.min.js';
|
||||
script.async = true;
|
||||
document.body.appendChild(script);
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!isScanning ? (
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={startCamera}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-4 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Camera className="w-5 h-5" />
|
||||
Scan QR Code
|
||||
</button>
|
||||
{cameraError && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<p className="text-red-700 dark:text-red-300 text-sm">{cameraError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="relative bg-black rounded-xl overflow-hidden">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
className="w-full h-64 object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative w-48 h-48">
|
||||
<div className="absolute inset-0 border-2 border-white border-dashed rounded-lg"></div>
|
||||
<div className="absolute top-0 left-0 w-6 h-6 border-t-4 border-l-4 border-blue-400 rounded-tl-lg"></div>
|
||||
<div className="absolute top-0 right-0 w-6 h-6 border-t-4 border-r-4 border-blue-400 rounded-tr-lg"></div>
|
||||
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-4 border-l-4 border-blue-400 rounded-bl-lg"></div>
|
||||
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-4 border-r-4 border-blue-400 rounded-br-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-4">
|
||||
<p className="text-white text-center text-sm font-medium">Position QR code within frame</p>
|
||||
</div>
|
||||
</div>
|
||||
<canvas ref={canvasRef} className="hidden" />
|
||||
<button
|
||||
onClick={stopCamera}
|
||||
className="w-full bg-gray-600 hover:bg-gray-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<CameraOff className="w-5 h-5" />
|
||||
Stop Camera
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BoardingPage() {
|
||||
const [qrInput, setQrInput] = useState('');
|
||||
const [lastScanned, setLastScanned] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { user, isAuthenticated } = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
// Check authentication on mount
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
// Get current agent data
|
||||
const { data: agentData } = useQuery({
|
||||
queryKey: ['agent-me'],
|
||||
queryFn: () => apiClient.get<any>('/agents/me'),
|
||||
enabled: !!user,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const boardingMutation = useMutation({
|
||||
mutationFn: (qrCodeOrRef: string) =>
|
||||
ticketsApi.scanAndBoard(qrCodeOrRef, {
|
||||
validatorId: agentData?.id || user?.id || 'BACKOFFICE',
|
||||
gateId: 'MOBILE-GATE',
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
setError(null);
|
||||
if (result.success) {
|
||||
setSuccess('Passenger boarded successfully!');
|
||||
setLastScanned(result.boarding);
|
||||
setQrInput('');
|
||||
// Auto-focus for next scan
|
||||
setTimeout(() => inputRef.current?.focus(), 1000);
|
||||
} else {
|
||||
setError(result.error || 'Boarding failed');
|
||||
setLastScanned(null);
|
||||
}
|
||||
},
|
||||
onError: (error: any) => {
|
||||
setError(error?.response?.data?.message || error.message || 'Boarding failed');
|
||||
setSuccess(null);
|
||||
setLastScanned(null);
|
||||
},
|
||||
});
|
||||
|
||||
const handleScan = (inputValue?: string) => {
|
||||
const valueToScan = inputValue || qrInput.trim();
|
||||
if (!valueToScan) {
|
||||
setError('Please enter QR code or booking reference');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
boardingMutation.mutate(valueToScan);
|
||||
};
|
||||
|
||||
const handleButtonClick = () => {
|
||||
handleScan();
|
||||
};
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleButtonClick();
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
setQrInput('');
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setLastScanned(null);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-focus on mount for mobile scanning (only if authenticated)
|
||||
if (isAuthenticated) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Show loading or redirect if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center bg-gray-50 dark:bg-slate-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-edr-green-600 mx-auto"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-gray-400">Redirecting to login...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-slate-950">
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<Header />
|
||||
<main className="flex-1 overflow-y-auto bg-gray-50 dark:bg-slate-950">
|
||||
<div className="min-h-full bg-gradient-to-br from-emerald-50 to-blue-50 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
{/* Mobile-optimized container */}
|
||||
<div className="max-w-md mx-auto space-y-6">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center py-6">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 bg-emerald-600 rounded-full mb-4">
|
||||
<QrCode className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Boarding</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">Scan ticket QR codes to board passengers</p>
|
||||
{agentData && (
|
||||
<div className="text-sm text-emerald-600 dark:text-emerald-400 mt-2">
|
||||
Agent: {agentData.agentCode || agentData.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scanner Input */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 border border-gray-100 dark:border-slate-700">
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Camera Scanner */}
|
||||
<QRScanner
|
||||
onScan={(data) => {
|
||||
setQrInput(data);
|
||||
handleScan(data);
|
||||
}}
|
||||
onError={setError}
|
||||
/>
|
||||
|
||||
{/* Manual Input */}
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 text-sm">OR</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={qrInput}
|
||||
onChange={(e) => setQrInput(e.target.value)}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="Type ticket number"
|
||||
className="w-full px-4 py-4 text-lg border border-gray-300 dark:border-slate-600 rounded-xl
|
||||
focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500
|
||||
dark:bg-slate-700 dark:text-white dark:placeholder-slate-400
|
||||
font-mono tracking-wide"
|
||||
autoCapitalize="characters"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleButtonClick}
|
||||
disabled={boardingMutation.isPending || !qrInput.trim()}
|
||||
className="flex-1 bg-emerald-600 hover:bg-emerald-700 disabled:bg-gray-300
|
||||
text-white font-semibold py-4 px-6 rounded-xl transition-colors
|
||||
disabled:cursor-not-allowed text-lg"
|
||||
>
|
||||
{boardingMutation.isPending ? 'Boarding...' : 'Board Passenger'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white font-semibold py-4 px-6 rounded-xl transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
{success && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<CheckCircle className="w-6 h-6 text-green-600 dark:text-green-400" />
|
||||
<span className="text-green-800 dark:text-green-200 font-semibold text-lg">{success}</span>
|
||||
</div>
|
||||
|
||||
{lastScanned && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-green-700 dark:text-green-300 font-medium">
|
||||
{lastScanned.passengerName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-green-700 dark:text-green-300">
|
||||
{lastScanned.route}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Train className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-green-700 dark:text-green-300">
|
||||
{lastScanned.trainName} - Coach {lastScanned.coach}, Seat {lastScanned.seat}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-green-600 dark:text-green-400" />
|
||||
<span className="text-green-700 dark:text-green-300">
|
||||
Boarded: {formatDateTime(lastScanned.boardedAt)} ({lastScanned.leg})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{lastScanned.isRoundTrip && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-3 mt-3">
|
||||
<p className="text-blue-700 dark:text-blue-300 text-sm">
|
||||
ℹ️ Round-trip ticket: Scan again for return journey
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-green-600 dark:text-green-400 font-mono mt-2">
|
||||
Booking: {lastScanned.bookingRef} | Ticket: {lastScanned.ticketId}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-green-600 dark:text-green-400 mt-2">
|
||||
📧 Email & SMS notifications sent to passenger
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-2xl p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<XCircle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||
<span className="text-red-800 dark:text-red-200 font-semibold">{error}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-2xl p-6">
|
||||
<h3 className="text-blue-800 dark:text-blue-200 font-semibold mb-3">How to scan:</h3>
|
||||
<ul className="text-blue-700 dark:text-blue-300 space-y-2 text-sm">
|
||||
<li>• Tap "Scan QR Code" and point at ticket QR code</li>
|
||||
<li>• For manual option, type or paste booking reference</li>
|
||||
<li>• Tickets can only be boarded on their departure date</li>
|
||||
<li>• First scan boards outbound leg for round trips</li>
|
||||
<li>• Email & SMS sent automatically to passenger contacts</li>
|
||||
<li>• Red error shows validation issues</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-xl p-6 border border-gray-100 dark:border-slate-700">
|
||||
<h3 className="text-gray-900 dark:text-white font-semibold mb-3">Session Summary</h3>
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className="text-emerald-600 dark:text-emerald-400 font-semibold">
|
||||
Ready to scan
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Eye, XCircle, Trash2 } from 'lucide-react';
|
||||
import { Download, Eye, Trash2 } from 'lucide-react';
|
||||
import DataTable from '@/components/ui/DataTable';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import Pagination from '@/components/ui/Pagination';
|
||||
@@ -31,7 +31,6 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
|
||||
function BookingsPageContent() {
|
||||
const canManage = usePermission(PERMS.bookings.manage);
|
||||
const canCancel = usePermission(PERMS.bookings.cancel);
|
||||
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
|
||||
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
@@ -62,16 +61,6 @@ function BookingsPageContent() {
|
||||
}),
|
||||
});
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: ({ id, reason }: { id: string; reason?: string }) => bookingsApi.cancel(id, reason),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['bookings'] });
|
||||
setSuccessMessage('Booking cancelled successfully');
|
||||
setTimeout(() => setSuccessMessage(''), 3000);
|
||||
},
|
||||
onError: (error: any) => alert(`Error: ${error.message || 'Failed to cancel booking'}`),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiClient.delete(`/bookings/${id}`),
|
||||
onSuccess: () => {
|
||||
@@ -87,12 +76,6 @@ function BookingsPageContent() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancel = async (booking: any) => {
|
||||
if (window.confirm(`Cancel booking ${booking.bookingRef}? This will process a refund.`)) {
|
||||
await cancelMutation.mutateAsync({ id: booking.id, reason: 'Cancelled by admin' });
|
||||
}
|
||||
};
|
||||
|
||||
const BOOKING_COLS = [
|
||||
{ key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' },
|
||||
{ key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' },
|
||||
@@ -167,9 +150,38 @@ function BookingsPageContent() {
|
||||
{
|
||||
key: 'passengerNames', label: 'Names',
|
||||
render: (booking: any) => {
|
||||
const names: string[] = booking.passengerNames || [];
|
||||
if (!names.length) return <span className="text-muted-foreground">—</span>;
|
||||
return <div className="flex flex-col gap-0.5">{names.map((n, i) => <span key={i} className="text-sm">{n}</span>)}</div>;
|
||||
const passengers = booking.passengers || [];
|
||||
if (!passengers.length) {
|
||||
// Fallback to old logic if passengers array not available
|
||||
const names: string[] = booking.passengerNames || [];
|
||||
const adultCount = booking.adultCount || 0;
|
||||
if (!names.length) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{names.map((name, i) => {
|
||||
const isAdult = i < adultCount;
|
||||
const passengerType = isAdult ? 'A' : 'C';
|
||||
return (
|
||||
<span key={i} className="text-sm">
|
||||
{name} ({passengerType})
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{passengers.map((p: any, i: number) => {
|
||||
const passengerType = p.category === 'ADULT' ? 'A' : 'C';
|
||||
return (
|
||||
<span key={i} className="text-sm">
|
||||
{p.name} ({passengerType})
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -206,10 +218,6 @@ function BookingsPageContent() {
|
||||
|
||||
const actions = [
|
||||
{ label: 'View Details', onClick: (b: any) => setSelectedBooking(b), variant: 'secondary' as const, icon: Eye },
|
||||
{
|
||||
label: 'Cancel Booking', onClick: handleCancel, variant: 'danger' as const, icon: XCircle,
|
||||
show: (b: any) => b.status !== 'CANCELLED' && b.status !== 'BOARDED',
|
||||
},
|
||||
{ label: 'Delete', onClick: (b: any) => { setDeleteError(null); setBookingToDelete(b); setDeleteConfirmOpen(true); }, variant: 'danger' as const, icon: Trash2 },
|
||||
];
|
||||
|
||||
|
||||
@@ -144,8 +144,11 @@ export default function CoachesPage() {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('coaches');
|
||||
const [search, setSearch] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const [seatMapPreview, setSeatMapPreview] = useState<any>(null);
|
||||
const [editingItem, setEditingItem] = useState<any>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; item: any | null; error?: string }>({ isOpen: false, item: null });
|
||||
const [selectedCoachTypeId, setSelectedCoachTypeId] = useState<string>('');
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Coach Types Queries
|
||||
@@ -212,6 +215,14 @@ export default function CoachesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const generateSeatMapMutation = useMutation({
|
||||
mutationFn: fleetApi.generateSeatMap,
|
||||
onSuccess: (data) => {
|
||||
setSeatMapPreview(data);
|
||||
setShowPreviewModal(true);
|
||||
},
|
||||
});
|
||||
|
||||
const handleCoachTypeSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
@@ -231,7 +242,8 @@ export default function CoachesPage() {
|
||||
const handleCoachSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const data = {
|
||||
|
||||
const data: any = {
|
||||
number: formData.get('number') as string,
|
||||
coachTypeId: formData.get('coachTypeId') as string,
|
||||
arrangement: formData.get('arrangement') as string,
|
||||
@@ -240,6 +252,16 @@ export default function CoachesPage() {
|
||||
status: formData.get('status') as string,
|
||||
};
|
||||
|
||||
// Add bed-specific fields if bed coach is selected
|
||||
const bedCategory = formData.get('bedCategory') as string;
|
||||
if (bedCategory) {
|
||||
data.bedCategory = bedCategory as 'ECONOMY_BED' | 'VIP_BED';
|
||||
const bedsPerRoom = formData.get('bedsPerRoom') as string;
|
||||
if (bedsPerRoom) {
|
||||
data.bedsPerRoom = parseInt(bedsPerRoom);
|
||||
}
|
||||
}
|
||||
|
||||
if (editingItem?.isCoach) {
|
||||
await updateCoachMutation.mutateAsync({ id: editingItem.id, data });
|
||||
} else {
|
||||
@@ -247,6 +269,27 @@ export default function CoachesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewSeatMap = async () => {
|
||||
const form = document.querySelector('form') as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const bedCategory = formData.get('bedCategory') as string;
|
||||
const capacity = parseInt(formData.get('capacity') as string);
|
||||
|
||||
if (!bedCategory || !capacity) {
|
||||
alert('Please select a bed category and enter capacity to preview seat map');
|
||||
return;
|
||||
}
|
||||
|
||||
const bedsPerRoom = bedCategory === 'VIP_BED' ? 4 : 6;
|
||||
const roomsPerCoach = Math.ceil(capacity / bedsPerRoom);
|
||||
|
||||
await generateSeatMapMutation.mutateAsync({
|
||||
coachCount: 1,
|
||||
roomsPerCoach,
|
||||
roomType: bedCategory,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = (item: any, isCoachType: boolean) => {
|
||||
setDeleteConfirm({ isOpen: true, item: { ...item, isCoachType } });
|
||||
};
|
||||
@@ -298,7 +341,6 @@ export default function CoachesPage() {
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
ACTIVE: 'edr-badge-success',
|
||||
MAINTENANCE: 'edr-badge-warning',
|
||||
INACTIVE: 'edr-badge-danger',
|
||||
};
|
||||
|
||||
@@ -373,10 +415,30 @@ export default function CoachesPage() {
|
||||
},
|
||||
{
|
||||
key: 'arrangement',
|
||||
label: 'Arrangement',
|
||||
render: (coach: any) => (
|
||||
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
|
||||
),
|
||||
label: 'Type/Arrangement',
|
||||
render: (coach: any) => {
|
||||
// Check if this is a bed coach based on coach type name containing 'bed'
|
||||
const coachTypeName = coach.coachType?.name?.toLowerCase() || '';
|
||||
const isBedCoach = coachTypeName.includes('bed') || coachTypeName.includes('sleeper') || coachTypeName.includes('berth');
|
||||
|
||||
if (isBedCoach) {
|
||||
// Determine if it's VIP or Economy based on coach type name
|
||||
const isVIP = coachTypeName.includes('vip');
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Bed className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Armchair className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-mono">{coach.arrangement || 'N/A'}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'capacity',
|
||||
@@ -422,6 +484,7 @@ export default function CoachesPage() {
|
||||
label: 'Edit',
|
||||
onClick: (item: any) => {
|
||||
setEditingItem({ ...item, isCoach: true });
|
||||
setSelectedCoachTypeId(item.coachTypeId || '');
|
||||
setShowModal(true);
|
||||
},
|
||||
variant: 'secondary' as const,
|
||||
@@ -446,6 +509,7 @@ export default function CoachesPage() {
|
||||
icon={Plus}
|
||||
onClick={() => {
|
||||
setEditingItem(null);
|
||||
setSelectedCoachTypeId('');
|
||||
setSearch('');
|
||||
setShowModal(true);
|
||||
}}
|
||||
@@ -556,6 +620,7 @@ export default function CoachesPage() {
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
setSelectedCoachTypeId('');
|
||||
}}
|
||||
title={
|
||||
activeTab === 'types'
|
||||
@@ -636,12 +701,13 @@ export default function CoachesPage() {
|
||||
name="coachTypeId"
|
||||
className="input"
|
||||
defaultValue={editingItem?.coachTypeId || ''}
|
||||
onChange={(e) => setSelectedCoachTypeId(e.target.value)}
|
||||
required
|
||||
>
|
||||
<option value="">Select Coach Type</option>
|
||||
{coachTypesArray.map((ct: any) => (
|
||||
<option key={ct.id} value={ct.id}>
|
||||
{ct.code} - {ct.name} - {ct.type}
|
||||
{ct.code} - {ct.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -659,17 +725,66 @@ export default function CoachesPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Conditionally show bed fields only for Economy and Regular coach types */}
|
||||
{(() => {
|
||||
const selectedCoachType = coachTypesArray.find((ct: any) => ct.id === (selectedCoachTypeId || editingItem?.coachTypeId));
|
||||
const isEconomyOrRegular = selectedCoachType &&
|
||||
(selectedCoachType.name?.toLowerCase().includes('economy') ||
|
||||
selectedCoachType.name?.toLowerCase().includes('regular') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('economy') ||
|
||||
selectedCoachType.type?.toLowerCase().includes('regular'));
|
||||
|
||||
return isEconomyOrRegular ? (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Bed Category</label>
|
||||
<select
|
||||
name="bedCategory"
|
||||
className="input"
|
||||
defaultValue={editingItem?.bedCategory || ''}
|
||||
>
|
||||
<option value="">Select bed category</option>
|
||||
<option value="ECONOMY_BED">Economy Bed</option>
|
||||
<option value="VIP_BED">VIP Bed</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Select if this is a bed coach
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Beds Per Room</label>
|
||||
<select
|
||||
name="bedsPerRoom"
|
||||
className="input"
|
||||
defaultValue={editingItem?.bedsPerRoom || ''}
|
||||
>
|
||||
<option value="">Auto (VIP: 4, Economy: 6)</option>
|
||||
<option value="2">2 beds per room</option>
|
||||
<option value="4">4 beds per room</option>
|
||||
<option value="6">6 beds per room</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Only applies to bed coaches
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
<div>
|
||||
<label className="label">Arrangement *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="arrangement"
|
||||
className="input"
|
||||
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement }
|
||||
defaultValue={editingItem?.arrangement || editingItem?.seatArrangement || '2+2'}
|
||||
required
|
||||
placeholder="e.g., 3+2, 3+0, 2+0"
|
||||
placeholder="e.g., 2+2, 3+2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Format: separate columns with +</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
For regular seats: columns separated by +
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -691,12 +806,14 @@ export default function CoachesPage() {
|
||||
type="number"
|
||||
name="sequence"
|
||||
className="input"
|
||||
defaultValue={editingItem?.sequence || 0}
|
||||
min="0"
|
||||
defaultValue={editingItem?.sequence || 1}
|
||||
min="1"
|
||||
required
|
||||
placeholder="e.g., 1"
|
||||
placeholder="1"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">Used for ordering coaches in trains</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Position in train consist
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -708,19 +825,27 @@ export default function CoachesPage() {
|
||||
required
|
||||
>
|
||||
<option value="ACTIVE">Active</option>
|
||||
<option value="MAINTENANCE">Maintenance</option>
|
||||
<option value="INACTIVE">Inactive</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handlePreviewSeatMap}
|
||||
loading={generateSeatMapMutation.isPending}
|
||||
>
|
||||
Preview Bed Layout
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowModal(false);
|
||||
setEditingItem(null);
|
||||
setSelectedCoachTypeId('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
@@ -735,6 +860,58 @@ export default function CoachesPage() {
|
||||
</form>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Seat Map Preview Modal */}
|
||||
<Modal
|
||||
isOpen={showPreviewModal}
|
||||
onClose={() => {
|
||||
setShowPreviewModal(false);
|
||||
setSeatMapPreview(null);
|
||||
}}
|
||||
title="Bed Layout Preview"
|
||||
size="lg"
|
||||
>
|
||||
{seatMapPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-muted/50 p-4 rounded-lg">
|
||||
<h4 className="font-semibold mb-2">Configuration</h4>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>Room Type: <span className="font-medium">{seatMapPreview.roomType}</span></div>
|
||||
<div>Rooms per Coach: <span className="font-medium">{seatMapPreview.roomsPerCoach}</span></div>
|
||||
<div>Beds per Room: <span className="font-medium">{seatMapPreview.bedsPerRoom}</span></div>
|
||||
<div>Total Beds: <span className="font-medium">{seatMapPreview.totalBeds}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold">Bed Layout Sample (First Few Rooms)</h4>
|
||||
<div className="bg-gray-50 p-4 rounded border max-h-64 overflow-y-auto">
|
||||
{seatMapPreview.seats?.slice(0, 24).map((seat: any, idx: number) => (
|
||||
<div key={idx} className="text-xs mb-1 font-mono">
|
||||
{seat.seat_id} - Room: {seat.room_id} - {seat.position} {seat.bed_type}
|
||||
</div>
|
||||
))}
|
||||
{seatMapPreview.seats?.length > 24 && (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
... and {seatMapPreview.seats.length - 24} more beds
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setShowPreviewModal(false);
|
||||
setSeatMapPreview(null);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user