train gate pass, Telebirr and Wafi

This commit is contained in:
hagiye
2026-07-01 16:23:52 +03:00
parent 38db9a4177
commit 14a00af341
41 changed files with 2807 additions and 151 deletions

View File

@@ -59,6 +59,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder"; import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
import { GovCompaniesSeeder } from "./seed/gov-companies.seeder"; import { GovCompaniesSeeder } from "./seed/gov-companies.seeder";
import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder"; import { ApprovedFirstLastMileDemoBookingsSeeder } from "./seed/approved-first-lastmile-demo-bookings.seeder";
import { PaidIndodeDemoBookingsSeeder } from "./seed/paid-indode-demo-bookings.seeder";
//New Trains, Wagons, Container and Cargo management modules //New Trains, Wagons, Container and Cargo management modules
import { TrainsModule } from "./modules/trains/trains.module"; import { TrainsModule } from "./modules/trains/trains.module";
import { WagonsModule } from './modules/wagons/wagons.module'; import { WagonsModule } from './modules/wagons/wagons.module';
@@ -156,6 +157,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
ExportDjiboutiInterchangeDemoSeeder, ExportDjiboutiInterchangeDemoSeeder,
MarshallingDemoTrainsSeeder, MarshallingDemoTrainsSeeder,
ApprovedFirstLastMileDemoBookingsSeeder, ApprovedFirstLastMileDemoBookingsSeeder,
PaidIndodeDemoBookingsSeeder,
], ],
}) })
export class AppModule implements OnApplicationBootstrap { export class AppModule implements OnApplicationBootstrap {
@@ -174,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap {
private readonly warehouseDemoSeeder: WarehouseDemoSeeder, private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder, private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder, private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder,
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder, private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
private readonly demoFreightDataSeeder: DemoFreightDataSeeder, private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
private readonly govCompaniesSeeder: GovCompaniesSeeder, private readonly govCompaniesSeeder: GovCompaniesSeeder,
@@ -195,6 +198,7 @@ export class AppModule implements OnApplicationBootstrap {
await this.warehouseDemoSeeder.run(); await this.warehouseDemoSeeder.run();
await this.exportDjiboutiInterchangeDemoSeeder.run(); await this.exportDjiboutiInterchangeDemoSeeder.run();
await this.marshallingDemoTrainsSeeder.run(); await this.marshallingDemoTrainsSeeder.run();
await this.paidIndodeDemoBookingsSeeder.run();
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users. // 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. // Each block self-guards on an empty-table check, so this is safe every boot.
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder, // Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,

View File

@@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration
{ name: 'zone_id', type: 'uuid', isNullable: true }, { name: 'zone_id', type: 'uuid', isNullable: true },
{ name: 'free_days', type: 'int', default: 0 }, { name: 'free_days', type: 'int', default: 0 },
{ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }, { name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 },
{ name: 'tiers', type: 'jsonb', default: "'[]'" },
{ name: 'currency', type: 'varchar', length: '8', default: "'USD'" }, { name: 'currency', type: 'varchar', length: '8', default: "'USD'" },
{ name: 'is_active', type: 'boolean', default: true }, { name: 'is_active', type: 'boolean', default: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' }, { name: 'created_at', type: 'timestamptz', default: 'now()' },

View File

@@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
`); `);
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoices_company ON freight.invoices (company_id);`, `
ALTER TABLE freight.invoices
ADD COLUMN IF NOT EXISTS id uuid DEFAULT uuid_generate_v4(),
ADD COLUMN IF NOT EXISTS invoice_number varchar(64),
ADD COLUMN IF NOT EXISTS company_id uuid,
ADD COLUMN IF NOT EXISTS company_profile_id uuid,
ADD COLUMN IF NOT EXISTS total_amount numeric(14, 2) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS currency varchar(8) NOT NULL DEFAULT 'ETB',
ADD COLUMN IF NOT EXISTS status freight.invoices_status_enum NOT NULL DEFAULT 'DRAFT',
ADD COLUMN IF NOT EXISTS source varchar(255),
ADD COLUMN IF NOT EXISTS source_id varchar(255),
ADD COLUMN IF NOT EXISTS type varchar(255),
ADD COLUMN IF NOT EXISTS issued_at timestamptz,
ADD COLUMN IF NOT EXISTS payment_id uuid,
ADD COLUMN IF NOT EXISTS due_at timestamptz,
ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS deleted_at timestamptz;
`,
);
await queryRunner.query(`
UPDATE freight.invoices
SET due_at = COALESCE(due_at, issued_at, created_at, now())
WHERE due_at IS NULL;
`);
await queryRunner.query(`ALTER TABLE freight.invoices ALTER COLUMN due_at SET NOT NULL;`);
await queryRunner.query(`
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'pk_invoices'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT pk_invoices PRIMARY KEY (id);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'uq_invoices_invoice_number'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT uq_invoices_invoice_number UNIQUE (invoice_number);
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_company'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company
FOREIGN KEY (company_id) REFERENCES freight.companies (id) ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_company_profile'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_company_profile
FOREIGN KEY (company_profile_id) REFERENCES freight.company_profiles (id) ON DELETE RESTRICT;
END IF;
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'fk_invoices_payment'
AND conrelid = 'freight.invoices'::regclass
) THEN
ALTER TABLE freight.invoices ADD CONSTRAINT fk_invoices_payment
FOREIGN KEY (payment_id) REFERENCES freight.payments (id) ON DELETE SET NULL;
END IF;
END $$;
`);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS idx_invoices_company ON freight.invoices (company_id);`,
); );
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoices_company_profile ON freight.invoices (company_profile_id);`, `CREATE INDEX IF NOT EXISTS idx_invoices_company_profile ON freight.invoices (company_profile_id);`,
); );
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoices_source ON freight.invoices (source, source_id);`, `CREATE INDEX IF NOT EXISTS idx_invoices_source ON freight.invoices (source, source_id);`,
); );
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`, `CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`,
); );
await queryRunner.query(` await queryRunner.query(`
CREATE TABLE freight.invoice_lines ( CREATE TABLE IF NOT EXISTS freight.invoice_lines (
id uuid NOT NULL DEFAULT uuid_generate_v4(), id uuid NOT NULL DEFAULT uuid_generate_v4(),
invoice_id uuid NOT NULL, invoice_id uuid NOT NULL,
charge_type varchar NOT NULL, charge_type varchar NOT NULL,
@@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
`); `);
await queryRunner.query( await queryRunner.query(
`CREATE INDEX idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`, `CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice ON freight.invoice_lines (invoice_id);`,
); );
} }

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddWarehouseFeeRuleTiers1831000000000 implements MigrationInterface {
name = 'AddWarehouseFeeRuleTiers1831000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
ADD COLUMN IF NOT EXISTS tiers jsonb NOT NULL DEFAULT '[]';
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.warehouse_fee_rules
DROP COLUMN IF EXISTS tiers;
`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCustomerTruckAssignmentToBookings1832000000000 implements MigrationInterface {
name = 'AddCustomerTruckAssignmentToBookings1832000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
ADD COLUMN IF NOT EXISTS customer_truck_plate_number varchar(32),
ADD COLUMN IF NOT EXISTS customer_truck_driver_name varchar(120),
ADD COLUMN IF NOT EXISTS customer_truck_type varchar(60),
ADD COLUMN IF NOT EXISTS customer_truck_container_number varchar(16),
ADD COLUMN IF NOT EXISTS customer_truck_assigned_at timestamptz,
ADD COLUMN IF NOT EXISTS customer_truck_arrived_at timestamptz
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE freight.bookings
DROP COLUMN IF EXISTS customer_truck_arrived_at,
DROP COLUMN IF EXISTS customer_truck_assigned_at,
DROP COLUMN IF EXISTS customer_truck_container_number,
DROP COLUMN IF EXISTS customer_truck_type,
DROP COLUMN IF EXISTS customer_truck_driver_name,
DROP COLUMN IF EXISTS customer_truck_plate_number
`);
}
}

View File

@@ -657,6 +657,11 @@ export class BillingService {
throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`); throw new NotFoundException(`No open invoice to charge for ${source}:${sourceId}`);
} }
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
if (!(amountDue > 0)) {
throw new BadRequestException("Invoice has no outstanding balance.");
}
const result = await this.payment.initiate({ const result = await this.payment.initiate({
referenceId: sourceId, referenceId: sourceId,
source: invoice.source, source: invoice.source,
@@ -667,7 +672,7 @@ export class BillingService {
// service branches on a domain-specific reference type. // service branches on a domain-specific reference type.
referenceType: PaymentReferenceType.SHIPMENT, referenceType: PaymentReferenceType.SHIPMENT,
orderRef: invoice.invoiceNumber, orderRef: invoice.invoiceNumber,
amountMinor: Math.round(Number(invoice.totalAmount)), amountMinor: Math.round(amountDue),
currency: invoice.currency, currency: invoice.currency,
reason: `Payment for invoice ${invoice.invoiceNumber}`, reason: `Payment for invoice ${invoice.invoiceNumber}`,
method: opts.method ?? "TELEBIRR", method: opts.method ?? "TELEBIRR",

View File

@@ -54,6 +54,7 @@ import {
StaffRejectDto, StaffRejectDto,
} from './dto/request-changes.dto'; } from './dto/request-changes.dto';
import { ContractViewDto } from './dto/contract-view.dto'; import { ContractViewDto } from './dto/contract-view.dto';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { SignContractDto } from './dto/sign-contract.dto'; import { SignContractDto } from './dto/sign-contract.dto';
import { UpdateBookingDto } from './dto/update-booking.dto'; import { UpdateBookingDto } from './dto/update-booking.dto';
import { import {
@@ -254,6 +255,39 @@ export class BookingsController {
return this.transitionService.enrichBookingResponse(booking); return this.transitionService.enrichBookingResponse(booking);
} }
@Post(':id/customer-truck-assignment')
@ApiOperation({ summary: 'Customer assigns external truck and driver for terminal pickup' })
async assignCustomerTruck(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CustomerTruckAssignmentDto,
@CurrentUser() user: TCurrentUser,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const assigned = await this.bookingsService.assignCustomerTruck(id, dto);
return this.transitionService.enrichBookingResponse(assigned);
}
@Get(':id/customer-truck-assignment/freight-order')
@ApiOperation({ summary: 'Download duplicate freight order copies for customer truck assignment' })
async customerTruckFreightOrder(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() user: TCurrentUser,
@Res() res: Response,
) {
const booking = await this.bookingsService.findById(id);
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
}
const { filename, buffer } =
await this.bookingsService.customerTruckFreightOrderCopies(id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(buffer);
}
@Get(':id/tracking') @Get(':id/tracking')
@ApiOperation({ @ApiOperation({
summary: 'Shipment tracking timeline for a booking', summary: 'Shipment tracking timeline for a booking',

View File

@@ -45,6 +45,8 @@ import {
import { Booking } from './entities/booking.entity'; import { Booking } from './entities/booking.entity';
import { BookingContainerAllocation } from './entities/booking-container-allocation.entity'; import { BookingContainerAllocation } from './entities/booking-container-allocation.entity';
import { FileRecord } from '../files/entities/file.entity'; import { FileRecord } from '../files/entities/file.entity';
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
import { ContractPdfService } from '../../contracts/contract-pdf.service';
/** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */ /** Paginated booking list: flat `total` (backoffice) + `meta` block (portal). */
export interface PaginatedBookings { export interface PaginatedBookings {
@@ -81,8 +83,54 @@ export class BookingsService {
private readonly ruleEngineService: RuleEngineService, private readonly ruleEngineService: RuleEngineService,
private readonly containerTypesService: ContainerTypesService, private readonly containerTypesService: ContainerTypesService,
private readonly consolidationService: ConsolidationService, private readonly consolidationService: ConsolidationService,
private readonly contractPdfService: ContractPdfService,
) {} ) {}
async assignCustomerTruck(
bookingId: string,
dto: CustomerTruckAssignmentDto,
): Promise<Booking> {
const booking = await this.findById(bookingId);
if (booking.lastMileDeliveryAddress?.trim()) {
throw new BadRequestException(
'Customer truck assignment is only allowed when last mile delivery is not selected',
);
}
if (booking.customerTruckAssignedAt) {
throw new ConflictException('Customer truck assignment is already submitted and locked');
}
if (booking.paymentStatus !== 'PAID') {
throw new BadRequestException('Booking must be paid before assigning an external customer truck');
}
await this.bookingsRepository.update(bookingId, {
status: 'TRUCK_ASSIGNED',
customerTruckPlateNumber: dto.truckPlateNumber.trim().toUpperCase(),
customerTruckDriverName: dto.driverName.trim(),
customerTruckType: dto.truckType.trim(),
customerTruckContainerNumber: dto.containerNumberToLoad.trim().toUpperCase(),
customerTruckAssignedAt: new Date(),
});
return this.findById(bookingId);
}
async customerTruckFreightOrderCopies(
bookingId: string,
): Promise<{ filename: string; buffer: Buffer }> {
const booking = await this.findById(bookingId);
if (!booking.customerTruckAssignedAt) {
throw new BadRequestException('Customer truck must be assigned before freight order copies can be generated');
}
const html = this.buildCustomerTruckFreightOrderHtml(booking);
const buffer = await this.contractPdfService.htmlToPdfBuffer(html);
return {
filename: `freight-order-${booking.reference.replace(/[^a-zA-Z0-9_-]+/g, '-')}.pdf`,
buffer,
};
}
/** Resolve trade direction from yard countries; reject client mismatch. */ /** Resolve trade direction from yard countries; reject client mismatch. */
private async resolveTradeDirectionForBooking( private async resolveTradeDirectionForBooking(
originYardId: string, originYardId: string,
@@ -120,6 +168,79 @@ export class BookingsService {
return `BK-${year}-${String(count + 1).padStart(6, '0')}`; return `BK-${year}-${String(count + 1).padStart(6, '0')}`;
} }
private buildCustomerTruckFreightOrderHtml(booking: Booking): string {
const assignedAt = booking.customerTruckAssignedAt
? new Date(booking.customerTruckAssignedAt).toLocaleString('en-GB')
: '-';
const rows: Array<[string, string | null | undefined]> = [
['Booking Reference', booking.reference],
['Client Name', booking.company?.name],
['Client ID', booking.companyId],
['Trade Direction', booking.tradeDirection],
['Freight Type', booking.freightType],
['Truck Plate Number', booking.customerTruckPlateNumber],
['Driver Name', booking.customerTruckDriverName],
['Truck Type', booking.customerTruckType],
['Container Number to Load', booking.customerTruckContainerNumber],
['Assigned At', assignedAt],
['Booking Status', booking.status],
];
const rowHtml = rows
.map(([label, value]) => `<tr><th>${this.escapeHtml(label)}</th><td>${this.escapeHtml(value || '-')}</td></tr>`)
.join('');
const copy = (watermark: string) => `
<section class="copy">
<div class="watermark">${this.escapeHtml(watermark)}</div>
<header>
<div>
<h1>Freight Order</h1>
<p>Customer external truck assignment</p>
</div>
<strong>${this.escapeHtml(booking.reference)}</strong>
</header>
<table>${rowHtml}</table>
<div class="signatures">
<div>Customer / Carrier Signature</div>
<div>Port Operations Verification</div>
<div>Gate Security Verification</div>
</div>
</section>`;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>
body { font-family: Arial, sans-serif; color: #10202f; margin: 0; }
.copy { position: relative; min-height: 46vh; padding: 28px 32px; page-break-inside: avoid; border-bottom: 1px dashed #94a3b8; }
.watermark { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 34px; font-weight: 800; color: rgba(16, 32, 47, 0.08); transform: rotate(-18deg); pointer-events: none; }
header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 3px solid #0a9f6a; padding-bottom: 14px; margin-bottom: 18px; }
h1 { margin: 0; font-size: 28px; letter-spacing: 0; }
p { margin: 4px 0 0; color: #64748b; }
strong { font-size: 16px; color: #0a9f6a; }
table { width: 100%; border-collapse: collapse; position: relative; z-index: 1; }
th, td { border: 1px solid #cbd5e1; padding: 9px 10px; text-align: left; font-size: 12px; }
th { width: 34%; background: #f1f5f9; }
.signatures { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-top: 34px; font-size: 11px; color: #475569; position: relative; z-index: 1; }
.signatures div { border-top: 1px solid #334155; padding-top: 8px; min-height: 28px; }
</style>
</head>
<body>
${copy('Copy 1: Port Operations Copy')}
${copy('Copy 2: Gate Security & Carrier Copy')}
</body>
</html>`;
}
private escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/** Build evaluation input from booking freight shape. */ /** Build evaluation input from booking freight shape. */
/** /**
* Whether a service type bundles customs clearance. This is the single source * Whether a service type bundles customs clearance. This is the single source

View File

@@ -0,0 +1,34 @@
import { IsIn, IsNotEmpty, IsString, Matches, MaxLength } from 'class-validator';
export const CUSTOMER_TRUCK_TYPES = [
'Flatbed',
'Container Chassis',
'Lowboy',
'Box Truck',
'Tipper',
] as const;
export class CustomerTruckAssignmentDto {
@IsString()
@IsNotEmpty()
@MaxLength(32)
truckPlateNumber!: string;
@IsString()
@IsNotEmpty()
@MaxLength(120)
driverName!: string;
@IsString()
@IsNotEmpty()
@IsIn(CUSTOMER_TRUCK_TYPES)
truckType!: string;
@IsString()
@IsNotEmpty()
@MaxLength(16)
@Matches(/^[A-Z]{4}\d{7}$/, {
message: 'containerNumberToLoad must match ISO container format, e.g. ABCD1234567',
})
containerNumberToLoad!: string;
}

View File

@@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [
// Road (truck) drawdown orders skip the train batch pool and wait here for // Road (truck) drawdown orders skip the train batch pool and wait here for
// truck dispatch after Marketing accepts; billed by KM, not wagons. // truck dispatch after Marketing accepts; billed by KM, not wagons.
'ROAD_DISPATCH_PENDING', 'ROAD_DISPATCH_PENDING',
'TRUCK_ASSIGNED',
'OPERATION_REQUESTED', 'OPERATION_REQUESTED',
// Operations review gate: customer picks a schedule day and submits the // Operations review gate: customer picks a schedule day and submits the
// operation request; the operations team reviews capacity/docs/route before // operation request; the operations team reviews capacity/docs/route before
@@ -260,6 +261,24 @@ export class Booking extends BaseEntity {
@Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true }) @Column({ name: 'last_mile_delivery_lng', type: 'numeric', precision: 10, scale: 7, nullable: true })
lastMileDeliveryLng?: number | null; lastMileDeliveryLng?: number | null;
@Column({ name: 'customer_truck_plate_number', type: 'varchar', length: 32, nullable: true })
customerTruckPlateNumber?: string | null;
@Column({ name: 'customer_truck_driver_name', type: 'varchar', length: 120, nullable: true })
customerTruckDriverName?: string | null;
@Column({ name: 'customer_truck_type', type: 'varchar', length: 60, nullable: true })
customerTruckType?: string | null;
@Column({ name: 'customer_truck_container_number', type: 'varchar', length: 16, nullable: true })
customerTruckContainerNumber?: string | null;
@Column({ name: 'customer_truck_assigned_at', type: 'timestamptz', nullable: true })
customerTruckAssignedAt?: Date | null;
@Column({ name: 'customer_truck_arrived_at', type: 'timestamptz', nullable: true })
customerTruckArrivedAt?: Date | null;
@Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false }) @Column({ name: 'customs_clearing_enabled', type: 'boolean', default: false })
customsClearingEnabled!: boolean; customsClearingEnabled!: boolean;

View File

@@ -1,7 +1,8 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional, IsString } from 'class-validator'; import { IsDateString, IsIn, IsOptional, IsString } from 'class-validator';
export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [ export const IMPORT_DJIBOUTI_DOCUMENT_TYPES = [
'GATE_PASS',
'DELIVERY_ORDER', 'DELIVERY_ORDER',
'PORT_INVOICE', 'PORT_INVOICE',
'DJIBOUTI_T1', 'DJIBOUTI_T1',
@@ -43,6 +44,26 @@ export class UploadImportDjiboutiDocumentDto {
} }
export class ImportDjiboutiActionDto { export class ImportDjiboutiActionDto {
@ApiPropertyOptional({ description: 'Gate pass secured date/time. Defaults to now.' })
@IsOptional()
@IsDateString()
securedAt?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
fileId?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
fileUrl?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
reference?: string;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity'; import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
export type ImportDjiboutiDocumentType = export type ImportDjiboutiDocumentType =
| 'GATE_PASS'
| 'DELIVERY_ORDER' | 'DELIVERY_ORDER'
| 'PORT_INVOICE' | 'PORT_INVOICE'
| 'DJIBOUTI_T1' | 'DJIBOUTI_T1'

View File

@@ -54,7 +54,6 @@ import {
type ImportDjiboutiDocumentType, type ImportDjiboutiDocumentType,
} from './entities/import-djibouti-operation.entity'; } from './entities/import-djibouti-operation.entity';
import { import {
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
ImportDjiboutiActionDto, ImportDjiboutiActionDto,
UploadImportDjiboutiDocumentDto, UploadImportDjiboutiDocumentDto,
} from './dto/import-djibouti-operation.dto'; } from './dto/import-djibouti-operation.dto';
@@ -832,7 +831,7 @@ export class TrainSchedulingService {
} }
async getImportDjiboutiOperation(scheduleId: string) { async getImportDjiboutiOperation(scheduleId: string) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId); const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
return this.mapImportDjiboutiOperation(schedule, operation); return this.mapImportDjiboutiOperation(schedule, operation);
} }
@@ -841,7 +840,7 @@ export class TrainSchedulingService {
scheduleId: string, scheduleId: string,
dto: UploadImportDjiboutiDocumentDto, dto: UploadImportDjiboutiDocumentDto,
) { ) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId); const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const documents = { const documents = {
...(operation.documents ?? {}), ...(operation.documents ?? {}),
@@ -865,21 +864,30 @@ export class TrainSchedulingService {
} }
async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) { async grantImportDjiboutiGatepass(scheduleId: string, dto: ImportDjiboutiActionDto = {}) {
const schedule = await this.getImportDjiboutiSchedule(scheduleId); const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId); const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
const missing = this.missingImportDjiboutiDocuments(operation); const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
if (missing.length) { const documents = { ...(operation.documents ?? {}) };
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`); if (dto.fileId || dto.fileUrl || dto.reference || dto.notes) {
documents.GATE_PASS = {
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, { await this.dataSource.getRepository(ImportDjiboutiOperation).update(operation.id, {
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(), documents,
gatepassGrantedAt: securedAt,
performedBy: dto.performedBy ?? operation.performedBy ?? null, performedBy: dto.performedBy ?? operation.performedBy ?? null,
notes: dto.notes ?? operation.notes ?? null, notes: dto.notes ?? operation.notes ?? null,
}); });
console.log( console.log(
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`, `[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
); );
return this.getImportDjiboutiOperation(schedule.id); return this.getImportDjiboutiOperation(schedule.id);
} }
@@ -1299,16 +1307,28 @@ export class TrainSchedulingService {
} }
private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> { private async getImportDjiboutiSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
if (!this.isImportDjiboutiSchedule(schedule)) {
throw new BadRequestException('This action applies only to IMPORT schedules originating from Djibouti');
}
return schedule;
}
private async getDjiboutiGatepassSchedule(scheduleId: string): Promise<TrainSchedule> {
const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId); const schedule = await this.trainSchedulesRepository.findByIdWithFullGraph(scheduleId);
if (!schedule) { if (!schedule) {
throw new NotFoundException(`Train schedule ${scheduleId} not found`); throw new NotFoundException(`Train schedule ${scheduleId} not found`);
} }
if (!this.isImportDjiboutiSchedule(schedule)) { if (!this.isDjiboutiGatepassSchedule(schedule)) {
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti'); throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
} }
return schedule; return schedule;
} }
private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
}
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean { private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
const direction = const direction =
(schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ?? (schedule.direction as 'IMPORT' | 'EXPORT' | 'DOMESTIC' | null) ??
@@ -1323,6 +1343,20 @@ export class TrainSchedulingService {
); );
} }
private isExportDjiboutiSchedule(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' &&
this.isDjiboutiPortDestination(
`${schedule.destinationStation?.code ?? ''} ${schedule.destinationStation?.label ?? ''}`,
)
);
}
private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> { private async getOrCreateImportDjiboutiOperation(scheduleId: string): Promise<ImportDjiboutiOperation> {
const repo = this.dataSource.getRepository(ImportDjiboutiOperation); const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } }); const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
@@ -1331,8 +1365,8 @@ export class TrainSchedulingService {
} }
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] { private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
const documents = operation?.documents ?? {}; void operation;
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]); return [];
} }
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void { private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
@@ -1343,6 +1377,7 @@ export class TrainSchedulingService {
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) { private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
const missingDocuments = this.missingImportDjiboutiDocuments(operation); const missingDocuments = this.missingImportDjiboutiDocuments(operation);
const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
return { return {
trainScheduleId: schedule.id, trainScheduleId: schedule.id,
trainNumber: schedule.trainNumber ?? null, trainNumber: schedule.trainNumber ?? null,
@@ -1350,6 +1385,7 @@ export class TrainSchedulingService {
status: { status: {
documentsComplete: missingDocuments.length === 0, documentsComplete: missingDocuments.length === 0,
missingDocuments, missingDocuments,
gatepassStatus,
gatepassGranted: Boolean(operation.gatepassGrantedAt), gatepassGranted: Boolean(operation.gatepassGrantedAt),
readyForLoading: Boolean(operation.readyForLoadingAt), readyForLoading: Boolean(operation.readyForLoadingAt),
loadedOnTrain: Boolean(operation.loadedOnTrainAt), loadedOnTrain: Boolean(operation.loadedOnTrainAt),
@@ -1358,6 +1394,8 @@ export class TrainSchedulingService {
}, },
documents: operation.documents ?? {}, documents: operation.documents ?? {},
gatepassGrantedAt: operation.gatepassGrantedAt ?? null, gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
gatepassStatus,
readyForLoadingAt: operation.readyForLoadingAt ?? null, readyForLoadingAt: operation.readyForLoadingAt ?? null,
loadedOnTrainAt: operation.loadedOnTrainAt ?? null, loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null, departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,

View File

@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsBoolean, IsIn, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { ValidateNested } from 'class-validator'; import { ValidateNested } from 'class-validator';
export class TruckEntranceDto { export class TruckEntranceDto {
@@ -90,6 +90,11 @@ export class TruckEntranceDto {
@Min(0) @Min(0)
grossWeightKg?: number; grossWeightKg?: number;
@ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' })
@IsOptional()
@IsBoolean()
weighingRequired?: boolean;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()
@IsNumber() @IsNumber()
@@ -135,10 +140,11 @@ export class TruckEntranceDto {
@IsString() @IsString()
truckType?: string; truckType?: string;
@ApiProperty() @ApiPropertyOptional()
@IsOptional()
@IsNumber() @IsNumber()
@Min(0) @Min(0)
entranceTareWeightKg!: number; entranceTareWeightKg?: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@IsOptional() @IsOptional()

View File

@@ -1,8 +1,27 @@
import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger';
import { IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min } from 'class-validator'; import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsInt, IsNumber, IsOptional, IsString, IsUUID, Min, ValidateNested } from 'class-validator';
import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity'; import { FEE_RULE_TYPES, FeeRuleType } from '../entities/warehouse-fee-rule.entity';
export class FeeRuleTierDto {
@ApiProperty({ example: 4 })
@IsInt()
@Min(1)
fromDay!: number;
@ApiPropertyOptional({ example: 4, description: 'Inclusive. Leave empty for an open-ended tier.' })
@IsOptional()
@IsInt()
@Min(1)
toDay?: number | null;
@ApiProperty({ example: 2500 })
@IsNumber()
@Min(0)
ratePerDay!: number;
}
export class CreateFeeRuleDto { export class CreateFeeRuleDto {
@ApiProperty() @ApiProperty()
@IsString() @IsString()
@@ -67,6 +86,13 @@ export class CreateFeeRuleDto {
@Min(0) @Min(0)
ratePerDay!: number; ratePerDay!: number;
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => FeeRuleTierDto)
tiers?: FeeRuleTierDto[];
@ApiPropertyOptional({ default: 'USD' }) @ApiPropertyOptional({ default: 'USD' })
@IsOptional() @IsOptional()
@IsString() @IsString()

View File

@@ -4,6 +4,12 @@ import { Column, Entity, Index } from 'typeorm';
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const; export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
export type FeeRuleType = (typeof FEE_RULE_TYPES)[number]; export type FeeRuleType = (typeof FEE_RULE_TYPES)[number];
export interface WarehouseFeeTier {
fromDay: number;
toDay: number | null;
ratePerDay: number;
}
/** /**
* Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6). * Batch 5 — configurable storage / demurrage fee rules (no invoice/payment here — that is Batch 6).
* The most specific active rule (highest `specificity` then lowest `priority`) applies to an item. * The most specific active rule (highest `specificity` then lowest `priority`) applies to an item.
@@ -54,6 +60,9 @@ export class WarehouseFeeRule extends BaseEntity {
@Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 }) @Column({ name: 'rate_per_day', type: 'numeric', precision: 14, scale: 2, default: 0 })
ratePerDay!: number; ratePerDay!: number;
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
tiers!: WarehouseFeeTier[];
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' }) @Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
currency!: string; currency!: string;

View File

@@ -1,9 +1,9 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { ExchangeService } from '@edr/api-common'; import { ExchangeService } from '@edr/api-common';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto'; import { CreateFeeRuleDto, UpdateFeeRuleDto } from './dto/fee-rule.dto';
import { FeeRuleType, WarehouseFeeRule } from './entities/warehouse-fee-rule.entity'; import { FeeRuleType, WarehouseFeeRule, WarehouseFeeTier } from './entities/warehouse-fee-rule.entity';
import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository'; import { WarehouseFeeRuleRepository } from './warehouse-fee-rule.repository';
interface ItemAttributes { interface ItemAttributes {
@@ -39,6 +39,15 @@ export interface FeePreview {
containerCount: number; containerCount: number;
billableUnits: number; billableUnits: number;
amount: number; amount: number;
tiers: Array<{
fromDay: number;
toDay: number | null;
appliedFromDay: number;
appliedToDay: number;
days: number;
ratePerDay: number;
amount: number;
}>;
} }
const MS_PER_DAY = 24 * 60 * 60 * 1000; const MS_PER_DAY = 24 * 60 * 60 * 1000;
@@ -57,11 +66,16 @@ export class WarehouseFeeService {
} }
createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> { createRule(dto: CreateFeeRuleDto): Promise<WarehouseFeeRule> {
return this.feeRuleRepository.create({ isActive: true, priority: 100, currency: 'USD', ...dto }); return this.feeRuleRepository.create({
isActive: true,
priority: 100,
currency: 'USD',
...this.normalizeRuleInput(dto),
});
} }
async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> { async updateRule(id: string, dto: UpdateFeeRuleDto): Promise<WarehouseFeeRule> {
const updated = await this.feeRuleRepository.update(id, dto); const updated = await this.feeRuleRepository.update(id, this.normalizeRuleInput(dto));
if (!updated) throw new NotFoundException(`Fee rule ${id} not found`); if (!updated) throw new NotFoundException(`Fee rule ${id} not found`);
return updated; return updated;
} }
@@ -70,6 +84,40 @@ export class WarehouseFeeService {
return this.feeRuleRepository.softDelete(id); return this.feeRuleRepository.softDelete(id);
} }
private normalizeRuleInput<T extends CreateFeeRuleDto | UpdateFeeRuleDto>(dto: T): T {
if (dto.tiers === undefined) return dto;
const tiers = (dto.tiers ?? [])
.map((tier) => ({
fromDay: Number(tier.fromDay),
toDay: tier.toDay == null ? null : Number(tier.toDay),
ratePerDay: Number(tier.ratePerDay),
}))
.filter((tier) => tier.fromDay > 0 || tier.toDay != null || tier.ratePerDay > 0);
for (const tier of tiers) {
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
throw new BadRequestException('Fee tier from day must be a positive whole number.');
}
if (tier.toDay != null && (!Number.isInteger(tier.toDay) || tier.toDay < tier.fromDay)) {
throw new BadRequestException('Fee tier to day must be empty or greater than/equal to from day.');
}
if (!Number.isFinite(tier.ratePerDay) || tier.ratePerDay < 0) {
throw new BadRequestException('Fee tier rate per day must be zero or greater.');
}
}
const sorted = [...tiers].sort((a, b) => a.fromDay - b.fromDay || (a.toDay ?? Infinity) - (b.toDay ?? Infinity));
for (let i = 1; i < sorted.length; i += 1) {
const prev = sorted[i - 1];
const current = sorted[i];
if (prev.toDay == null || current.fromDay <= prev.toDay) {
throw new BadRequestException('Fee tiers cannot overlap. Use separate from/to day ranges.');
}
}
return { ...dto, tiers: sorted } as T;
}
private async loadItem(inventoryId: string): Promise<ItemAttributes> { private async loadItem(inventoryId: string): Promise<ItemAttributes> {
const [row] = await this.dataSource.query( const [row] = await this.dataSource.query(
`SELECT inv.arrived_at AS "arrivedAt", `SELECT inv.arrived_at AS "arrivedAt",
@@ -82,16 +130,27 @@ export class WarehouseFeeService {
w.facility_id AS "facilityId", w.facility_id AS "facilityId",
b.freight_type AS "freightType", b.freight_type AS "freightType",
b.trade_direction AS "tradeDirection", b.trade_direction AS "tradeDirection",
cgt.code AS "cargoTypeCode", COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
ctt.code AS "containerTypeCode", COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount" COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
FROM freight.warehouse_inventory inv FROM freight.warehouse_inventory inv
LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id LEFT JOIN freight.warehouses w ON w.id = inv.warehouse_id
LEFT JOIN freight.bookings b ON b.id = inv.booking_id LEFT JOIN freight.bookings b ON b.id = inv.booking_id
LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id LEFT JOIN freight.cargoes cg ON cg.id = inv.cargo_id
LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id LEFT JOIN freight.cargo_types cgt ON cgt.id = cg.cargo_type_id
LEFT JOIN freight.cargo_types booking_cgt ON booking_cgt.id = b.cargo_type_id
LEFT JOIN freight.containers ct ON ct.id = inv.container_id LEFT JOIN freight.containers ct ON ct.id = inv.container_id
LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id LEFT JOIN freight.container_types ctt ON ctt.id = ct.container_type_id
LEFT JOIN LATERAL (
SELECT bc.container_type_id
FROM freight.booking_container bc
WHERE bc.booking_id = inv.booking_id
AND bc.deleted_at IS NULL
AND bc.container_type_id IS NOT NULL
ORDER BY bc.created_at ASC
LIMIT 1
) booking_container_type ON true
LEFT JOIN freight.container_types booking_ctt ON booking_ctt.id = booking_container_type.container_type_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
FROM freight.booking_container bc FROM freight.booking_container bc
@@ -108,16 +167,26 @@ export class WarehouseFeeService {
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null { private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails. // Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
let score = 0; let score = 0;
const check = (ruleVal: string | null | undefined, itemVal: string | null) => { const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
if (ruleVal == null) return true; const check = (
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) { ruleVal: string | null | undefined,
itemVal: string | null,
opts: { allowBoth?: boolean } = {},
) => {
const ruleCode = normalized(ruleVal);
if (ruleCode == null || ruleCode === 'ANY' || ruleCode === 'ALL') return true;
if (opts.allowBoth && ruleCode === 'BOTH') {
score += 1;
return true;
}
if (ruleCode === normalized(itemVal)) {
score += 1; score += 1;
return true; return true;
} }
return false; return false;
}; };
if (!check(rule.freightType, item.freightType)) return null; if (!check(rule.freightType, item.freightType)) return null;
if (!check(rule.tradeDirection, item.tradeDirection)) return null; if (!check(rule.tradeDirection, item.tradeDirection, { allowBoth: true })) return null;
if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null; if (!check(rule.cargoTypeCode, item.cargoTypeCode)) return null;
if (!check(rule.containerType, item.containerTypeCode)) return null; if (!check(rule.containerType, item.containerTypeCode)) return null;
if (!check(rule.facilityId, item.facilityId)) return null; if (!check(rule.facilityId, item.facilityId)) return null;
@@ -153,6 +222,60 @@ export class WarehouseFeeService {
return Math.round(amount * rate * 100) / 100; return Math.round(amount * rate * 100) / 100;
} }
private calculateTieredAmount(
tiers: WarehouseFeeTier[] | null | undefined,
elapsedDays: number,
containerCount: number,
): {
sourceAmount: number;
billableUnits: number;
chargeableDays: number;
weightedRatePerDay: number;
tiers: FeePreview['tiers'];
} {
const sourceTiers = (tiers ?? [])
.map((tier) => ({
fromDay: Number(tier.fromDay),
toDay: tier.toDay == null ? null : Number(tier.toDay),
ratePerDay: Number(tier.ratePerDay),
}))
.filter((tier) => Number.isFinite(tier.fromDay) && tier.fromDay > 0 && Number.isFinite(tier.ratePerDay))
.sort((a, b) => a.fromDay - b.fromDay);
let sourceAmount = 0;
let tierDays = 0;
const appliedTiers: FeePreview['tiers'] = [];
for (const tier of sourceTiers) {
if (elapsedDays < tier.fromDay) continue;
const appliedFromDay = tier.fromDay;
const appliedToDay = Math.min(elapsedDays, tier.toDay ?? elapsedDays);
const days = Math.max(0, appliedToDay - appliedFromDay + 1);
if (days <= 0) continue;
const amount = Math.round(days * containerCount * tier.ratePerDay * 100) / 100;
sourceAmount += amount;
tierDays += days;
appliedTiers.push({
fromDay: tier.fromDay,
toDay: tier.toDay,
appliedFromDay,
appliedToDay,
days,
ratePerDay: tier.ratePerDay,
amount,
});
}
return {
sourceAmount: Math.round(sourceAmount * 100) / 100,
billableUnits: tierDays * containerCount,
chargeableDays: tierDays,
weightedRatePerDay: tierDays > 0 ? Math.round((sourceAmount / tierDays / containerCount) * 100) / 100 : 0,
tiers: appliedTiers,
};
}
private async compute( private async compute(
ruleType: FeeRuleType, ruleType: FeeRuleType,
rule: WarehouseFeeRule | null, rule: WarehouseFeeRule | null,
@@ -176,13 +299,25 @@ export class WarehouseFeeService {
const elapsedDays = start const elapsedDays = start
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY)) ? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
: 0; : 0;
const chargeableDays = Math.max(0, elapsedDays - freeDays); const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
const billableUnits = chargeableDays * containerCount; const hasTiers = Boolean(rule?.tiers?.length);
const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100; const chargeableDays = hasTiers ? tiered.chargeableDays : Math.max(0, elapsedDays - freeDays);
const billableUnits = hasTiers ? tiered.billableUnits : chargeableDays * containerCount;
const sourceAmount = hasTiers ? tiered.sourceAmount : Math.round(billableUnits * ratePerDay * 100) / 100;
const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0; const amount = ruleCurrency ? await this.convertAmount(sourceAmount, ruleCurrency, targetCurrency) : 0;
const sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
const convertedRatePerDay = ruleCurrency const convertedRatePerDay = ruleCurrency
? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency) ? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
: 0; : 0;
const convertedTiers = ruleCurrency
? await Promise.all(
tiered.tiers.map(async (tier) => ({
...tier,
ratePerDay: await this.convertAmount(tier.ratePerDay, ruleCurrency, targetCurrency),
amount: await this.convertAmount(tier.amount, ruleCurrency, targetCurrency),
})),
)
: [];
return { return {
ruleType, ruleType,
@@ -201,6 +336,7 @@ export class WarehouseFeeService {
containerCount, containerCount,
billableUnits, billableUnits,
amount, amount,
tiers: hasTiers ? convertedTiers : [],
}; };
} }

View File

@@ -221,6 +221,11 @@ export interface EligibleBookingRow {
firstMileDriverPhone: string | null; firstMileDriverPhone: string | null;
firstMileDriverLicenseNumber: string | null; firstMileDriverLicenseNumber: string | null;
firstMileTruckType: string | null; firstMileTruckType: string | null;
customerTruckPlateNumber: string | null;
customerTruckDriverName: string | null;
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
} }
export interface BulkReceiveResult { export interface BulkReceiveResult {
@@ -302,6 +307,11 @@ export interface ImportUnloadedRow {
inspectionStatus: string | null; inspectionStatus: string | null;
pickupOption: string; pickupOption: string;
lastMileRequested: boolean; lastMileRequested: boolean;
customerTruckPlateNumber: string | null;
customerTruckDriverName: string | null;
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
currentStatus: string; currentStatus: string;
releaseDate: string | null; releaseDate: string | null;
releaseOrderReference: string | null; releaseOrderReference: string | null;
@@ -704,7 +714,12 @@ export class WarehouseInventoryService {
) AS "firstMileDriverName", ) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone", driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber", driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType" v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings b FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id 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 oy ON oy.id = b.origin_yard_id
@@ -796,7 +811,12 @@ export class WarehouseInventoryService {
) AS "firstMileDriverName", ) AS "firstMileDriverName",
driver.phone_number AS "firstMileDriverPhone", driver.phone_number AS "firstMileDriverPhone",
driver.license_number AS "firstMileDriverLicenseNumber", driver.license_number AS "firstMileDriverLicenseNumber",
v.vehicle_type AS "firstMileTruckType" v.vehicle_type AS "firstMileTruckType",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt"
FROM freight.bookings b FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id 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 oy ON oy.id = b.origin_yard_id
@@ -1044,6 +1064,11 @@ export class WarehouseInventoryService {
CASE WHEN b.last_mile_delivery_address IS NOT NULL CASE WHEN b.last_mile_delivery_address IS NOT NULL
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption", THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
(b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested", (b.last_mile_delivery_address IS NOT NULL) AS "lastMileRequested",
b.customer_truck_plate_number AS "customerTruckPlateNumber",
b.customer_truck_driver_name AS "customerTruckDriverName",
b.customer_truck_type AS "customerTruckType",
b.customer_truck_container_number AS "customerTruckContainerNumber",
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
inv.status AS "currentStatus", inv.status AS "currentStatus",
inv.release_date AS "releaseDate", inv.release_date AS "releaseDate",
inv.release_order_reference AS "releaseOrderReference", inv.release_order_reference AS "releaseOrderReference",
@@ -1336,6 +1361,16 @@ export class WarehouseInventoryService {
if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) { if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) {
throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`); throw new BadRequestException(`Train schedule is ${schedule.status}, not ARRIVED`);
} }
const [gatepass] = await this.dataSource.query(
`SELECT gatepass_granted_at AS "gatepassSecuredAt"
FROM freight.import_djibouti_operations
WHERE train_schedule_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[scheduleId],
);
if (!gatepass?.gatepassSecuredAt) {
throw new BadRequestException('Djibouti Port entry blocked: gate pass status is NOT_SECURED');
}
const items: Array<{ const items: Array<{
bookingId: string; bookingId: string;
@@ -1955,6 +1990,9 @@ export class WarehouseInventoryService {
} }
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime); const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
if (isTruckLeaving) {
await this.invoices.assertClearanceAllowed(id);
}
const releaseDate = isTruckLeaving const releaseDate = isTruckLeaving
? dto.releaseDate ? new Date(dto.releaseDate) : new Date() ? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
: item.releaseDate ?? null; : item.releaseDate ?? null;
@@ -1967,6 +2005,17 @@ export class WarehouseInventoryService {
releaseOrderReference: reference, releaseOrderReference: reference,
notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote), notes: this.replaceExitInspectionNote(item.notes, exitInspectionNote),
}); });
if (!isTruckLeaving && item.bookingId) {
await manager.query(
`UPDATE freight.bookings
SET customer_truck_arrived_at = COALESCE(customer_truck_arrived_at, NOW()),
updated_at = NOW()
WHERE id = $1
AND customer_truck_assigned_at IS NOT NULL
AND deleted_at IS NULL`,
[item.bookingId],
);
}
await this.activityLog.record( await this.activityLog.record(
{ {
activityType: 'INVENTORY_RELEASED', activityType: 'INVENTORY_RELEASED',
@@ -2034,6 +2083,7 @@ export class WarehouseInventoryService {
if (!row.releaseDate) { if (!row.releaseDate) {
throw new BadRequestException('A release order must be issued before downloading the exit paper'); throw new BadRequestException('A release order must be issued before downloading the exit paper');
} }
await this.invoices.assertClearanceAllowed(id);
const bookingReference = row?.bookingReference || 'N/A'; const bookingReference = row?.bookingReference || 'N/A';
const reference = const reference =
@@ -2180,11 +2230,19 @@ export class WarehouseInventoryService {
throw new BadRequestException('Please save your signature before approving delivery'); throw new BadRequestException('Please save your signature before approving delivery');
} }
const [item]: Array<{ id: string; warehouseId: string | null; notes: string | null }> = const [item]: Array<{
id: string;
warehouseId: string | null;
notes: string | null;
customerTruckAssignedAt: string | null;
customerTruckArrivedAt: string | null;
}> =
await this.dataSource.query( await this.dataSource.query(
`SELECT inv.id, `SELECT inv.id,
inv.warehouse_id AS "warehouseId", inv.warehouse_id AS "warehouseId",
inv.notes inv.notes,
b.customer_truck_assigned_at AS "customerTruckAssignedAt",
b.customer_truck_arrived_at AS "customerTruckArrivedAt"
FROM freight.warehouse_inventory inv FROM freight.warehouse_inventory inv
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
WHERE inv.booking_id = $1 WHERE inv.booking_id = $1
@@ -2198,6 +2256,10 @@ export class WarehouseInventoryService {
if (!item) { if (!item) {
throw new BadRequestException('Delivery can be approved after warehouse inspection has passed'); throw new BadRequestException('Delivery can be approved after warehouse inspection has passed');
} }
if (item.customerTruckAssignedAt && !item.customerTruckArrivedAt) {
throw new BadRequestException('Customer truck arrival must be recorded before delivery approval');
}
await this.invoices.assertClearanceAllowed(item.id);
const approvedAt = new Date(); const approvedAt = new Date();
const approval = { const approval = {
@@ -2301,6 +2363,7 @@ export class WarehouseInventoryService {
if (!row) { if (!row) {
throw new NotFoundException(`Inventory item ${id} not found`); throw new NotFoundException(`Inventory item ${id} not found`);
} }
await this.invoices.assertClearanceAllowed(id);
if (row.inspectionStatus !== 'PASSED') { if (row.inspectionStatus !== 'PASSED') {
throw new BadRequestException('Handover document is available after inspection has passed'); throw new BadRequestException('Handover document is available after inspection has passed');
} }
@@ -3302,8 +3365,13 @@ export class WarehouseInventoryService {
if (!truckEntrance.driverPhone?.trim()) { if (!truckEntrance.driverPhone?.trim()) {
throw new BadRequestException('Driver phone is required for entrance registration'); throw new BadRequestException('Driver phone is required for entrance registration');
} }
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) { if (truckEntrance.weighingRequired) {
throw new BadRequestException('Entrance tare weight is required for entrance registration'); if (truckEntrance.grossWeightKg === undefined || Number(truckEntrance.grossWeightKg) < 0) {
throw new BadRequestException('Gross weight is required when customer truck weighing is Yes');
}
if (truckEntrance.exitTareWeightKg === undefined || Number(truckEntrance.exitTareWeightKg) < 0) {
throw new BadRequestException('Exit tare weight is required when customer truck weighing is Yes');
}
} }
} }
@@ -3325,6 +3393,11 @@ export class WarehouseInventoryService {
firstMileDriverPhone?: string | null; firstMileDriverPhone?: string | null;
firstMileDriverLicenseNumber?: string | null; firstMileDriverLicenseNumber?: string | null;
firstMileTruckType?: string | null; firstMileTruckType?: string | null;
customerTruckPlateNumber?: string | null;
customerTruckDriverName?: string | null;
customerTruckType?: string | null;
customerTruckContainerNumber?: string | null;
customerTruckAssignedAt?: string | null;
}, },
): TruckEntranceDto { ): TruckEntranceDto {
return { return {
@@ -3340,16 +3413,22 @@ export class WarehouseInventoryService {
booking.containerQuantity !== undefined && booking.containerQuantity !== null booking.containerQuantity !== undefined && booking.containerQuantity !== null
? Number(booking.containerQuantity) ? Number(booking.containerQuantity)
: submitted.unitCount, : submitted.unitCount,
grossWeightKg: grossWeightKg: submitted.grossWeightKg,
booking.weight !== undefined && booking.weight !== null truckPlateNumber:
? Number(booking.weight) booking.firstMileTruckPlateNumber?.trim() ||
: submitted.grossWeightKg, booking.customerTruckPlateNumber?.trim() ||
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber, submitted.truckPlateNumber,
trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber, trailerPlateNumber: booking.firstMileTrailerPlateNumber?.trim() || submitted.trailerPlateNumber,
driverName: booking.firstMileDriverName?.trim() || submitted.driverName, driverName:
booking.firstMileDriverName?.trim() ||
booking.customerTruckDriverName?.trim() ||
submitted.driverName,
driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone, driverPhone: booking.firstMileDriverPhone?.trim() || submitted.driverPhone,
driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber, driverLicenseNumber: booking.firstMileDriverLicenseNumber?.trim() || submitted.driverLicenseNumber,
truckType: booking.firstMileTruckType?.trim() || submitted.truckType, truckType:
booking.firstMileTruckType?.trim() ||
booking.customerTruckType?.trim() ||
submitted.truckType,
}; };
} }
@@ -3622,6 +3701,7 @@ export class WarehouseInventoryService {
truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null, truck?.driverPhone ? `Driver Phone: ${truck.driverPhone}` : null,
truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null, truck?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : null,
truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null, truck?.entranceTareWeightKg !== undefined ? `Entrance Tare Weight: ${Number(truck.entranceTareWeightKg)} kg` : null,
truck?.weighingRequired !== undefined ? `Weighing Required: ${truck.weighingRequired ? 'Yes' : 'No'}` : null,
truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null, truck?.exitTareWeightKg !== undefined ? `Exit Tare Weight: ${Number(truck.exitTareWeightKg)} kg` : null,
truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null, truck?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null, truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,

View File

@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import type { Response } from 'express'; import type { Response } from 'express';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto'; import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
import { WarehouseInvoiceService } from './warehouse-invoice.service'; import { WarehouseInvoiceService } from './warehouse-invoice.service';
@@ -86,4 +87,10 @@ export class WarehouseInvoiceController {
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) { pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
return this.invoiceService.pay(id, dto); return this.invoiceService.pay(id, dto);
} }
@Post('warehouse-fee-invoices/:id/pay-online')
@ApiOperation({ summary: 'Initiate Telebirr/Waafi payment for a warehouse fee invoice' })
payOnline(@Param('id', ParseUUIDPipe) id: string, @Body() dto: GatewayPayInvoiceDto) {
return this.invoiceService.initiatePayment(id, dto);
}
} }

View File

@@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
import { Freight } from '@edr/types'; import { Freight } from '@edr/types';
import { DataSource } from 'typeorm'; import { DataSource } from 'typeorm';
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service'; import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
import { Invoice } from '../billing/entities/invoice.entity'; import { Invoice } from '../billing/entities/invoice.entity';
import { InvoiceLine } from '../billing/entities/invoice-line.entity'; import { InvoiceLine } from '../billing/entities/invoice-line.entity';
@@ -171,8 +172,12 @@ export class WarehouseInvoiceService {
feeType, feeType,
description: description:
p.ruleType === 'STORAGE_FEE' p.ruleType === 'STORAGE_FEE'
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free` ? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`, p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
}`
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s)${
p.tiers.length ? ' using tiered tariff' : ` after ${p.freeDays} free`
}`,
quantity: p.billableUnits, quantity: p.billableUnits,
unitRate: p.ratePerDay, unitRate: p.ratePerDay,
amount: p.amount, amount: p.amount,
@@ -305,6 +310,22 @@ export class WarehouseInvoiceService {
return detail; return detail;
} }
/** Initiate a wallet/gateway payment for the invoice. */
async initiatePayment(id: string, dto: GatewayPayInvoiceDto = {}) {
const invoice = await this.loadWarehouseInvoice(id);
if (invoice.status === Freight.InvoiceStatus.Paid) {
throw new BadRequestException('Invoice is already fully paid.');
}
return this.billing.payInvoice(invoice.source as Freight.InvoiceSource, invoice.sourceId, {
method: dto.method ?? (invoice.currency === 'USD' ? 'WAAFI' : 'TELEBIRR'),
platform: dto.platform ?? 'web',
payerAccount: dto.payerAccount,
returnUrl: dto.returnUrl,
failureUrl: dto.failureUrl,
});
}
/** /**
* Notify on online (gateway) settlement — the domain side-effect of a warehouse * Notify on online (gateway) settlement — the domain side-effect of a warehouse
* fee being paid through billing's payment flow. The counter {@link pay} path * fee being paid through billing's payment flow. The counter {@link pay} path

File diff suppressed because it is too large Load Diff

View File

@@ -72,6 +72,13 @@ function FeeCard({ fee }: { fee: FeePreview }) {
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} /> <Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
<Row label="Containers" value={String(fee.containerCount ?? 1)} /> <Row label="Containers" value={String(fee.containerCount ?? 1)} />
<Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} /> <Row label="Billable units" value={`${fee.billableUnits ?? fee.chargeableDays} container-day(s)`} />
{(fee.tiers ?? []).map((tier) => (
<Row
key={`${tier.appliedFromDay}-${tier.appliedToDay}-${tier.ratePerDay}`}
label={`Days ${tier.appliedFromDay}-${tier.appliedToDay}`}
value={`${tier.days} x ${money(tier.ratePerDay, fee.currency)} = ${money(tier.amount, fee.currency)}`}
/>
))}
</Stack> </Stack>
)} )}
</Card> </Card>

View File

@@ -142,6 +142,7 @@ interface TruckEntranceFormState {
packagingType: string; packagingType: string;
unitCount: number | ''; unitCount: number | '';
grossWeightKg: number | ''; grossWeightKg: number | '';
weighingRequired: boolean | null;
netWeightKg: number | ''; netWeightKg: number | '';
volumeDimensions: string; volumeDimensions: string;
conditionAtReceipt: string; conditionAtReceipt: string;
@@ -163,11 +164,17 @@ interface LockedTruckEntranceFields {
tin?: boolean; tin?: boolean;
edrDigitalBookingId?: boolean; edrDigitalBookingId?: boolean;
customerPhone?: boolean; customerPhone?: boolean;
truckPlateNumber?: boolean;
trailerPlateNumber?: boolean;
assignedEquipmentNumber?: boolean; assignedEquipmentNumber?: boolean;
itemDescription?: boolean; itemDescription?: boolean;
packagingType?: boolean; packagingType?: boolean;
unitCount?: boolean; unitCount?: boolean;
grossWeightKg?: boolean; grossWeightKg?: boolean;
driverName?: boolean;
driverPhone?: boolean;
driverLicenseNumber?: boolean;
truckType?: boolean;
} }
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED'; type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
@@ -190,6 +197,7 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
packagingType: '', packagingType: '',
unitCount: '', unitCount: '',
grossWeightKg: '', grossWeightKg: '',
weighingRequired: null,
netWeightKg: '', netWeightKg: '',
volumeDimensions: '', volumeDimensions: '',
conditionAtReceipt: '', conditionAtReceipt: '',
@@ -206,13 +214,24 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
}); });
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({ 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,
customerPhone: form.customerPhone.trim() || undefined,
truckPlateNumber: form.truckPlateNumber.trim(), truckPlateNumber: form.truckPlateNumber.trim(),
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined, trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
customsSealNumber: form.customsSealNumber.trim() || undefined, customsSealNumber: form.customsSealNumber.trim() || undefined,
declarationNumber: form.declarationNumber.trim() || undefined, declarationNumber: form.declarationNumber.trim() || undefined,
incoterms: form.incoterms.trim() || undefined, incoterms: form.incoterms.trim() || undefined,
hsCodes: form.hsCodes.trim() || undefined, hsCodes: form.hsCodes.trim() || undefined,
itemCode: form.itemCode.trim() || undefined, itemCode: form.itemCode.trim() || undefined,
itemDescription: form.itemDescription.trim() || undefined,
packagingType: form.packagingType.trim() || undefined,
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
weighingRequired: form.weighingRequired ?? undefined,
grossWeightKg: form.weighingRequired && form.grossWeightKg !== '' ? Number(form.grossWeightKg) : undefined,
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg), netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
volumeDimensions: form.volumeDimensions.trim() || undefined, volumeDimensions: form.volumeDimensions.trim() || undefined,
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined, conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
@@ -222,8 +241,11 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
driverPhone: form.driverPhone.trim(), driverPhone: form.driverPhone.trim(),
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined, driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
truckType: form.truckType.trim() || undefined, truckType: form.truckType.trim() || undefined,
entranceTareWeightKg: Number(form.entranceTareWeightKg), entranceTareWeightKg:
exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg), form.entranceTareWeightKg === ''
? undefined
: Number(form.entranceTareWeightKg),
exitTareWeightKg: form.weighingRequired && form.exitTareWeightKg !== '' ? Number(form.exitTareWeightKg) : undefined,
driverSignatoryName: form.driverSignatoryName.trim() || undefined, driverSignatoryName: form.driverSignatoryName.trim() || undefined,
warehouseManagerName: form.warehouseManagerName.trim() || undefined, warehouseManagerName: form.warehouseManagerName.trim() || undefined,
}); });
@@ -242,22 +264,31 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin)); const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone)); const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer)); const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer));
const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber)); const assignedEquipmentNumber = commonNonEmptyValue(
bookings.map((booking) => booking.customerTruckContainerNumber || booking.containerNumber),
);
const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo)); const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo));
const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType)); const packagingType = commonNonEmptyValue(bookings.map((booking) => booking.containerPackagingType));
const truckPlateNumber = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileTruckPlateNumber || booking.customerTruckPlateNumber),
);
const trailerPlateNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileTrailerPlateNumber));
const driverName = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileDriverName || booking.customerTruckDriverName),
);
const driverPhone = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverPhone));
const driverLicenseNumber = commonNonEmptyValue(bookings.map((booking) => booking.firstMileDriverLicenseNumber));
const truckType = commonNonEmptyValue(
bookings.map((booking) => booking.firstMileTruckType || booking.customerTruckType),
);
const edrDigitalBookingId = const edrDigitalBookingId =
bookings.length === 1 bookings.length === 1
? bookings[0]?.reference ?? bookings[0]?.id ?? '' ? bookings[0]?.reference ?? bookings[0]?.id ?? ''
: commonNonEmptyValue(bookings.map((booking) => booking.reference)); : commonNonEmptyValue(bookings.map((booking) => booking.reference));
const firstMileBooking = bookings.length === 1 ? bookings[0] : null;
const unitCount = const unitCount =
bookings.length === 1 && bookings[0]?.containerQuantity != null bookings.length === 1 && bookings[0]?.containerQuantity != null
? Number(bookings[0].containerQuantity) ? 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 freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
const packagingFreightType = const packagingFreightType =
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER' freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
@@ -278,13 +309,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
itemDescription, itemDescription,
packagingType, packagingType,
unitCount, unitCount,
grossWeightKg, grossWeightKg: '',
truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '', truckPlateNumber,
trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '', trailerPlateNumber,
driverName: firstMileBooking?.firstMileDriverName ?? '', driverName,
driverPhone: firstMileBooking?.firstMileDriverPhone ?? '', driverPhone,
driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '', driverLicenseNumber,
truckType: firstMileBooking?.firstMileTruckType ?? '', truckType,
}, },
lockedFields: { lockedFields: {
ownerName: Boolean(ownerName), ownerName: Boolean(ownerName),
@@ -296,7 +327,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
itemDescription: Boolean(itemDescription), itemDescription: Boolean(itemDescription),
packagingType: Boolean(packagingType), packagingType: Boolean(packagingType),
unitCount: unitCount !== '', unitCount: unitCount !== '',
grossWeightKg: grossWeightKg !== '', grossWeightKg: false,
truckPlateNumber: Boolean(truckPlateNumber),
trailerPlateNumber: Boolean(trailerPlateNumber),
driverName: Boolean(driverName),
driverPhone: Boolean(driverPhone),
driverLicenseNumber: Boolean(driverLicenseNumber),
truckType: Boolean(truckType),
}, },
packagingFreightType, packagingFreightType,
}; };
@@ -338,11 +375,13 @@ function TruckEntranceFields({
onChange, onChange,
lockedFields, lockedFields,
packagingFreightType = 'MIXED', packagingFreightType = 'MIXED',
allowTruckWeighing = true,
}: { }: {
value: TruckEntranceFormState; value: TruckEntranceFormState;
onChange: (next: TruckEntranceFormState) => void; onChange: (next: TruckEntranceFormState) => void;
lockedFields?: LockedTruckEntranceFields; lockedFields?: LockedTruckEntranceFields;
packagingFreightType?: PackagingFreightType; packagingFreightType?: PackagingFreightType;
allowTruckWeighing?: boolean;
}) { }) {
const packagingOptions = packagingOptionsFor(packagingFreightType); const packagingOptions = packagingOptionsFor(packagingFreightType);
const quantityLabel = const quantityLabel =
@@ -396,11 +435,13 @@ function TruckEntranceFields({
label="Truck plate number" label="Truck plate number"
required required
value={value.truckPlateNumber} value={value.truckPlateNumber}
readOnly={lockedFields?.truckPlateNumber}
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })} onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
/> />
<TextInput <TextInput
label="Trailer plate number" label="Trailer plate number"
value={value.trailerPlateNumber} value={value.trailerPlateNumber}
readOnly={lockedFields?.trailerPlateNumber}
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })} onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
/> />
</Group> </Group>
@@ -422,12 +463,14 @@ function TruckEntranceFields({
label="Driver name" label="Driver name"
required required
value={value.driverName} value={value.driverName}
readOnly={lockedFields?.driverName}
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })} onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
/> />
<TextInput <TextInput
label="Driver phone" label="Driver phone"
required required
value={value.driverPhone} value={value.driverPhone}
readOnly={lockedFields?.driverPhone}
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })} onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
/> />
</Group> </Group>
@@ -435,29 +478,61 @@ function TruckEntranceFields({
<TextInput <TextInput
label="Driver license number" label="Driver license number"
value={value.driverLicenseNumber} value={value.driverLicenseNumber}
readOnly={lockedFields?.driverLicenseNumber}
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })} onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
/> />
<TextInput <TextInput
label="Truck type" label="Truck type"
value={value.truckType} value={value.truckType}
readOnly={lockedFields?.truckType}
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })} onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
/> />
</Group> </Group>
<Group grow> {allowTruckWeighing ? (
<NumberInput <>
label="Entrance tare weight (kg)" <Select
required label="Weighing"
min={0} required
value={value.entranceTareWeightKg} data={[
onChange={(v) => onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })} { value: 'YES', label: 'Yes' },
/> { value: 'NO', label: 'No' },
<NumberInput ]}
label="Exit tare weight (kg)" value={value.weighingRequired == null ? null : value.weighingRequired ? 'YES' : 'NO'}
min={0} onChange={(next) =>
value={value.exitTareWeightKg} onChange({
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })} ...value,
/> weighingRequired: next === 'YES' ? true : next === 'NO' ? false : null,
</Group> grossWeightKg: next === 'YES' ? value.grossWeightKg : '',
exitTareWeightKg: next === 'YES' ? value.exitTareWeightKg : '',
})
}
/>
{value.weighingRequired && (
<Group grow>
<NumberInput
label="Gross weight (kg)"
required
min={0}
value={value.grossWeightKg}
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Exit tare weight (kg)"
required
min={0}
value={value.exitTareWeightKg}
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
)}
</>
) : (
<Alert icon={<Info size={16} />} color="green" variant="light">
<Text size="sm">
Truck weighing is not required for a received first-mile arrival. The GRN uses the booking weight.
</Text>
</Alert>
)}
<Text size="sm" fw={600} mt="xs">Customs and compliance</Text> <Text size="sm" fw={600} mt="xs">Customs and compliance</Text>
<Group grow> <Group grow>
@@ -510,21 +585,12 @@ function TruckEntranceFields({
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })} onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
/> />
</Group> </Group>
<Group grow> <NumberInput
<NumberInput label="Net weight (kg)"
label="Gross weight (kg)" min={0}
min={0} value={value.netWeightKg}
value={value.grossWeightKg} onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
readOnly={lockedFields?.grossWeightKg} />
onChange={(v) => onChange({ ...value, grossWeightKg: v === '' ? '' : Number(v) })}
/>
<NumberInput
label="Net weight (kg)"
min={0}
value={value.netWeightKg}
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
/>
</Group>
<TextInput <TextInput
label="Volume / dimensions" label="Volume / dimensions"
value={value.volumeDimensions} value={value.volumeDimensions}
@@ -766,6 +832,8 @@ function EligibleTab({
[pendingReceiveIds, rows], [pendingReceiveIds, rows],
); );
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile); const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
const pendingUsesFirstMile =
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
const toggleAll = () => const toggleAll = () =>
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id))); setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
@@ -822,32 +890,65 @@ function EligibleTab({
void receiveBookings(filteredIds); void receiveBookings(filteredIds);
return; return;
} }
const hasFirstMileRows = selectedRows.some((row) => row.hasFirstMile);
const hasCustomerTruckRows = selectedRows.some((row) => !row.hasFirstMile);
if (hasFirstMileRows && hasCustomerTruckRows) {
toast({
variant: 'destructive',
title: 'Receive separately',
description: 'First-mile arrivals and customer-truck arrivals use different truck evidence. Select one group at a time.',
});
return;
}
const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows); const { form, lockedFields, packagingFreightType: nextPackagingFreightType } = truckEntranceFromBookings(selectedRows);
const totalContainerQuantity = selectedRows.reduce( const totalContainerQuantity = selectedRows.reduce(
(sum, row) => sum + Number(row.containerQuantity ?? 0), (sum, row) => sum + Number(row.containerQuantity ?? 0),
0, 0,
); );
const usesFirstMile = selectedRows.length > 0 && selectedRows.every((row) => row.hasFirstMile);
const usesCustomerAssignedTruck =
selectedRows.length > 0 &&
selectedRows.every((row) => !row.hasFirstMile && Boolean(row.customerTruckAssignedAt));
const normalizedForm = const normalizedForm =
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0 nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
? { ? {
...form, ...form,
unitCount: totalContainerQuantity, unitCount: totalContainerQuantity,
} }
: form; : {
...form,
};
setPendingReceiveIds(filteredIds); setPendingReceiveIds(filteredIds);
setReceivedAt(new Date().toISOString()); setReceivedAt(new Date().toISOString());
setTruckForm(normalizedForm); setTruckForm(normalizedForm);
setLockedTruckFields({ setLockedTruckFields({
...lockedFields, ...lockedFields,
unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0, unitCount: nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0,
assignedEquipmentNumber: usesCustomerAssignedTruck
? lockedFields.assignedEquipmentNumber
: lockedFields.assignedEquipmentNumber,
truckPlateNumber: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckPlateNumber,
trailerPlateNumber: usesFirstMile && lockedFields.trailerPlateNumber,
driverName: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.driverName,
driverPhone: usesFirstMile && lockedFields.driverPhone,
driverLicenseNumber: usesFirstMile && lockedFields.driverLicenseNumber,
truckType: (usesFirstMile || usesCustomerAssignedTruck) && lockedFields.truckType,
}); });
setPackagingFreightType(nextPackagingFreightType); setPackagingFreightType(nextPackagingFreightType);
setTruckOpen(true); setTruckOpen(true);
}; };
const receive = async () => { const receive = async () => {
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') { if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) {
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' }); toast({ variant: 'destructive', title: 'Truck and driver information are required' });
return;
}
if (!pendingUsesFirstMile && truckForm.weighingRequired == null) {
toast({ variant: 'destructive', title: 'Select whether customer truck weighing is required' });
return;
}
if (truckForm.weighingRequired && (truckForm.grossWeightKg === '' || truckForm.exitTareWeightKg === '')) {
toast({ variant: 'destructive', title: 'Gross weight and exit tare weight are required when weighing is Yes' });
return; return;
} }
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm)); await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
@@ -1053,8 +1154,8 @@ function EligibleTab({
<Stack gap="md"> <Stack gap="md">
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light"> <Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
<Text size="sm"> <Text size="sm">
{pendingHasFirstMile {pendingUsesFirstMile
? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.' ? 'Received first-mile truck and driver details are pulled from the first-mile record. GRN uses booking cargo, quantity and weight.'
: 'Register the customer or third-party truck and driver before export receiving and GRN.'} : 'Register the customer or third-party truck and driver before export receiving and GRN.'}
</Text> </Text>
</Alert> </Alert>
@@ -1109,6 +1210,7 @@ function EligibleTab({
onChange={setTruckForm} onChange={setTruckForm}
lockedFields={lockedTruckFields} lockedFields={lockedTruckFields}
packagingFreightType={packagingFreightType} packagingFreightType={packagingFreightType}
allowTruckWeighing={!pendingUsesFirstMile}
/> />
<Group justify="flex-end"> <Group justify="flex-end">
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}> <Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
@@ -1934,6 +2036,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
reference: row.bookingReference ?? row.bookingId, reference: row.bookingReference ?? row.bookingId,
tradeDirection: 'IMPORT', tradeDirection: 'IMPORT',
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null, lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
customerTruckPlateNumber: row.customerTruckPlateNumber,
customerTruckDriverName: row.customerTruckDriverName,
customerTruckType: row.customerTruckType,
customerTruckContainerNumber: row.customerTruckContainerNumber,
customerTruckAssignedAt: row.customerTruckAssignedAt,
} }
: null, : null,
}) as unknown as WarehouseInventoryItem; }) as unknown as WarehouseInventoryItem;

View File

@@ -82,6 +82,9 @@ const splitContainerNumbers = (value: string | null | undefined) =>
const getItemContainerNumber = (item: WarehouseInventoryItem | null) => const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
(item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? ''; (item as (WarehouseInventoryItem & { containerNumber?: string | null }) | null)?.containerNumber ?? '';
const assignedTruckValue = (item: WarehouseInventoryItem | null, key: keyof NonNullable<WarehouseInventoryItem['booking']>) =>
item?.booking?.[key] == null ? '' : String(item.booking[key]);
const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => { const isContainerInventory = (item: WarehouseInventoryItem | null, containerCount: number) => {
const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null) const freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
?.booking?.freightType; ?.booking?.freightType;
@@ -138,14 +141,18 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
useEffect(() => { useEffect(() => {
if (opened) { if (opened) {
const inspection = parseInspectionNote(item?.notes); const inspection = parseInspectionNote(item?.notes);
const assignedTruckPlate = assignedTruckValue(item, 'customerTruckPlateNumber');
const assignedDriverName = assignedTruckValue(item, 'customerTruckDriverName');
const assignedTruckType = assignedTruckValue(item, 'customerTruckType');
const assignedContainerNumber = assignedTruckValue(item, 'customerTruckContainerNumber');
setReference(item?.releaseOrderReference ?? generateReleaseReference(item)); setReference(item?.releaseOrderReference ?? generateReleaseReference(item));
setTruckPlateNumber(inspection.truckPlateNumber); setTruckPlateNumber(inspection.truckPlateNumber || assignedTruckPlate);
setTrailerPlateNumber(inspection.trailerPlateNumber); setTrailerPlateNumber(inspection.trailerPlateNumber);
setDriverName(inspection.driverName); setDriverName(inspection.driverName || assignedDriverName);
setDriverLicense(inspection.driverLicense); setDriverLicense(inspection.driverLicense);
setDriverPhone(inspection.driverPhone); setDriverPhone(inspection.driverPhone);
setTruckType(inspection.truckType); setTruckType(inspection.truckType || assignedTruckType);
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber)); setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || assignedContainerNumber));
setGateInTime(inspection.gateInTime); setGateInTime(inspection.gateInTime);
setTareWeight(inspection.tareWeight); setTareWeight(inspection.tareWeight);
setGrossWeight(inspection.grossWeight); setGrossWeight(inspection.grossWeight);
@@ -157,6 +164,8 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
const savedInspection = parseInspectionNote(item?.notes); const savedInspection = parseInspectionNote(item?.notes);
const isExitStep = savedInspection.tareWeight !== ''; const isExitStep = savedInspection.tareWeight !== '';
const isEntranceLocked = isExitStep; const isEntranceLocked = isExitStep;
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck;
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight); const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
const computedNetWeight = const computedNetWeight =
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null; tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
@@ -269,7 +278,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
searchable searchable
clearable clearable
data={REGISTERED_FIRST_LAST_MILE_TRUCKS} data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
disabled={isEntranceLocked} disabled={isTruckIdentityLocked}
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null} value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
onChange={(value) => { onChange={(value) => {
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value); const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
@@ -283,7 +292,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
required required
value={truckPlateNumber} value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)} onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={isEntranceLocked} readOnly={isTruckIdentityLocked}
/> />
<TextInput <TextInput
label="Trailer plate number" label="Trailer plate number"
@@ -293,12 +302,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
/> />
</Group> </Group>
<Group grow> <Group grow>
<TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isEntranceLocked} /> <TextInput label="Driver name" required value={driverName} onChange={(e) => setDriverName(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
<TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} /> <TextInput label="Driver license" value={driverLicense} onChange={(e) => setDriverLicense(e.currentTarget.value)} readOnly={isEntranceLocked} />
</Group> </Group>
<Group grow> <Group grow>
<TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} /> <TextInput label="Driver phone" value={driverPhone} onChange={(e) => setDriverPhone(e.currentTarget.value)} readOnly={isEntranceLocked} />
<TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isEntranceLocked} /> <TextInput label="Truck type" value={truckType} onChange={(e) => setTruckType(e.currentTarget.value)} readOnly={isTruckIdentityLocked} />
</Group> </Group>
<Group grow> <Group grow>
<Stack gap={6}> <Stack gap={6}>
@@ -313,7 +322,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)), numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
) )
} }
readOnly={isEntranceLocked} readOnly={isTruckIdentityLocked}
/> />
))} ))}
</SimpleGrid> </SimpleGrid>

View File

@@ -441,6 +441,7 @@ export const URL_CONSTANTS = {
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`, RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`, CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`, PAY: (id: string) => `/warehouse-fee-invoices/${id}/pay`,
PAY_ONLINE: (id: string) => `/warehouse-fee-invoices/${id}/pay-online`,
GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`, GENERATE: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/generate-fee-invoice`,
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`, FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`, FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,

View File

@@ -1,6 +1,6 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; //export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001'; export const API_BASE_URL = 'http://localhost:3001';

View File

@@ -9,6 +9,8 @@ import {
RingProgress, RingProgress,
Stack, Stack,
Text, Text,
Textarea,
TextInput,
ThemeIcon, ThemeIcon,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
@@ -88,6 +90,10 @@ export default function TrainScheduleV2DetailPage() {
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null); const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]); const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
const [maintenanceOpen, setMaintenanceOpen] = useState(false); const [maintenanceOpen, setMaintenanceOpen] = useState(false);
const [gatepassSecuredAt, setGatepassSecuredAt] = useState("");
const [gatepassReference, setGatepassReference] = useState("");
const [gatepassFileUrl, setGatepassFileUrl] = useState("");
const [gatepassNotes, setGatepassNotes] = useState("");
const autoPreviewedRef = useRef(false); const autoPreviewedRef = useRef(false);
const detailQuery = useQuery( const detailQuery = useQuery(
@@ -98,6 +104,42 @@ export default function TrainScheduleV2DetailPage() {
); );
const schedule = detailQuery.data; const schedule = detailQuery.data;
const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined; const freightType: FreightType | undefined = schedule?.freightType as FreightType | undefined;
const isDjiboutiPort = (value?: string | null) =>
["DJIBOUTI", "DORALEH", "DMP", "DCT", "NAGAD"].some((token) =>
(value ?? "").toUpperCase().includes(token),
);
const gatepassApplies = Boolean(
schedule &&
((schedule.direction === "IMPORT" &&
isDjiboutiPort(`${schedule.originStation?.code ?? ""} ${schedule.originStation?.label ?? ""}`)) ||
(schedule.direction === "EXPORT" &&
isDjiboutiPort(`${schedule.destinationStation?.code ?? ""} ${schedule.destinationStation?.label ?? ""}`))),
);
const gatepassQuery = useQuery({
queryKey: ["train-scheduling", "gatepass", scheduleId],
queryFn: () => trainSchedulingService.getImportDjiboutiOperation(scheduleId as string),
enabled: Boolean(scheduleId && gatepassApplies),
});
const secureGatepass = useMutation({
mutationFn: () =>
trainSchedulingService.grantImportDjiboutiGatepass(scheduleId as string, {
securedAt: gatepassSecuredAt ? new Date(gatepassSecuredAt).toISOString() : undefined,
reference: gatepassReference.trim() || undefined,
fileUrl: gatepassFileUrl.trim() || undefined,
notes: gatepassNotes.trim() || undefined,
}),
onSuccess: () => {
toast({ title: "Gate pass secured" });
void gatepassQuery.refetch();
},
onError: (error) => {
toast({
title: "Gate pass failed",
description: parseError(error, "Could not secure gate pass"),
variant: "destructive",
});
},
});
const eligibleFilters = useMemo( const eligibleFilters = useMemo(
() => () =>
@@ -133,6 +175,16 @@ export default function TrainScheduleV2DetailPage() {
: trainSchedulingService.downloadImportDjiboutiLoadListDocument(id), : trainSchedulingService.downloadImportDjiboutiLoadListDocument(id),
}); });
useEffect(() => {
const operation = gatepassQuery.data;
if (!operation) return;
const secured = operation.gatepassSecuredAt ?? operation.gatepassGrantedAt;
setGatepassSecuredAt(secured ? new Date(secured).toISOString().slice(0, 16) : "");
setGatepassReference(operation.documents?.GATE_PASS?.reference ?? "");
setGatepassFileUrl(operation.documents?.GATE_PASS?.fileUrl ?? "");
setGatepassNotes(operation.documents?.GATE_PASS?.notes ?? operation.notes ?? "");
}, [gatepassQuery.data]);
const assignedIds = useMemo( const assignedIds = useMemo(
() => (schedule?.bookings ?? []).map((b) => b.id), () => (schedule?.bookings ?? []).map((b) => b.id),
[schedule?.bookings], [schedule?.bookings],
@@ -899,6 +951,83 @@ export default function TrainScheduleV2DetailPage() {
]} ]}
/> />
{gatepassApplies ? (
<Paper radius="xl" p="lg" withBorder>
<Stack gap="md">
<Group justify="space-between" align="flex-start" wrap="wrap">
<Group gap="md" align="flex-start" wrap="nowrap">
<ThemeIcon
size={44}
radius="md"
variant="light"
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "edr-green" : "orange"}
>
<FileText size={22} />
</ThemeIcon>
<Stack gap={4}>
<Group gap="sm">
<Title order={4} fw={700}>
Djibouti Port gate pass
</Title>
<Badge
color={gatepassQuery.data?.gatepassStatus === "SECURED" ? "green" : "orange"}
variant="light"
>
{gatepassQuery.data?.gatepassStatus ?? "NOT_SECURED"}
</Badge>
</Group>
<Text size="sm" c="dimmed">
{schedule.direction === "IMPORT"
? "Secure before dispatch from Djibouti."
: "Secure after dispatch before Djibouti Port entry / unloading."}
</Text>
</Stack>
</Group>
{gatepassQuery.isLoading ? <Loader size="sm" /> : null}
</Group>
<Group align="flex-end" grow>
<TextInput
label="Secured date"
type="datetime-local"
value={gatepassSecuredAt}
onChange={(event) => setGatepassSecuredAt(event.currentTarget.value)}
/>
<TextInput
label="Document reference"
placeholder="Optional"
value={gatepassReference}
onChange={(event) => setGatepassReference(event.currentTarget.value)}
/>
<TextInput
label="Document URL"
placeholder="Optional upload/link"
value={gatepassFileUrl}
onChange={(event) => setGatepassFileUrl(event.currentTarget.value)}
/>
</Group>
<Textarea
label="Notes"
placeholder="Optional"
autosize
minRows={2}
value={gatepassNotes}
onChange={(event) => setGatepassNotes(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button
color="edr-green"
leftSection={<CheckCircle2 size={16} />}
loading={secureGatepass.isPending}
onClick={() => secureGatepass.mutate()}
>
Save as Secured
</Button>
</Group>
</Stack>
</Paper>
) : null}
<Paper radius="xl" p="lg"> <Paper radius="xl" p="lg">
<Stack gap="lg"> <Stack gap="lg">
{/* Workflow header with ring progress */} {/* Workflow header with ring progress */}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { import {
ActionIcon, ActionIcon,
Badge, Badge,
@@ -28,6 +28,7 @@ import { useToast } from '@/hooks/use-toast';
import { import {
WAREHOUSE_INVOICE_STATUSES, WAREHOUSE_INVOICE_STATUSES,
type WarehouseFeeInvoice, type WarehouseFeeInvoice,
type WarehouseGatewayPaymentMethod,
type WarehouseInvoiceStatus, type WarehouseInvoiceStatus,
} from '@/types/warehouse'; } from '@/types/warehouse';
import { openPdfBlob } from '@/components/warehouses/pdf'; import { openPdfBlob } from '@/components/warehouses/pdf';
@@ -162,15 +163,23 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
}), }),
); );
const pay = useMutation(api.warehouses.payInvoice.mutationOptions()); const pay = useMutation(api.warehouses.payInvoice.mutationOptions());
const payOnline = useMutation(api.warehouses.payInvoiceOnline.mutationOptions());
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions()); const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions()); const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
const [payAmount, setPayAmount] = useState<number | ''>(''); const [payAmount, setPayAmount] = useState<number | ''>('');
const [driverName, setDriverName] = useState(''); const [driverName, setDriverName] = useState('');
const [driverPhone, setDriverPhone] = useState(''); const [driverPhone, setDriverPhone] = useState('');
const [gatewayMethod, setGatewayMethod] = useState<WarehouseGatewayPaymentMethod>('TELEBIRR');
const [payerAccount, setPayerAccount] = useState('');
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID'); const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId); const canGateClear = inv?.status === 'PAID' && Boolean(inv.inventoryId);
useEffect(() => {
setGatewayMethod(inv?.currency === 'USD' ? 'WAAFI' : 'TELEBIRR');
setPayerAccount('');
}, [inv?.id, inv?.currency]);
const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => { const downloadInvoicePdf = async (invoice: WarehouseFeeInvoice) => {
const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE'); const blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`); openPdfBlob(blob, `warehouse-invoice-${invoice.invoiceNumber}.pdf`);
@@ -280,6 +289,34 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
} }
}; };
const handleOnlinePay = async () => {
if (!inv) return;
try {
const currentUrl = window.location.href;
const result = await payOnline.mutateAsync({
id: inv.id,
payload: {
method: gatewayMethod,
platform: 'web',
payerAccount: payerAccount.trim() || undefined,
returnUrl: currentUrl,
failureUrl: currentUrl,
},
});
const url = result.clientAction?.url;
if (url) {
window.location.href = url;
return;
}
toast({
title: 'Payment initiated',
description: 'No redirect URL was returned by the payment provider.',
});
} catch (e) {
toast({ variant: 'destructive', title: 'Payment failed', description: extractErrorMessage(e) });
}
};
const handleCancel = async () => { const handleCancel = async () => {
if (!inv) return; if (!inv) return;
try { try {
@@ -337,7 +374,31 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
{canPay && ( {canPay && (
<> <>
<Divider label="Record payment" labelPosition="left" /> <Divider label="Online payment" labelPosition="left" />
<Group align="flex-end">
<Select
label="Provider"
value={gatewayMethod}
onChange={(v) => setGatewayMethod((v as WarehouseGatewayPaymentMethod) ?? 'TELEBIRR')}
data={[
{ value: 'TELEBIRR', label: 'Telebirr' },
{ value: 'WAAFI', label: 'Waafi' },
]}
style={{ flex: 1 }}
/>
<TextInput
label="Wallet phone / account"
value={payerAccount}
onChange={(e) => setPayerAccount(e.currentTarget.value)}
placeholder="Optional"
style={{ flex: 1 }}
/>
<Button leftSection={<CreditCard size={16} />} loading={payOnline.isPending} onClick={handleOnlinePay}>
Pay with {gatewayMethod === 'WAAFI' ? 'Waafi' : 'Telebirr'}
</Button>
</Group>
<Divider label="Record manual payment" labelPosition="left" />
<Group align="flex-end"> <Group align="flex-end">
<NumberInput <NumberInput
label="Amount" label="Amount"

View File

@@ -18,6 +18,8 @@ import {
} from '@mantine/core'; } from '@mantine/core';
import { Info, Plus, Trash2 } from 'lucide-react'; import { Info, Plus, Trash2 } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { PageContainer, PageHeader } from '@/components/page'; import { PageContainer, PageHeader } from '@/components/page';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { import {
@@ -29,7 +31,9 @@ import {
useDeleteFeeRule, useDeleteFeeRule,
useFeeRules, useFeeRules,
} from '@/hooks/useWarehouses'; } from '@/hooks/useWarehouses';
import { api } from '@/services/api';
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse'; import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
import { extractErrorMessage } from '@/components/warehouses/options';
const FREIGHT = [ const FREIGHT = [
{ value: 'CONTAINER', label: 'Container' }, { value: 'CONTAINER', label: 'Container' },
@@ -38,6 +42,7 @@ const FREIGHT = [
const TRADE = [ const TRADE = [
{ value: 'IMPORT', label: 'Import' }, { value: 'IMPORT', label: 'Import' },
{ value: 'EXPORT', label: 'Export' }, { value: 'EXPORT', label: 'Export' },
{ value: 'BOTH', label: 'Import & Export' },
{ value: 'DOMESTIC', label: 'Domestic' }, { value: 'DOMESTIC', label: 'Domestic' },
]; ];
const CURRENCIES = [ const CURRENCIES = [
@@ -53,6 +58,23 @@ const numberValue = (value: string | number, fallback = 0) => {
}; };
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`; const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
const dash = '-'; const dash = '-';
type CodeOptionSource = {
id?: string;
code?: string;
cargoTypeName?: string;
label?: string;
name?: string;
};
const codeOptions = (rows: unknown[]) =>
(rows as CodeOptionSource[])
.filter((row) => row.code)
.map((row) => ({
value: row.code as string,
label: `${row.cargoTypeName ?? row.label ?? row.name ?? row.code} (${row.code})`,
}));
const isUnknownTiersError = (error: unknown) => extractErrorMessage(error).includes('property tiers should not exist');
export default function WarehouseRulesPage() { export default function WarehouseRulesPage() {
return ( return (
@@ -319,6 +341,12 @@ function AllocationRules() {
function FeeRules() { function FeeRules() {
const { toast } = useToast(); const { toast } = useToast();
const { data, isLoading } = useFeeRules(); const { data, isLoading } = useFeeRules();
const { data: cargoTypes = [], isLoading: cargoTypesLoading } = useQuery(
api.cargoTypes.list.queryOptions({ staleTime: Infinity }),
);
const { data: containerTypes = [], isLoading: containerTypesLoading } = useQuery(
api.containerTypes.list.queryOptions({ staleTime: Infinity }),
);
const create = useCreateFeeRule(); const create = useCreateFeeRule();
const remove = useDeleteFeeRule(); const remove = useDeleteFeeRule();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@@ -328,11 +356,56 @@ function FeeRules() {
freightType: '', freightType: '',
tradeDirection: '', tradeDirection: '',
cargoTypeCode: '', cargoTypeCode: '',
containerType: '',
freeDays: 3, freeDays: 3,
ratePerDay: 0, ratePerDay: 0,
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
currency: 'USD', currency: 'USD',
}); });
const rules = data ?? []; const rules = data ?? [];
const cargoTypeOptions = codeOptions(cargoTypes);
const containerTypeOptions = codeOptions(containerTypes);
const isBulkRule = form.freightType === 'BULK';
const isContainerRule = form.freightType === 'CONTAINER';
const resetForm = () =>
setForm({
name: '',
ruleType: 'DEMURRAGE_FEE',
freightType: '',
tradeDirection: '',
cargoTypeCode: '',
containerType: '',
freeDays: 3,
ratePerDay: 0,
tiers: [],
currency: 'USD',
});
const addTier = () =>
setForm((f) => {
const last = f.tiers[f.tiers.length - 1];
const fromDay = last?.toDay ? last.toDay + 1 : f.tiers.length ? last.fromDay + 1 : f.freeDays + 1;
return {
...f,
tiers: [...f.tiers, { fromDay, toDay: fromDay, ratePerDay: f.ratePerDay || 0 }],
};
});
const updateTier = (
index: number,
patch: Partial<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
) =>
setForm((f) => ({
...f,
tiers: f.tiers.map((tier, i) => (i === index ? { ...tier, ...patch } : tier)),
}));
const removeTier = (index: number) =>
setForm((f) => ({
...f,
tiers: f.tiers.filter((_, i) => i !== index),
}));
const submit = async () => { const submit = async () => {
if (!form.name.trim()) { if (!form.name.trim()) {
@@ -340,18 +413,68 @@ function FeeRules() {
return; return;
} }
await create.mutateAsync({ const tiers = form.tiers.map((tier) => ({
fromDay: tier.fromDay,
toDay: tier.toDay || null,
ratePerDay: tier.ratePerDay,
}));
for (const [index, tier] of tiers.entries()) {
if (!Number.isInteger(tier.fromDay) || tier.fromDay < 1) {
toast({ variant: 'destructive', title: `Tier ${index + 1}: from day must be at least 1` });
return;
}
if (tier.toDay != null && tier.toDay < tier.fromDay) {
toast({ variant: 'destructive', title: `Tier ${index + 1}: to day must be after from day` });
return;
}
if (tier.ratePerDay < 0) {
toast({ variant: 'destructive', title: `Tier ${index + 1}: amount must be zero or greater` });
return;
}
}
const payload = {
name: form.name.trim(), name: form.name.trim(),
ruleType: form.ruleType, ruleType: form.ruleType,
freightType: clean(form.freightType) ?? null, freightType: clean(form.freightType) ?? null,
tradeDirection: clean(form.tradeDirection) ?? null, tradeDirection: clean(form.tradeDirection) ?? null,
cargoTypeCode: clean(form.cargoTypeCode) ?? null, cargoTypeCode: isBulkRule ? (clean(form.cargoTypeCode) ?? null) : null,
containerType: isContainerRule ? (clean(form.containerType) ?? null) : null,
freeDays: form.freeDays, freeDays: form.freeDays,
ratePerDay: form.ratePerDay, ratePerDay: form.ratePerDay,
currency: form.currency || 'USD', currency: form.currency || 'USD',
} as never); ...(tiers.length ? { tiers } : {}),
toast({ title: 'Fee rule created' }); };
setOpen(false);
try {
await create.mutateAsync(payload as never);
toast({ title: 'Fee rule created' });
setOpen(false);
resetForm();
} catch (error) {
if (tiers.length && isUnknownTiersError(error)) {
const legacyPayload: Omit<typeof payload, 'tiers'> = {
name: payload.name,
ruleType: payload.ruleType,
freightType: payload.freightType,
tradeDirection: payload.tradeDirection,
cargoTypeCode: payload.cargoTypeCode,
containerType: payload.containerType,
freeDays: payload.freeDays,
ratePerDay: payload.ratePerDay,
currency: payload.currency,
};
await create.mutateAsync(legacyPayload as never);
toast({
title: 'Fee rule created without tiers',
description: 'The connected API does not support progressive tiers yet. Deploy the warehouse fee tier migration/API to save tier rows.',
});
setOpen(false);
resetForm();
return;
}
toast({ variant: 'destructive', title: 'Create failed', description: extractErrorMessage(error) });
}
}; };
return ( return (
@@ -370,7 +493,7 @@ function FeeRules() {
<Loader /> <Loader />
</Group> </Group>
) : ( ) : (
<Table.ScrollContainer minWidth={900}> <Table.ScrollContainer minWidth={1100}>
<Table striped highlightOnHover verticalSpacing="sm"> <Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
@@ -378,6 +501,9 @@ function FeeRules() {
<Table.Th>Name</Table.Th> <Table.Th>Name</Table.Th>
<Table.Th>Freight</Table.Th> <Table.Th>Freight</Table.Th>
<Table.Th>Trade</Table.Th> <Table.Th>Trade</Table.Th>
<Table.Th>Cargo</Table.Th>
<Table.Th>Container</Table.Th>
<Table.Th>Location scope</Table.Th>
<Table.Th>Free days</Table.Th> <Table.Th>Free days</Table.Th>
<Table.Th>Rate / day</Table.Th> <Table.Th>Rate / day</Table.Th>
<Table.Th>Active</Table.Th> <Table.Th>Active</Table.Th>
@@ -395,6 +521,20 @@ function FeeRules() {
<Table.Td>{rule.name}</Table.Td> <Table.Td>{rule.name}</Table.Td>
<Table.Td>{rule.freightType ?? dash}</Table.Td> <Table.Td>{rule.freightType ?? dash}</Table.Td>
<Table.Td>{rule.tradeDirection ?? dash}</Table.Td> <Table.Td>{rule.tradeDirection ?? dash}</Table.Td>
<Table.Td>{rule.cargoTypeCode ?? dash}</Table.Td>
<Table.Td>{rule.containerType ?? dash}</Table.Td>
<Table.Td>
{[rule.facilityId, rule.warehouseId, rule.yardId, rule.zoneId].some(Boolean) ? (
<Stack gap={2}>
{rule.facilityId && <Text size="xs">Facility: {rule.facilityId}</Text>}
{rule.warehouseId && <Text size="xs">Warehouse: {rule.warehouseId}</Text>}
{rule.yardId && <Text size="xs">Yard: {rule.yardId}</Text>}
{rule.zoneId && <Text size="xs">Zone: {rule.zoneId}</Text>}
</Stack>
) : (
dash
)}
</Table.Td>
<Table.Td>{rule.freeDays}</Table.Td> <Table.Td>{rule.freeDays}</Table.Td>
<Table.Td> <Table.Td>
{Number(rule.ratePerDay).toLocaleString()} {rule.currency} {Number(rule.ratePerDay).toLocaleString()} {rule.currency}
@@ -454,7 +594,14 @@ function FeeRules() {
label="Freight type" label="Freight type"
data={FREIGHT} data={FREIGHT}
value={form.freightType || null} value={form.freightType || null}
onChange={(value) => setForm((f) => ({ ...f, freightType: selectValue(value) }))} onChange={(value) =>
setForm((f) => ({
...f,
freightType: selectValue(value),
cargoTypeCode: value === 'BULK' ? f.cargoTypeCode : '',
containerType: value === 'CONTAINER' ? f.containerType : '',
}))
}
clearable clearable
/> />
<Select <Select
@@ -464,15 +611,31 @@ function FeeRules() {
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))} onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
clearable clearable
/> />
<TextInput {isBulkRule && (
label="Cargo type code" <Select
value={form.cargoTypeCode} label="Cargo type"
onChange={(e) => { placeholder={cargoTypesLoading ? 'Loading cargo types...' : 'Any cargo'}
const value = e.currentTarget.value; data={cargoTypeOptions}
setForm((f) => ({ ...f, cargoTypeCode: value })); value={form.cargoTypeCode || null}
}} onChange={(value) => setForm((f) => ({ ...f, cargoTypeCode: selectValue(value) }))}
/> searchable
clearable
disabled={cargoTypesLoading}
/>
)}
</Group> </Group>
{isContainerRule && (
<Select
label="Container type"
placeholder={containerTypesLoading ? 'Loading container types...' : 'Any container'}
data={containerTypeOptions}
value={form.containerType || null}
onChange={(value) => setForm((f) => ({ ...f, containerType: selectValue(value) }))}
searchable
clearable
disabled={containerTypesLoading}
/>
)}
<Group grow> <Group grow>
<NumberInput <NumberInput
label="Free days" label="Free days"
@@ -494,6 +657,51 @@ function FeeRules() {
allowDeselect={false} allowDeselect={false}
/> />
</Group> </Group>
<Stack gap="xs">
<Group justify="space-between">
<Text size="sm" fw={600}>
Progressive tariff tiers
</Text>
<Button variant="light" size="xs" leftSection={<Plus size={14} />} onClick={addTier}>
Add tier
</Button>
</Group>
{form.tiers.map((tier, index) => (
<Group key={index} grow align="end">
<NumberInput
label="From day"
min={1}
value={tier.fromDay}
onChange={(value) => updateTier(index, { fromDay: numberValue(value, 1) || 1 })}
/>
<NumberInput
label="To day"
min={tier.fromDay}
value={tier.toDay ?? ''}
placeholder="Open"
onChange={(value) =>
updateTier(index, {
toDay: value === '' ? null : numberValue(value, tier.fromDay),
})
}
/>
<NumberInput
label="Amount / day"
min={0}
value={tier.ratePerDay}
onChange={(value) => updateTier(index, { ratePerDay: numberValue(value) })}
/>
<ActionIcon variant="subtle" color="red" onClick={() => removeTier(index)} title="Remove tier">
<Trash2 size={16} />
</ActionIcon>
</Group>
))}
{form.tiers.length === 0 && (
<Text size="xs" c="dimmed">
No stepped tiers. The flat rate per day is used after the free days.
</Text>
)}
</Stack>
<Group justify="flex-end" mt="sm"> <Group justify="flex-end" mt="sm">
<Button variant="default" onClick={() => setOpen(false)}> <Button variant="default" onClick={() => setOpen(false)}>
Cancel Cancel

View File

@@ -84,6 +84,7 @@ import type {
InventoryInquiryFilter, InventoryInquiryFilter,
InventoryInquiryResult, InventoryInquiryResult,
InventoryMovement, InventoryMovement,
InitiateWarehouseInvoicePaymentPayload,
LoadableWagon, LoadableWagon,
LoadInventoryPayload, LoadInventoryPayload,
LoadPassedExportResult, LoadPassedExportResult,
@@ -102,6 +103,7 @@ import type {
WarehouseActivityLog, WarehouseActivityLog,
WarehouseDashboard, WarehouseDashboard,
WarehouseFeeInvoice, WarehouseFeeInvoice,
WarehouseInvoicePaymentResponse,
WarehouseFilter, WarehouseFilter,
WarehouseInventoryItem, WarehouseInventoryItem,
WarehouseInvoiceFilter, WarehouseInvoiceFilter,
@@ -1111,6 +1113,18 @@ export const api = {
() => [["warehouse-fee-invoices"], ["warehouse-inventory"]], () => [["warehouse-fee-invoices"], ["warehouse-inventory"]],
), ),
payInvoiceOnline: endpoint<
{ id: string; payload: InitiateWarehouseInvoicePaymentPayload },
WarehouseInvoicePaymentResponse
>(
"warehouse-fee-invoices",
"pay-online",
({ id, payload }) =>
warehouseService.payInvoiceOnline(id, payload).then((r) => r.data),
undefined,
() => [["warehouse-fee-invoices"], ["warehouse-inventory"]],
),
gateClearance: endpoint<string, WarehouseInventoryItem>( gateClearance: endpoint<string, WarehouseInventoryItem>(
"warehouse-fee-invoices", "warehouse-fee-invoices",
"gate-clearance", "gate-clearance",

View File

@@ -18,6 +18,8 @@ import type {
WarehouseFeeInvoice, WarehouseFeeInvoice,
WarehouseInvoiceFilter, WarehouseInvoiceFilter,
PayInvoicePayload, PayInvoicePayload,
InitiateWarehouseInvoicePaymentPayload,
WarehouseInvoicePaymentResponse,
BookingScheduleView, BookingScheduleView,
InventoryFilter, InventoryFilter,
InventoryInquiryFilter, InventoryInquiryFilter,
@@ -294,6 +296,8 @@ export const warehouseService = {
apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)), apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
payInvoice: (id: string, payload: PayInvoicePayload) => payInvoice: (id: string, payload: PayInvoicePayload) =>
apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY(id), payload), apiClient.post<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY(id), payload),
payInvoiceOnline: (id: string, payload: InitiateWarehouseInvoicePaymentPayload) =>
apiClient.post<WarehouseInvoicePaymentResponse>(URL_CONSTANTS.WAREHOUSE_INVOICES.PAY_ONLINE(id), payload),
gateClearance: (inventoryId: string) => gateClearance: (inventoryId: string) =>
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}), apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}),
}; };

View File

@@ -400,6 +400,7 @@ export interface TrainScheduleDetail {
} }
export type ImportDjiboutiDocumentType = export type ImportDjiboutiDocumentType =
| "GATE_PASS"
| "DELIVERY_ORDER" | "DELIVERY_ORDER"
| "PORT_INVOICE" | "PORT_INVOICE"
| "DJIBOUTI_T1" | "DJIBOUTI_T1"
@@ -422,6 +423,7 @@ export interface ImportDjiboutiOperation {
status: { status: {
documentsComplete: boolean; documentsComplete: boolean;
missingDocuments: ImportDjiboutiDocumentType[]; missingDocuments: ImportDjiboutiDocumentType[];
gatepassStatus: "SECURED" | "NOT_SECURED";
gatepassGranted: boolean; gatepassGranted: boolean;
readyForLoading: boolean; readyForLoading: boolean;
loadedOnTrain: boolean; loadedOnTrain: boolean;
@@ -430,6 +432,8 @@ export interface ImportDjiboutiOperation {
}; };
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>; documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
gatepassGrantedAt: string | null; gatepassGrantedAt: string | null;
gatepassSecuredAt?: string | null;
gatepassStatus: "SECURED" | "NOT_SECURED";
readyForLoadingAt: string | null; readyForLoadingAt: string | null;
loadedOnTrainAt: string | null; loadedOnTrainAt: string | null;
departedFromDjiboutiAt: string | null; departedFromDjiboutiAt: string | null;
@@ -448,6 +452,10 @@ export interface UploadImportDjiboutiDocumentPayload {
} }
export interface ImportDjiboutiActionPayload { export interface ImportDjiboutiActionPayload {
securedAt?: string;
fileId?: string;
fileUrl?: string;
reference?: string;
notes?: string; notes?: string;
performedBy?: string; performedBy?: string;
} }

View File

@@ -223,6 +223,11 @@ export interface InventoryBookingRef {
tradeDirection?: string | null; tradeDirection?: string | null;
// Present when the customer requested last-mile (door) delivery — gates the Last Mile action. // Present when the customer requested last-mile (door) delivery — gates the Last Mile action.
lastMileDeliveryAddress?: string | null; lastMileDeliveryAddress?: string | null;
customerTruckPlateNumber?: string | null;
customerTruckDriverName?: string | null;
customerTruckType?: string | null;
customerTruckContainerNumber?: string | null;
customerTruckAssignedAt?: string | null;
} }
export interface InventoryMovement { export interface InventoryMovement {
@@ -398,6 +403,11 @@ export interface EligibleBooking {
firstMileDriverPhone: string | null; firstMileDriverPhone: string | null;
firstMileDriverLicenseNumber: string | null; firstMileDriverLicenseNumber: string | null;
firstMileTruckType: string | null; firstMileTruckType: string | null;
customerTruckPlateNumber: string | null;
customerTruckDriverName: string | null;
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
} }
export interface BulkReceivePayload { export interface BulkReceivePayload {
@@ -433,6 +443,7 @@ export interface TruckEntrancePayload {
packagingType?: string; packagingType?: string;
unitCount?: number; unitCount?: number;
grossWeightKg?: number; grossWeightKg?: number;
weighingRequired?: boolean;
netWeightKg?: number; netWeightKg?: number;
volumeDimensions?: string; volumeDimensions?: string;
conditionAtReceipt?: string; conditionAtReceipt?: string;
@@ -442,7 +453,7 @@ export interface TruckEntrancePayload {
driverPhone: string; driverPhone: string;
driverLicenseNumber?: string; driverLicenseNumber?: string;
truckType?: string; truckType?: string;
entranceTareWeightKg: number; entranceTareWeightKg?: number;
exitTareWeightKg?: number; exitTareWeightKg?: number;
driverSignatoryName?: string; driverSignatoryName?: string;
warehouseManagerName?: string; warehouseManagerName?: string;
@@ -573,6 +584,11 @@ export interface ImportUnloadedItem {
inspectionStatus: string | null; inspectionStatus: string | null;
pickupOption: string; pickupOption: string;
lastMileRequested: boolean; lastMileRequested: boolean;
customerTruckPlateNumber: string | null;
customerTruckDriverName: string | null;
customerTruckType: string | null;
customerTruckContainerNumber: string | null;
customerTruckAssignedAt: string | null;
currentStatus: string; currentStatus: string;
releaseDate: string | null; releaseDate: string | null;
releaseOrderReference: string | null; releaseOrderReference: string | null;
@@ -738,11 +754,25 @@ export interface FeeRule {
zoneId?: string | null; zoneId?: string | null;
freeDays: number; freeDays: number;
ratePerDay: number; ratePerDay: number;
tiers?: FeeRuleTier[];
currency: string; currency: string;
isActive: boolean; isActive: boolean;
} }
export type SaveFeeRulePayload = Omit<FeeRule, 'id'>; export type SaveFeeRulePayload = Omit<FeeRule, 'id'>;
export interface FeeRuleTier {
fromDay: number;
toDay: number | null;
ratePerDay: number;
}
export interface FeePreviewTier extends FeeRuleTier {
appliedFromDay: number;
appliedToDay: number;
days: number;
amount: number;
}
export interface FeePreview { export interface FeePreview {
ruleType: FeeRuleType; ruleType: FeeRuleType;
ruleId: string | null; ruleId: string | null;
@@ -760,6 +790,7 @@ export interface FeePreview {
containerCount: number; containerCount: number;
billableUnits: number; billableUnits: number;
amount: number; amount: number;
tiers?: FeePreviewTier[];
} }
export interface AllocationPreviewResult { export interface AllocationPreviewResult {
@@ -868,6 +899,27 @@ export interface PayInvoicePayload {
driverPhone?: string; driverPhone?: string;
} }
export type WarehouseGatewayPaymentMethod = 'TELEBIRR' | 'WAAFI';
export interface InitiateWarehouseInvoicePaymentPayload {
method: WarehouseGatewayPaymentMethod;
platform?: 'web' | 'mobile';
payerAccount?: string;
returnUrl?: string;
failureUrl?: string;
}
export interface WarehouseInvoicePaymentResponse {
intentId: string;
status?: string;
merchantOrderId?: string;
clientAction?: {
type?: string;
url?: string;
[key: string]: unknown;
};
}
// ── Payloads ─────────────────────────────────────────────────────────────── // ── Payloads ───────────────────────────────────────────────────────────────
export interface SaveWarehousePayload { export interface SaveWarehousePayload {

View File

@@ -1,5 +1,5 @@
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com'; //export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
//export const API_BASE_URL = 'http://localhost:3001'; export const API_BASE_URL = 'http://localhost:3001';
/** /**
* URL that streams an uploaded file through the API by its UUID. Routes the * URL that streams an uploaded file through the API by its UUID. Routes the

View File

@@ -1,3 +1,4 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
@@ -9,6 +10,7 @@ import {
Group, Group,
Loader, Loader,
Paper, Paper,
Select,
SimpleGrid, SimpleGrid,
Stack, Stack,
Table, Table,
@@ -44,6 +46,7 @@ function MetaItem({ label, value }: { label: string; value: string }) {
export default function InvoiceDetailPage() { export default function InvoiceDetailPage() {
const { id = "" } = useParams(); const { id = "" } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">("TELEBIRR");
const { data: invoice, isLoading, isError } = useQuery( const { data: invoice, isLoading, isError } = useQuery(
api.invoices.get.queryOptions({ input: { id } }), api.invoices.get.queryOptions({ input: { id } }),
@@ -58,6 +61,11 @@ export default function InvoiceDetailPage() {
}), }),
); );
useEffect(() => {
if (!invoice) return;
setPaymentMethod(invoice.currency === "USD" ? "WAAFI" : "TELEBIRR");
}, [invoice?.currency]);
if (isLoading) { if (isLoading) {
return ( return (
<Center py={80}> <Center py={80}>
@@ -88,11 +96,12 @@ export default function InvoiceDetailPage() {
const payable = isPayable(invoice.status); const payable = isPayable(invoice.status);
const lines = invoice.lines ?? []; const lines = invoice.lines ?? [];
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
const handlePay = () => { const handlePay = () => {
const returnUrl = `${window.location.origin}/payment/success`; const returnUrl = `${window.location.origin}/payment/success`;
const failureUrl = `${window.location.origin}/payment/failure`; const failureUrl = `${window.location.origin}/payment/failure`;
payMutation.mutate({ id, payload: { returnUrl, failureUrl } }); payMutation.mutate({ id, payload: { method: paymentMethod, returnUrl, failureUrl } });
}; };
return ( return (
@@ -118,17 +127,29 @@ export default function InvoiceDetailPage() {
<InvoiceStatusBadge status={invoice.status} /> <InvoiceStatusBadge status={invoice.status} />
</Group> </Group>
{payable && ( {payable && (
<Button <Group align="flex-end" gap="sm">
color="edr-green" <Select
radius="md" label="Payment provider"
size="md" value={paymentMethod}
leftSection={<CreditCard size={16} />} onChange={(value) => setPaymentMethod((value as "TELEBIRR" | "WAAFI") ?? "TELEBIRR")}
loading={payMutation.isPending} data={[
onClick={handlePay} { value: "TELEBIRR", label: "Telebirr" },
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }} { value: "WAAFI", label: "Waafi" },
> ]}
Pay {formatCurrency(Number(invoice.totalAmount), invoice.currency)} w={170}
</Button> />
<Button
color="edr-green"
radius="md"
size="md"
leftSection={<CreditCard size={16} />}
loading={payMutation.isPending}
onClick={handlePay}
styles={{ root: { fontWeight: 600, height: 42, paddingInline: 18 } }}
>
Pay {formatCurrency(amountDue, invoice.currency)}
</Button>
</Group>
)} )}
</Group> </Group>

View File

@@ -16,6 +16,7 @@ import { ActivityCard } from "./components/ActivityCard";
import { ClearanceCard } from "./components/ClearanceCard"; import { ClearanceCard } from "./components/ClearanceCard";
import { ContainersCard } from "./components/ContainersCard"; import { ContainersCard } from "./components/ContainersCard";
import { ContractCard } from "./components/ContractCard"; import { ContractCard } from "./components/ContractCard";
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
import { DocRow, IconSquare } from "./components/Documents"; import { DocRow, IconSquare } from "./components/Documents";
import { KeyFactsStrip } from "./components/KeyFactsStrip"; import { KeyFactsStrip } from "./components/KeyFactsStrip";
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout"; import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
@@ -35,7 +36,13 @@ import { StatusHero } from "./components/StatusHero";
import { SupportCard } from "./components/SupportCard"; import { SupportCard } from "./components/SupportCard";
import { fmtDate, isNegative, priceTotal } from "./utils"; import { fmtDate, isNegative, priceTotal } from "./utils";
export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking }) { export function ReadonlyBookingView({
booking,
onBookingUpdated,
}: {
booking: Freight.IBooking;
onBookingUpdated?: () => void;
}) {
const navigate = useNavigate(); const navigate = useNavigate();
const status = booking.status as string; const status = booking.status as string;
const [payModalOpen, setPayModalOpen] = useState(false); const [payModalOpen, setPayModalOpen] = useState(false);
@@ -73,7 +80,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
(isGeneralContract (isGeneralContract
? status === "FULLY_EXECUTED" ? status === "FULLY_EXECUTED"
: status === "SELECTED_FOR_BATCH"); : status === "SELECTED_FOR_BATCH");
const canApproveDelivery = status === "COMPLETED"; const canApproveDelivery =
status === "COMPLETED" ||
(status === "TRUCK_ASSIGNED" && Boolean(booking.customerTruckArrivedAt));
const canAssignCustomerTruck =
booking.tradeDirection === "IMPORT" &&
booking.paymentStatus === "PAID" &&
!booking.lastMileDeliveryAddress &&
["PAID", "IN_TRANSIT", "COMPLETED", "TRUCK_ASSIGNED"].includes(status);
const showCountdown = canPay && !!booking.paymentDeadline; const showCountdown = canPay && !!booking.paymentDeadline;
const isExpired = status === "EXPIRED"; const isExpired = status === "EXPIRED";
const isPendingConsolidation = status === "PENDING_CONSOLIDATION"; const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
@@ -164,6 +178,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
<ShipmentTrackingCard bookingId={booking.id} /> <ShipmentTrackingCard bookingId={booking.id} />
{canAssignCustomerTruck && (
<CustomerTruckAssignmentCard
booking={booking}
onAssigned={onBookingUpdated ?? (() => {})}
/>
)}
{booking.files && booking.files.length > 0 && ( {booking.files && booking.files.length > 0 && (
<SectionCard> <SectionCard>
<Group justify="space-between" align="center" mb="md"> <Group justify="space-between" align="center" mb="md">

View File

@@ -0,0 +1,151 @@
import { Alert, Button, Group, Select, SimpleGrid, Stack, Text, TextInput } from "@mantine/core";
import { useMutation } from "@tanstack/react-query";
import type { Freight } from "@edr/types";
import { Download, Lock, Truck } from "lucide-react";
import { useState } from "react";
import { api } from "@/services/api";
import { CardTitle, SectionCard } from "./layout";
const TRUCK_TYPES = ["Flatbed", "Container Chassis", "Lowboy", "Box Truck", "Tipper"];
const ISO_CONTAINER_PATTERN = /^[A-Z]{4}\d{7}$/;
const downloadBlob = (blob: Blob, filename: string) => {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
export function CustomerTruckAssignmentCard({
booking,
onAssigned,
}: {
booking: Freight.IBooking;
onAssigned: () => void;
}) {
const assigned = Boolean(booking.customerTruckAssignedAt);
const [truckPlateNumber, setTruckPlateNumber] = useState(booking.customerTruckPlateNumber ?? "");
const [driverName, setDriverName] = useState(booking.customerTruckDriverName ?? "");
const [truckType, setTruckType] = useState(booking.customerTruckType ?? "");
const [containerNumberToLoad, setContainerNumberToLoad] = useState(
booking.customerTruckContainerNumber ?? "",
);
const [error, setError] = useState<string | null>(null);
const assignMutation = useMutation(api.bookings.assignCustomerTruck.mutationOptions());
const downloadMutation = useMutation(api.bookings.downloadCustomerTruckFreightOrder.mutationOptions());
const submit = async () => {
const payload = {
truckPlateNumber: truckPlateNumber.trim().toUpperCase(),
driverName: driverName.trim(),
truckType: truckType.trim(),
containerNumberToLoad: containerNumberToLoad.trim().toUpperCase(),
};
if (!payload.truckPlateNumber || !payload.driverName || !payload.truckType || !payload.containerNumberToLoad) {
setError("All truck assignment fields are required.");
return;
}
if (!ISO_CONTAINER_PATTERN.test(payload.containerNumberToLoad)) {
setError("Container number must match ISO format, e.g. ABCD1234567.");
return;
}
setError(null);
await assignMutation.mutateAsync({ id: booking.id, payload });
onAssigned();
};
const downloadFreightOrder = async () => {
const blob = await downloadMutation.mutateAsync({ id: booking.id });
downloadBlob(blob, `freight-order-${booking.reference}.pdf`);
};
return (
<SectionCard>
<Stack gap="md">
<Group justify="space-between" align="center">
<Group gap={10}>
<Truck size={18} color="#0a9f6a" />
<CardTitle>External Truck Assignment</CardTitle>
</Group>
{assigned && (
<Group gap={6} c="#0a9f6a">
<Lock size={14} />
<Text size="sm" fw={700}>
Truck Assigned
</Text>
</Group>
)}
</Group>
{error && (
<Alert color="red" variant="light">
{error}
</Alert>
)}
{assignMutation.isError && (
<Alert color="red" variant="light">
{assignMutation.error instanceof Error
? assignMutation.error.message
: "Truck assignment failed."}
</Alert>
)}
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="sm">
<TextInput
label="Truck Plate Number"
required
value={truckPlateNumber}
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
readOnly={assigned}
/>
<TextInput
label="Driver Name"
required
value={driverName}
onChange={(e) => setDriverName(e.currentTarget.value)}
readOnly={assigned}
/>
<Select
label="Truck Type"
required
data={TRUCK_TYPES}
value={truckType || null}
onChange={(value) => setTruckType(value ?? "")}
disabled={assigned}
/>
<TextInput
label="Container Number to Load"
required
value={containerNumberToLoad}
onChange={(e) => setContainerNumberToLoad(e.currentTarget.value.toUpperCase())}
readOnly={assigned}
/>
</SimpleGrid>
<Group justify="flex-end">
{assigned ? (
<Button
leftSection={<Download size={16} />}
color="edr-green"
onClick={downloadFreightOrder}
loading={downloadMutation.isPending}
>
Generate Freight Order Copies
</Button>
) : (
<Button color="edr-green" onClick={submit} loading={assignMutation.isPending}>
Verify & Submit Assignment
</Button>
)}
</Group>
</Stack>
</SectionCard>
);
}

View File

@@ -94,5 +94,5 @@ export default function BookingDetailPage() {
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} /> <DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
); );
} }
return <ReadonlyBookingView booking={booking} />; return <ReadonlyBookingView booking={booking} onBookingUpdated={refetchBooking} />;
} }

View File

@@ -11,6 +11,7 @@ import {
type ApproveDeliveryResponse, type ApproveDeliveryResponse,
BookingListFilter, BookingListFilter,
CreateBookingPayload, CreateBookingPayload,
type CustomerTruckAssignmentPayload,
GeneratePriceResponse, GeneratePriceResponse,
SubmitBookingResponse, SubmitBookingResponse,
} from "./bookings.service"; } from "./bookings.service";
@@ -210,6 +211,19 @@ export const api = {
({ id }) => bookingsService.tracking(id), ({ id }) => bookingsService.tracking(id),
), ),
assignCustomerTruck: endpoint<
{ id: string; payload: CustomerTruckAssignmentPayload },
Freight.IBooking
>("bookings", "assignCustomerTruck", ({ id, payload }) =>
bookingsService.assignCustomerTruck(id, payload),
),
downloadCustomerTruckFreightOrder: endpoint<{ id: string }, Blob>(
"bookings",
"downloadCustomerTruckFreightOrder",
({ id }) => bookingsService.downloadCustomerTruckFreightOrder(id),
),
create: endpoint< create: endpoint<
{ payload: CreateBookingPayload; documents?: BookingDocuments }, { payload: CreateBookingPayload; documents?: BookingDocuments },
Freight.IBooking Freight.IBooking

View File

@@ -79,6 +79,13 @@ export interface ApproveDeliveryResponse {
signerDisplayName: string; signerDisplayName: string;
} }
export interface CustomerTruckAssignmentPayload {
truckPlateNumber: string;
driverName: string;
truckType: string;
containerNumberToLoad: string;
}
export interface BookingListFilter { export interface BookingListFilter {
status?: string; status?: string;
/** Comma-separated statuses (overrides `status` when set). */ /** Comma-separated statuses (overrides `status` when set). */
@@ -111,6 +118,23 @@ export const bookingsService = {
const { data } = await client.get(`/api/bookings/${id}`); const { data } = await client.get(`/api/bookings/${id}`);
return data.data; return data.data;
}, },
assignCustomerTruck: async (
id: string,
payload: CustomerTruckAssignmentPayload,
): Promise<Freight.IBooking> => {
const { data } = await client.post(
`/api/bookings/${id}/customer-truck-assignment`,
payload,
);
return data.data;
},
downloadCustomerTruckFreightOrder: async (id: string): Promise<Blob> => {
const { data } = await client.get(
`/api/bookings/${id}/customer-truck-assignment/freight-order`,
{ responseType: "blob" },
);
return data;
},
tracking: async (id: string): Promise<Freight.IBookingTracking> => { tracking: async (id: string): Promise<Freight.IBookingTracking> => {
const { data } = await client.get(`/api/bookings/${id}/tracking`); const { data } = await client.get(`/api/bookings/${id}/tracking`);
return data.data; return data.data;

View File

@@ -89,6 +89,7 @@ export enum BookingStatus {
Cancelled = "CANCELLED", Cancelled = "CANCELLED",
PendingConsolidation = "PENDING_CONSOLIDATION", PendingConsolidation = "PENDING_CONSOLIDATION",
Consolidated = "CONSOLIDATED", Consolidated = "CONSOLIDATED",
TruckAssigned = "TRUCK_ASSIGNED",
/** General contract: paid umbrella contract that is accepting drawdown orders. */ /** General contract: paid umbrella contract that is accepting drawdown orders. */
ContractActive = "CONTRACT_ACTIVE", ContractActive = "CONTRACT_ACTIVE",
/** General contract: closed because its quantity was exhausted (or period elapsed). */ /** General contract: closed because its quantity was exhausted (or period elapsed). */
@@ -422,6 +423,12 @@ export interface IBooking extends BaseEntity {
lastMileDeliveryAddress?: string | null; lastMileDeliveryAddress?: string | null;
lastMileDeliveryLat?: number | null; lastMileDeliveryLat?: number | null;
lastMileDeliveryLng?: number | null; lastMileDeliveryLng?: number | null;
customerTruckPlateNumber?: string | null;
customerTruckDriverName?: string | null;
customerTruckType?: string | null;
customerTruckContainerNumber?: string | null;
customerTruckAssignedAt?: string | null;
customerTruckArrivedAt?: string | null;
customsClearingEnabled?: boolean; customsClearingEnabled?: boolean;
customsClearingAgent?: string | null; customsClearingAgent?: string | null;