mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
train gate pass, Telebirr and Wafi
This commit is contained in:
@@ -59,6 +59,7 @@ import { FreightPermissionKeyMigrationSeeder } from "./seed/freight-permission-k
|
||||
import { DemoFreightDataSeeder } from "./seed/demo-freight-data.seeder";
|
||||
import { GovCompaniesSeeder } from "./seed/gov-companies.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
|
||||
import { TrainsModule } from "./modules/trains/trains.module";
|
||||
import { WagonsModule } from './modules/wagons/wagons.module';
|
||||
@@ -156,6 +157,7 @@ import { ImportOperationsModule } from './modules/import-operations/import-opera
|
||||
ExportDjiboutiInterchangeDemoSeeder,
|
||||
MarshallingDemoTrainsSeeder,
|
||||
ApprovedFirstLastMileDemoBookingsSeeder,
|
||||
PaidIndodeDemoBookingsSeeder,
|
||||
],
|
||||
})
|
||||
export class AppModule implements OnApplicationBootstrap {
|
||||
@@ -174,6 +176,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
private readonly warehouseDemoSeeder: WarehouseDemoSeeder,
|
||||
private readonly exportDjiboutiInterchangeDemoSeeder: ExportDjiboutiInterchangeDemoSeeder,
|
||||
private readonly marshallingDemoTrainsSeeder: MarshallingDemoTrainsSeeder,
|
||||
private readonly paidIndodeDemoBookingsSeeder: PaidIndodeDemoBookingsSeeder,
|
||||
private readonly freightPermissionKeyMigrationSeeder: FreightPermissionKeyMigrationSeeder,
|
||||
private readonly demoFreightDataSeeder: DemoFreightDataSeeder,
|
||||
private readonly govCompaniesSeeder: GovCompaniesSeeder,
|
||||
@@ -195,6 +198,7 @@ export class AppModule implements OnApplicationBootstrap {
|
||||
await this.warehouseDemoSeeder.run();
|
||||
await this.exportDjiboutiInterchangeDemoSeeder.run();
|
||||
await this.marshallingDemoTrainsSeeder.run();
|
||||
await this.paidIndodeDemoBookingsSeeder.run();
|
||||
// Idempotent demo data: ≥100 wagons/type, approval chains, 4 staff users.
|
||||
// Each block self-guards on an empty-table check, so this is safe every boot.
|
||||
// Demo data seeds (DemoBookingsSeeder, PricingDataSeeder,
|
||||
|
||||
@@ -56,6 +56,7 @@ export class AddWarehouseAllocationAndFeeRules1791000000000 implements Migration
|
||||
{ name: 'zone_id', type: 'uuid', isNullable: true },
|
||||
{ name: 'free_days', type: 'int', 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: 'is_active', type: 'boolean', default: true },
|
||||
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
|
||||
|
||||
@@ -62,20 +62,96 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
`);
|
||||
|
||||
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(
|
||||
`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(
|
||||
`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(
|
||||
`CREATE INDEX idx_invoices_status ON freight.invoices (status);`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_invoices_status ON freight.invoices (status);`,
|
||||
);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE freight.invoice_lines (
|
||||
CREATE TABLE IF NOT EXISTS freight.invoice_lines (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v4(),
|
||||
invoice_id uuid NOT NULL,
|
||||
charge_type varchar NOT NULL,
|
||||
@@ -95,7 +171,7 @@ export class CreateInvoices1821000000002 implements MigrationInterface {
|
||||
`);
|
||||
|
||||
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);`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -657,6 +657,11 @@ export class BillingService {
|
||||
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({
|
||||
referenceId: sourceId,
|
||||
source: invoice.source,
|
||||
@@ -667,7 +672,7 @@ export class BillingService {
|
||||
// service branches on a domain-specific reference type.
|
||||
referenceType: PaymentReferenceType.SHIPMENT,
|
||||
orderRef: invoice.invoiceNumber,
|
||||
amountMinor: Math.round(Number(invoice.totalAmount)),
|
||||
amountMinor: Math.round(amountDue),
|
||||
currency: invoice.currency,
|
||||
reason: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
method: opts.method ?? "TELEBIRR",
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
StaffRejectDto,
|
||||
} from './dto/request-changes.dto';
|
||||
import { ContractViewDto } from './dto/contract-view.dto';
|
||||
import { CustomerTruckAssignmentDto } from './dto/customer-truck-assignment.dto';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import {
|
||||
@@ -254,6 +255,39 @@ export class BookingsController {
|
||||
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')
|
||||
@ApiOperation({
|
||||
summary: 'Shipment tracking timeline for a booking',
|
||||
|
||||
@@ -45,6 +45,8 @@ import {
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingContainerAllocation } from './entities/booking-container-allocation.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). */
|
||||
export interface PaginatedBookings {
|
||||
@@ -81,8 +83,54 @@ export class BookingsService {
|
||||
private readonly ruleEngineService: RuleEngineService,
|
||||
private readonly containerTypesService: ContainerTypesService,
|
||||
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. */
|
||||
private async resolveTradeDirectionForBooking(
|
||||
originYardId: string,
|
||||
@@ -120,6 +168,79 @@ export class BookingsService {
|
||||
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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/** Build evaluation input from booking freight shape. */
|
||||
/**
|
||||
* Whether a service type bundles customs clearance. This is the single source
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -51,6 +51,7 @@ export const BOOKING_STATUSES = [
|
||||
// Road (truck) drawdown orders skip the train batch pool and wait here for
|
||||
// truck dispatch after Marketing accepts; billed by KM, not wagons.
|
||||
'ROAD_DISPATCH_PENDING',
|
||||
'TRUCK_ASSIGNED',
|
||||
'OPERATION_REQUESTED',
|
||||
// Operations review gate: customer picks a schedule day and submits the
|
||||
// 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 })
|
||||
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 })
|
||||
customsClearingEnabled!: boolean;
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 = [
|
||||
'GATE_PASS',
|
||||
'DELIVERY_ORDER',
|
||||
'PORT_INVOICE',
|
||||
'DJIBOUTI_T1',
|
||||
@@ -43,6 +44,26 @@ export class UploadImportDjiboutiDocumentDto {
|
||||
}
|
||||
|
||||
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()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
|
||||
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| 'GATE_PASS'
|
||||
| 'DELIVERY_ORDER'
|
||||
| 'PORT_INVOICE'
|
||||
| 'DJIBOUTI_T1'
|
||||
|
||||
@@ -54,7 +54,6 @@ import {
|
||||
type ImportDjiboutiDocumentType,
|
||||
} from './entities/import-djibouti-operation.entity';
|
||||
import {
|
||||
IMPORT_DJIBOUTI_DOCUMENT_TYPES,
|
||||
ImportDjiboutiActionDto,
|
||||
UploadImportDjiboutiDocumentDto,
|
||||
} from './dto/import-djibouti-operation.dto';
|
||||
@@ -832,7 +831,7 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
async getImportDjiboutiOperation(scheduleId: string) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
return this.mapImportDjiboutiOperation(schedule, operation);
|
||||
}
|
||||
@@ -841,7 +840,7 @@ export class TrainSchedulingService {
|
||||
scheduleId: string,
|
||||
dto: UploadImportDjiboutiDocumentDto,
|
||||
) {
|
||||
const schedule = await this.getImportDjiboutiSchedule(scheduleId);
|
||||
const schedule = await this.getDjiboutiGatepassSchedule(scheduleId);
|
||||
const operation = await this.getOrCreateImportDjiboutiOperation(scheduleId);
|
||||
const documents = {
|
||||
...(operation.documents ?? {}),
|
||||
@@ -865,21 +864,30 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
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 missing = this.missingImportDjiboutiDocuments(operation);
|
||||
if (missing.length) {
|
||||
throw new BadRequestException(`Gatepass cannot be granted until documents are uploaded: ${missing.join(', ')}`);
|
||||
const securedAt = dto.securedAt ? new Date(dto.securedAt) : new Date();
|
||||
const documents = { ...(operation.documents ?? {}) };
|
||||
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, {
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? new Date(),
|
||||
documents,
|
||||
gatepassGrantedAt: securedAt,
|
||||
performedBy: dto.performedBy ?? operation.performedBy ?? null,
|
||||
notes: dto.notes ?? operation.notes ?? null,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[NOTIFY] Import gatepass granted for train ${schedule.trainNumber ?? schedule.id}; loading may proceed.`,
|
||||
`[NOTIFY] Gate pass secured for train ${schedule.trainNumber ?? schedule.id}; Djibouti Port entry is allowed.`,
|
||||
);
|
||||
return this.getImportDjiboutiOperation(schedule.id);
|
||||
}
|
||||
@@ -1299,16 +1307,28 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
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);
|
||||
if (!schedule) {
|
||||
throw new NotFoundException(`Train schedule ${scheduleId} not found`);
|
||||
}
|
||||
if (!this.isImportDjiboutiSchedule(schedule)) {
|
||||
throw new BadRequestException('Batch 7 actions apply only to IMPORT schedules originating from Djibouti');
|
||||
if (!this.isDjiboutiGatepassSchedule(schedule)) {
|
||||
throw new BadRequestException('Gate pass applies only to trains entering Djibouti Port on import or export routes');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
|
||||
private isDjiboutiGatepassSchedule(schedule: TrainSchedule): boolean {
|
||||
return this.isImportDjiboutiSchedule(schedule) || this.isExportDjiboutiSchedule(schedule);
|
||||
}
|
||||
|
||||
private isImportDjiboutiSchedule(schedule: TrainSchedule): boolean {
|
||||
const direction =
|
||||
(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> {
|
||||
const repo = this.dataSource.getRepository(ImportDjiboutiOperation);
|
||||
const existing = await repo.findOne({ where: { trainScheduleId: scheduleId } });
|
||||
@@ -1331,8 +1365,8 @@ export class TrainSchedulingService {
|
||||
}
|
||||
|
||||
private missingImportDjiboutiDocuments(operation?: ImportDjiboutiOperation | null): ImportDjiboutiDocumentType[] {
|
||||
const documents = operation?.documents ?? {};
|
||||
return IMPORT_DJIBOUTI_DOCUMENT_TYPES.filter((type) => !documents[type]);
|
||||
void operation;
|
||||
return [];
|
||||
}
|
||||
|
||||
private assertImportDjiboutiGatepassGranted(operation?: ImportDjiboutiOperation | null): void {
|
||||
@@ -1343,6 +1377,7 @@ export class TrainSchedulingService {
|
||||
|
||||
private mapImportDjiboutiOperation(schedule: TrainSchedule, operation: ImportDjiboutiOperation) {
|
||||
const missingDocuments = this.missingImportDjiboutiDocuments(operation);
|
||||
const gatepassStatus = operation.gatepassGrantedAt ? 'SECURED' : 'NOT_SECURED';
|
||||
return {
|
||||
trainScheduleId: schedule.id,
|
||||
trainNumber: schedule.trainNumber ?? null,
|
||||
@@ -1350,6 +1385,7 @@ export class TrainSchedulingService {
|
||||
status: {
|
||||
documentsComplete: missingDocuments.length === 0,
|
||||
missingDocuments,
|
||||
gatepassStatus,
|
||||
gatepassGranted: Boolean(operation.gatepassGrantedAt),
|
||||
readyForLoading: Boolean(operation.readyForLoadingAt),
|
||||
loadedOnTrain: Boolean(operation.loadedOnTrainAt),
|
||||
@@ -1358,6 +1394,8 @@ export class TrainSchedulingService {
|
||||
},
|
||||
documents: operation.documents ?? {},
|
||||
gatepassGrantedAt: operation.gatepassGrantedAt ?? null,
|
||||
gatepassSecuredAt: operation.gatepassGrantedAt ?? null,
|
||||
gatepassStatus,
|
||||
readyForLoadingAt: operation.readyForLoadingAt ?? null,
|
||||
loadedOnTrainAt: operation.loadedOnTrainAt ?? null,
|
||||
departedFromDjiboutiAt: operation.departedFromDjiboutiAt ?? null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
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';
|
||||
|
||||
export class TruckEntranceDto {
|
||||
@@ -90,6 +90,11 @@ export class TruckEntranceDto {
|
||||
@Min(0)
|
||||
grossWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Whether the customer truck was weighed at receipt.' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
weighingRequired?: boolean;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@@ -135,10 +140,11 @@ export class TruckEntranceDto {
|
||||
@IsString()
|
||||
truckType?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
entranceTareWeightKg!: number;
|
||||
entranceTareWeightKg?: number;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,8 +1,27 @@
|
||||
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';
|
||||
|
||||
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 {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@@ -67,6 +86,13 @@ export class CreateFeeRuleDto {
|
||||
@Min(0)
|
||||
ratePerDay!: number;
|
||||
|
||||
@ApiPropertyOptional({ type: [FeeRuleTierDto] })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => FeeRuleTierDto)
|
||||
tiers?: FeeRuleTierDto[];
|
||||
|
||||
@ApiPropertyOptional({ default: 'USD' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -4,6 +4,12 @@ import { Column, Entity, Index } from 'typeorm';
|
||||
export const FEE_RULE_TYPES = ['STORAGE_FEE', 'DEMURRAGE_FEE'] as const;
|
||||
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).
|
||||
* 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 })
|
||||
ratePerDay!: number;
|
||||
|
||||
@Column({ name: 'tiers', type: 'jsonb', default: () => "'[]'" })
|
||||
tiers!: WarehouseFeeTier[];
|
||||
|
||||
@Column({ name: 'currency', type: 'varchar', length: 8, default: 'USD' })
|
||||
currency!: string;
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
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';
|
||||
|
||||
interface ItemAttributes {
|
||||
@@ -39,6 +39,15 @@ export interface FeePreview {
|
||||
containerCount: number;
|
||||
billableUnits: 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;
|
||||
@@ -57,11 +66,16 @@ export class WarehouseFeeService {
|
||||
}
|
||||
|
||||
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> {
|
||||
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`);
|
||||
return updated;
|
||||
}
|
||||
@@ -70,6 +84,40 @@ export class WarehouseFeeService {
|
||||
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> {
|
||||
const [row] = await this.dataSource.query(
|
||||
`SELECT inv.arrived_at AS "arrivedAt",
|
||||
@@ -82,16 +130,27 @@ export class WarehouseFeeService {
|
||||
w.facility_id AS "facilityId",
|
||||
b.freight_type AS "freightType",
|
||||
b.trade_direction AS "tradeDirection",
|
||||
cgt.code AS "cargoTypeCode",
|
||||
ctt.code AS "containerTypeCode",
|
||||
COALESCE(cgt.code, booking_cgt.code) AS "cargoTypeCode",
|
||||
COALESCE(ctt.code, booking_ctt.code) AS "containerTypeCode",
|
||||
COALESCE(container_lines.container_count, 0) AS "bookingContainerCount"
|
||||
FROM freight.warehouse_inventory inv
|
||||
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.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 booking_cgt ON booking_cgt.id = b.cargo_type_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 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 (
|
||||
SELECT COALESCE(SUM(bc.quantity), 0)::int AS container_count
|
||||
FROM freight.booking_container bc
|
||||
@@ -108,16 +167,26 @@ export class WarehouseFeeService {
|
||||
private matchScore(rule: WarehouseFeeRule, item: ItemAttributes): number | null {
|
||||
// Returns specificity score (#matched non-null scope fields), or null if any constraint fails.
|
||||
let score = 0;
|
||||
const check = (ruleVal: string | null | undefined, itemVal: string | null) => {
|
||||
if (ruleVal == null) return true;
|
||||
if (itemVal != null && ruleVal.toUpperCase() === itemVal.toUpperCase()) {
|
||||
const normalized = (value: string | null | undefined) => value?.trim().toUpperCase() || null;
|
||||
const check = (
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
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.containerType, item.containerTypeCode)) return null;
|
||||
if (!check(rule.facilityId, item.facilityId)) return null;
|
||||
@@ -153,6 +222,60 @@ export class WarehouseFeeService {
|
||||
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(
|
||||
ruleType: FeeRuleType,
|
||||
rule: WarehouseFeeRule | null,
|
||||
@@ -176,13 +299,25 @@ export class WarehouseFeeService {
|
||||
const elapsedDays = start
|
||||
? Math.max(0, Math.ceil((new Date(endDate).getTime() - start.getTime()) / MS_PER_DAY))
|
||||
: 0;
|
||||
const chargeableDays = Math.max(0, elapsedDays - freeDays);
|
||||
const billableUnits = chargeableDays * containerCount;
|
||||
const sourceAmount = Math.round(billableUnits * ratePerDay * 100) / 100;
|
||||
const tiered = this.calculateTieredAmount(rule?.tiers, elapsedDays, containerCount);
|
||||
const hasTiers = Boolean(rule?.tiers?.length);
|
||||
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 sourceRatePerDay = hasTiers ? tiered.weightedRatePerDay : ratePerDay;
|
||||
const convertedRatePerDay = ruleCurrency
|
||||
? await this.convertAmount(ratePerDay, ruleCurrency, targetCurrency)
|
||||
? await this.convertAmount(sourceRatePerDay, ruleCurrency, targetCurrency)
|
||||
: 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 {
|
||||
ruleType,
|
||||
@@ -201,6 +336,7 @@ export class WarehouseFeeService {
|
||||
containerCount,
|
||||
billableUnits,
|
||||
amount,
|
||||
tiers: hasTiers ? convertedTiers : [],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -221,6 +221,11 @@ export interface EligibleBookingRow {
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceiveResult {
|
||||
@@ -302,6 +307,11 @@ export interface ImportUnloadedRow {
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -704,7 +714,12 @@ export class WarehouseInventoryService {
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
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
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_id
|
||||
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
|
||||
@@ -796,7 +811,12 @@ export class WarehouseInventoryService {
|
||||
) AS "firstMileDriverName",
|
||||
driver.phone_number AS "firstMileDriverPhone",
|
||||
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
|
||||
LEFT JOIN freight.companies company ON company.id = b.company_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
|
||||
THEN 'DOOR_DELIVERY' ELSE 'TERMINAL_PICKUP' END AS "pickupOption",
|
||||
(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.release_date AS "releaseDate",
|
||||
inv.release_order_reference AS "releaseOrderReference",
|
||||
@@ -1336,6 +1361,16 @@ export class WarehouseInventoryService {
|
||||
if (!['ARRIVED', 'ARRIVED_AT_DJIBOUTI'].includes(schedule.status)) {
|
||||
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<{
|
||||
bookingId: string;
|
||||
@@ -1955,6 +1990,9 @@ export class WarehouseInventoryService {
|
||||
}
|
||||
|
||||
const isTruckLeaving = dto.grossWeight !== undefined && Boolean(dto.gateOutTime);
|
||||
if (isTruckLeaving) {
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
}
|
||||
const releaseDate = isTruckLeaving
|
||||
? dto.releaseDate ? new Date(dto.releaseDate) : new Date()
|
||||
: item.releaseDate ?? null;
|
||||
@@ -1967,6 +2005,17 @@ export class WarehouseInventoryService {
|
||||
releaseOrderReference: reference,
|
||||
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(
|
||||
{
|
||||
activityType: 'INVENTORY_RELEASED',
|
||||
@@ -2034,6 +2083,7 @@ export class WarehouseInventoryService {
|
||||
if (!row.releaseDate) {
|
||||
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 reference =
|
||||
@@ -2180,11 +2230,19 @@ export class WarehouseInventoryService {
|
||||
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(
|
||||
`SELECT inv.id,
|
||||
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
|
||||
JOIN freight.bookings b ON b.id = inv.booking_id AND b.deleted_at IS NULL
|
||||
WHERE inv.booking_id = $1
|
||||
@@ -2198,6 +2256,10 @@ export class WarehouseInventoryService {
|
||||
if (!item) {
|
||||
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 approval = {
|
||||
@@ -2301,6 +2363,7 @@ export class WarehouseInventoryService {
|
||||
if (!row) {
|
||||
throw new NotFoundException(`Inventory item ${id} not found`);
|
||||
}
|
||||
await this.invoices.assertClearanceAllowed(id);
|
||||
if (row.inspectionStatus !== 'PASSED') {
|
||||
throw new BadRequestException('Handover document is available after inspection has passed');
|
||||
}
|
||||
@@ -3302,8 +3365,13 @@ export class WarehouseInventoryService {
|
||||
if (!truckEntrance.driverPhone?.trim()) {
|
||||
throw new BadRequestException('Driver phone is required for entrance registration');
|
||||
}
|
||||
if (truckEntrance.entranceTareWeightKg === undefined || Number(truckEntrance.entranceTareWeightKg) < 0) {
|
||||
throw new BadRequestException('Entrance tare weight is required for entrance registration');
|
||||
if (truckEntrance.weighingRequired) {
|
||||
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;
|
||||
firstMileDriverLicenseNumber?: string | null;
|
||||
firstMileTruckType?: string | null;
|
||||
customerTruckPlateNumber?: string | null;
|
||||
customerTruckDriverName?: string | null;
|
||||
customerTruckType?: string | null;
|
||||
customerTruckContainerNumber?: string | null;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
},
|
||||
): TruckEntranceDto {
|
||||
return {
|
||||
@@ -3340,16 +3413,22 @@ export class WarehouseInventoryService {
|
||||
booking.containerQuantity !== undefined && booking.containerQuantity !== null
|
||||
? Number(booking.containerQuantity)
|
||||
: submitted.unitCount,
|
||||
grossWeightKg:
|
||||
booking.weight !== undefined && booking.weight !== null
|
||||
? Number(booking.weight)
|
||||
: submitted.grossWeightKg,
|
||||
truckPlateNumber: booking.firstMileTruckPlateNumber?.trim() || submitted.truckPlateNumber,
|
||||
grossWeightKg: submitted.grossWeightKg,
|
||||
truckPlateNumber:
|
||||
booking.firstMileTruckPlateNumber?.trim() ||
|
||||
booking.customerTruckPlateNumber?.trim() ||
|
||||
submitted.truckPlateNumber,
|
||||
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,
|
||||
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?.driverLicenseNumber ? `Driver License: ${truck.driverLicenseNumber}` : 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?.declarationNumber ? `Declaration / Bill of Entry: ${truck.declarationNumber}` : null,
|
||||
truck?.incoterms ? `Incoterms: ${truck.incoterms}` : null,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Res }
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { GenerateInvoiceDto, PayInvoiceBodyDto } from './dto/invoice.dto';
|
||||
import { WarehouseInvoiceService } from './warehouse-invoice.service';
|
||||
|
||||
@@ -86,4 +87,10 @@ export class WarehouseInvoiceController {
|
||||
pay(@Param('id', ParseUUIDPipe) id: string, @Body() dto: PayInvoiceBodyDto) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { OnEvent } from '@nestjs/event-emitter';
|
||||
import { Freight } from '@edr/types';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { PayInvoiceDto as GatewayPayInvoiceDto } from '../billing/dto/pay-invoice.dto';
|
||||
import { BillingService, InvoiceEventPayload, InvoiceLineInput } from '../billing/billing.service';
|
||||
import { Invoice } from '../billing/entities/invoice.entity';
|
||||
import { InvoiceLine } from '../billing/entities/invoice-line.entity';
|
||||
@@ -171,8 +172,12 @@ export class WarehouseInvoiceService {
|
||||
feeType,
|
||||
description:
|
||||
p.ruleType === 'STORAGE_FEE'
|
||||
? `Storage fee - ${p.chargeableDays} chargeable day(s) x ${p.containerCount} container(s) after ${p.freeDays} free`
|
||||
: `${isContainer ? 'Container' : 'Bulk'} demurrage - ${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)${
|
||||
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,
|
||||
unitRate: p.ratePerDay,
|
||||
amount: p.amount,
|
||||
@@ -305,6 +310,22 @@ export class WarehouseInvoiceService {
|
||||
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
|
||||
* fee being paid through billing's payment flow. The counter {@link pay} path
|
||||
|
||||
1131
apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts
Normal file
1131
apps/edr-freight-api/src/seed/paid-indode-demo-bookings.seeder.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -72,6 +72,13 @@ function FeeCard({ fee }: { fee: FeePreview }) {
|
||||
<Row label="Chargeable days" value={`${fee.chargeableDays} (after ${fee.freeDays} free)`} />
|
||||
<Row label="Containers" value={String(fee.containerCount ?? 1)} />
|
||||
<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>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -142,6 +142,7 @@ interface TruckEntranceFormState {
|
||||
packagingType: string;
|
||||
unitCount: number | '';
|
||||
grossWeightKg: number | '';
|
||||
weighingRequired: boolean | null;
|
||||
netWeightKg: number | '';
|
||||
volumeDimensions: string;
|
||||
conditionAtReceipt: string;
|
||||
@@ -163,11 +164,17 @@ interface LockedTruckEntranceFields {
|
||||
tin?: boolean;
|
||||
edrDigitalBookingId?: boolean;
|
||||
customerPhone?: boolean;
|
||||
truckPlateNumber?: boolean;
|
||||
trailerPlateNumber?: boolean;
|
||||
assignedEquipmentNumber?: boolean;
|
||||
itemDescription?: boolean;
|
||||
packagingType?: boolean;
|
||||
unitCount?: boolean;
|
||||
grossWeightKg?: boolean;
|
||||
driverName?: boolean;
|
||||
driverPhone?: boolean;
|
||||
driverLicenseNumber?: boolean;
|
||||
truckType?: boolean;
|
||||
}
|
||||
|
||||
type PackagingFreightType = 'CONTAINER' | 'BULK' | 'MIXED';
|
||||
@@ -190,6 +197,7 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
packagingType: '',
|
||||
unitCount: '',
|
||||
grossWeightKg: '',
|
||||
weighingRequired: null,
|
||||
netWeightKg: '',
|
||||
volumeDimensions: '',
|
||||
conditionAtReceipt: '',
|
||||
@@ -206,13 +214,24 @@ const emptyTruckEntrance = (): TruckEntranceFormState => ({
|
||||
});
|
||||
|
||||
const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayload => ({
|
||||
ownerName: form.ownerName.trim() || undefined,
|
||||
consigneeDetails: form.consigneeDetails.trim() || undefined,
|
||||
edrDigitalBookingId: form.edrDigitalBookingId.trim() || undefined,
|
||||
tin: form.tin.trim() || undefined,
|
||||
customerPhone: form.customerPhone.trim() || undefined,
|
||||
truckPlateNumber: form.truckPlateNumber.trim(),
|
||||
trailerPlateNumber: form.trailerPlateNumber.trim() || undefined,
|
||||
assignedEquipmentNumber: form.assignedEquipmentNumber.trim() || undefined,
|
||||
customsSealNumber: form.customsSealNumber.trim() || undefined,
|
||||
declarationNumber: form.declarationNumber.trim() || undefined,
|
||||
incoterms: form.incoterms.trim() || undefined,
|
||||
hsCodes: form.hsCodes.trim() || undefined,
|
||||
itemCode: form.itemCode.trim() || undefined,
|
||||
itemDescription: form.itemDescription.trim() || undefined,
|
||||
packagingType: form.packagingType.trim() || undefined,
|
||||
unitCount: form.unitCount === '' ? undefined : Number(form.unitCount),
|
||||
weighingRequired: form.weighingRequired ?? undefined,
|
||||
grossWeightKg: form.weighingRequired && form.grossWeightKg !== '' ? Number(form.grossWeightKg) : undefined,
|
||||
netWeightKg: form.netWeightKg === '' ? undefined : Number(form.netWeightKg),
|
||||
volumeDimensions: form.volumeDimensions.trim() || undefined,
|
||||
conditionAtReceipt: form.conditionAtReceipt.trim() || undefined,
|
||||
@@ -222,8 +241,11 @@ const toTruckEntrancePayload = (form: TruckEntranceFormState): TruckEntrancePayl
|
||||
driverPhone: form.driverPhone.trim(),
|
||||
driverLicenseNumber: form.driverLicenseNumber.trim() || undefined,
|
||||
truckType: form.truckType.trim() || undefined,
|
||||
entranceTareWeightKg: Number(form.entranceTareWeightKg),
|
||||
exitTareWeightKg: form.exitTareWeightKg === '' ? undefined : Number(form.exitTareWeightKg),
|
||||
entranceTareWeightKg:
|
||||
form.entranceTareWeightKg === ''
|
||||
? undefined
|
||||
: Number(form.entranceTareWeightKg),
|
||||
exitTareWeightKg: form.weighingRequired && form.exitTareWeightKg !== '' ? Number(form.exitTareWeightKg) : undefined,
|
||||
driverSignatoryName: form.driverSignatoryName.trim() || undefined,
|
||||
warehouseManagerName: form.warehouseManagerName.trim() || undefined,
|
||||
});
|
||||
@@ -242,22 +264,31 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
const tin = commonNonEmptyValue(bookings.map((booking) => booking.customerTin));
|
||||
const customerPhone = commonNonEmptyValue(bookings.map((booking) => booking.customerPhone));
|
||||
const consigneeDetails = commonNonEmptyValue(bookings.map((booking) => booking.customer));
|
||||
const assignedEquipmentNumber = commonNonEmptyValue(bookings.map((booking) => booking.containerNumber));
|
||||
const assignedEquipmentNumber = commonNonEmptyValue(
|
||||
bookings.map((booking) => booking.customerTruckContainerNumber || booking.containerNumber),
|
||||
);
|
||||
const itemDescription = commonNonEmptyValue(bookings.map((booking) => booking.cargoDescription ?? booking.cargo));
|
||||
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 =
|
||||
bookings.length === 1
|
||||
? bookings[0]?.reference ?? bookings[0]?.id ?? ''
|
||||
: commonNonEmptyValue(bookings.map((booking) => booking.reference));
|
||||
const firstMileBooking = bookings.length === 1 ? bookings[0] : null;
|
||||
const unitCount =
|
||||
bookings.length === 1 && bookings[0]?.containerQuantity != null
|
||||
? Number(bookings[0].containerQuantity)
|
||||
: '';
|
||||
const grossWeightKg =
|
||||
bookings.length === 1 && bookings[0]?.weight != null
|
||||
? Number(bookings[0].weight)
|
||||
: '';
|
||||
const freightTypes = [...new Set(bookings.map((booking) => booking.freightType).filter(Boolean))];
|
||||
const packagingFreightType =
|
||||
freightTypes.length === 1 && freightTypes[0] === 'CONTAINER'
|
||||
@@ -278,13 +309,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
itemDescription,
|
||||
packagingType,
|
||||
unitCount,
|
||||
grossWeightKg,
|
||||
truckPlateNumber: firstMileBooking?.firstMileTruckPlateNumber ?? '',
|
||||
trailerPlateNumber: firstMileBooking?.firstMileTrailerPlateNumber ?? '',
|
||||
driverName: firstMileBooking?.firstMileDriverName ?? '',
|
||||
driverPhone: firstMileBooking?.firstMileDriverPhone ?? '',
|
||||
driverLicenseNumber: firstMileBooking?.firstMileDriverLicenseNumber ?? '',
|
||||
truckType: firstMileBooking?.firstMileTruckType ?? '',
|
||||
grossWeightKg: '',
|
||||
truckPlateNumber,
|
||||
trailerPlateNumber,
|
||||
driverName,
|
||||
driverPhone,
|
||||
driverLicenseNumber,
|
||||
truckType,
|
||||
},
|
||||
lockedFields: {
|
||||
ownerName: Boolean(ownerName),
|
||||
@@ -296,7 +327,13 @@ const truckEntranceFromBookings = (bookings: EligibleBooking[]): {
|
||||
itemDescription: Boolean(itemDescription),
|
||||
packagingType: Boolean(packagingType),
|
||||
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,
|
||||
};
|
||||
@@ -338,11 +375,13 @@ function TruckEntranceFields({
|
||||
onChange,
|
||||
lockedFields,
|
||||
packagingFreightType = 'MIXED',
|
||||
allowTruckWeighing = true,
|
||||
}: {
|
||||
value: TruckEntranceFormState;
|
||||
onChange: (next: TruckEntranceFormState) => void;
|
||||
lockedFields?: LockedTruckEntranceFields;
|
||||
packagingFreightType?: PackagingFreightType;
|
||||
allowTruckWeighing?: boolean;
|
||||
}) {
|
||||
const packagingOptions = packagingOptionsFor(packagingFreightType);
|
||||
const quantityLabel =
|
||||
@@ -396,11 +435,13 @@ function TruckEntranceFields({
|
||||
label="Truck plate number"
|
||||
required
|
||||
value={value.truckPlateNumber}
|
||||
readOnly={lockedFields?.truckPlateNumber}
|
||||
onChange={(e) => onChange({ ...value, truckPlateNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
value={value.trailerPlateNumber}
|
||||
readOnly={lockedFields?.trailerPlateNumber}
|
||||
onChange={(e) => onChange({ ...value, trailerPlateNumber: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -422,12 +463,14 @@ function TruckEntranceFields({
|
||||
label="Driver name"
|
||||
required
|
||||
value={value.driverName}
|
||||
readOnly={lockedFields?.driverName}
|
||||
onChange={(e) => onChange({ ...value, driverName: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Driver phone"
|
||||
required
|
||||
value={value.driverPhone}
|
||||
readOnly={lockedFields?.driverPhone}
|
||||
onChange={(e) => onChange({ ...value, driverPhone: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
@@ -435,29 +478,61 @@ function TruckEntranceFields({
|
||||
<TextInput
|
||||
label="Driver license number"
|
||||
value={value.driverLicenseNumber}
|
||||
readOnly={lockedFields?.driverLicenseNumber}
|
||||
onChange={(e) => onChange({ ...value, driverLicenseNumber: e.currentTarget.value })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Truck type"
|
||||
value={value.truckType}
|
||||
readOnly={lockedFields?.truckType}
|
||||
onChange={(e) => onChange({ ...value, truckType: e.currentTarget.value })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Entrance tare weight (kg)"
|
||||
required
|
||||
min={0}
|
||||
value={value.entranceTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, entranceTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Exit tare weight (kg)"
|
||||
min={0}
|
||||
value={value.exitTareWeightKg}
|
||||
onChange={(v) => onChange({ ...value, exitTareWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
{allowTruckWeighing ? (
|
||||
<>
|
||||
<Select
|
||||
label="Weighing"
|
||||
required
|
||||
data={[
|
||||
{ value: 'YES', label: 'Yes' },
|
||||
{ value: 'NO', label: 'No' },
|
||||
]}
|
||||
value={value.weighingRequired == null ? null : value.weighingRequired ? 'YES' : 'NO'}
|
||||
onChange={(next) =>
|
||||
onChange({
|
||||
...value,
|
||||
weighingRequired: next === 'YES' ? true : next === 'NO' ? false : null,
|
||||
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>
|
||||
<Group grow>
|
||||
@@ -510,21 +585,12 @@ function TruckEntranceFields({
|
||||
onChange={(v) => onChange({ ...value, unitCount: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<NumberInput
|
||||
label="Gross weight (kg)"
|
||||
min={0}
|
||||
value={value.grossWeightKg}
|
||||
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>
|
||||
<NumberInput
|
||||
label="Net weight (kg)"
|
||||
min={0}
|
||||
value={value.netWeightKg}
|
||||
onChange={(v) => onChange({ ...value, netWeightKg: v === '' ? '' : Number(v) })}
|
||||
/>
|
||||
<TextInput
|
||||
label="Volume / dimensions"
|
||||
value={value.volumeDimensions}
|
||||
@@ -766,6 +832,8 @@ function EligibleTab({
|
||||
[pendingReceiveIds, rows],
|
||||
);
|
||||
const pendingHasFirstMile = pendingReceiveRows.some((row) => row.hasFirstMile);
|
||||
const pendingUsesFirstMile =
|
||||
pendingReceiveRows.length > 0 && pendingReceiveRows.every((row) => row.hasFirstMile);
|
||||
|
||||
const toggleAll = () =>
|
||||
setSelected(allSelected ? new Set() : new Set(selectableRows.map((r) => r.id)));
|
||||
@@ -822,32 +890,65 @@ function EligibleTab({
|
||||
void receiveBookings(filteredIds);
|
||||
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 totalContainerQuantity = selectedRows.reduce(
|
||||
(sum, row) => sum + Number(row.containerQuantity ?? 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 =
|
||||
nextPackagingFreightType === 'CONTAINER' && totalContainerQuantity > 0
|
||||
? {
|
||||
...form,
|
||||
unitCount: totalContainerQuantity,
|
||||
}
|
||||
: form;
|
||||
: {
|
||||
...form,
|
||||
};
|
||||
setPendingReceiveIds(filteredIds);
|
||||
setReceivedAt(new Date().toISOString());
|
||||
setTruckForm(normalizedForm);
|
||||
setLockedTruckFields({
|
||||
...lockedFields,
|
||||
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);
|
||||
setTruckOpen(true);
|
||||
};
|
||||
|
||||
const receive = async () => {
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim() || truckForm.entranceTareWeightKg === '') {
|
||||
toast({ variant: 'destructive', title: 'Truck, driver and entrance tare weight are required' });
|
||||
if (!truckForm.truckPlateNumber.trim() || !truckForm.driverName.trim() || !truckForm.driverPhone.trim()) {
|
||||
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;
|
||||
}
|
||||
await receiveBookings(pendingReceiveIds, toTruckEntrancePayload(truckForm));
|
||||
@@ -1053,8 +1154,8 @@ function EligibleTab({
|
||||
<Stack gap="md">
|
||||
<Alert icon={<Truck size={16} />} color={pendingHasFirstMile ? 'green' : 'blue'} variant="light">
|
||||
<Text size="sm">
|
||||
{pendingHasFirstMile
|
||||
? 'Assigned first-mile truck and driver details are prefilled. Confirm or update the actual arrival details before GRN.'
|
||||
{pendingUsesFirstMile
|
||||
? '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.'}
|
||||
</Text>
|
||||
</Alert>
|
||||
@@ -1109,6 +1210,7 @@ function EligibleTab({
|
||||
onChange={setTruckForm}
|
||||
lockedFields={lockedTruckFields}
|
||||
packagingFreightType={packagingFreightType}
|
||||
allowTruckWeighing={!pendingUsesFirstMile}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={() => setTruckOpen(false)} disabled={bulkReceive.isPending}>
|
||||
@@ -1934,6 +2036,11 @@ function ImportUnloadedQueueTab({ enabled }: { enabled: boolean }) {
|
||||
reference: row.bookingReference ?? row.bookingId,
|
||||
tradeDirection: 'IMPORT',
|
||||
lastMileDeliveryAddress: row.lastMileRequested ? row.pickupOption : null,
|
||||
customerTruckPlateNumber: row.customerTruckPlateNumber,
|
||||
customerTruckDriverName: row.customerTruckDriverName,
|
||||
customerTruckType: row.customerTruckType,
|
||||
customerTruckContainerNumber: row.customerTruckContainerNumber,
|
||||
customerTruckAssignedAt: row.customerTruckAssignedAt,
|
||||
}
|
||||
: null,
|
||||
}) as unknown as WarehouseInventoryItem;
|
||||
|
||||
@@ -82,6 +82,9 @@ const splitContainerNumbers = (value: string | null | undefined) =>
|
||||
const getItemContainerNumber = (item: WarehouseInventoryItem | null) =>
|
||||
(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 freightType = (item as (WarehouseInventoryItem & { booking?: { freightType?: string | null } | null }) | null)
|
||||
?.booking?.freightType;
|
||||
@@ -138,14 +141,18 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
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));
|
||||
setTruckPlateNumber(inspection.truckPlateNumber);
|
||||
setTruckPlateNumber(inspection.truckPlateNumber || assignedTruckPlate);
|
||||
setTrailerPlateNumber(inspection.trailerPlateNumber);
|
||||
setDriverName(inspection.driverName);
|
||||
setDriverName(inspection.driverName || assignedDriverName);
|
||||
setDriverLicense(inspection.driverLicense);
|
||||
setDriverPhone(inspection.driverPhone);
|
||||
setTruckType(inspection.truckType);
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber));
|
||||
setTruckType(inspection.truckType || assignedTruckType);
|
||||
setContainerNumbers(initialContainerNumbers(item, inspection.containerNumber || assignedContainerNumber));
|
||||
setGateInTime(inspection.gateInTime);
|
||||
setTareWeight(inspection.tareWeight);
|
||||
setGrossWeight(inspection.grossWeight);
|
||||
@@ -157,6 +164,8 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
const savedInspection = parseInspectionNote(item?.notes);
|
||||
const isExitStep = savedInspection.tareWeight !== '';
|
||||
const isEntranceLocked = isExitStep;
|
||||
const isCustomerAssignedTruck = Boolean(item?.booking?.customerTruckAssignedAt);
|
||||
const isTruckIdentityLocked = isEntranceLocked || isCustomerAssignedTruck;
|
||||
const systemNetWeight = item?.weight == null ? netWeight : Number(item.weight);
|
||||
const computedNetWeight =
|
||||
tareWeight !== '' && grossWeight !== '' ? Number((Number(grossWeight) - Number(tareWeight)).toFixed(3)) : null;
|
||||
@@ -269,7 +278,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
searchable
|
||||
clearable
|
||||
data={REGISTERED_FIRST_LAST_MILE_TRUCKS}
|
||||
disabled={isEntranceLocked}
|
||||
disabled={isTruckIdentityLocked}
|
||||
value={REGISTERED_FIRST_LAST_MILE_TRUCKS.some((truck) => truck.value === truckPlateNumber) ? truckPlateNumber : null}
|
||||
onChange={(value) => {
|
||||
const truck = REGISTERED_FIRST_LAST_MILE_TRUCKS.find((row) => row.value === value);
|
||||
@@ -283,7 +292,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
required
|
||||
value={truckPlateNumber}
|
||||
onChange={(e) => setTruckPlateNumber(e.currentTarget.value)}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
<TextInput
|
||||
label="Trailer plate number"
|
||||
@@ -293,12 +302,12 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
/>
|
||||
</Group>
|
||||
<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} />
|
||||
</Group>
|
||||
<Group grow>
|
||||
<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 grow>
|
||||
<Stack gap={6}>
|
||||
@@ -313,7 +322,7 @@ export function ReleaseOrderModal({ opened, onClose, item }: ReleaseOrderModalPr
|
||||
numbers.map((number, numberIndex) => (numberIndex === index ? e.currentTarget.value : number)),
|
||||
)
|
||||
}
|
||||
readOnly={isEntranceLocked}
|
||||
readOnly={isTruckIdentityLocked}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
@@ -441,6 +441,7 @@ export const URL_CONSTANTS = {
|
||||
RECEIPT: (id: string) => `/warehouse-fee-invoices/${id}/receipt`,
|
||||
CANCEL: (id: string) => `/warehouse-fee-invoices/${id}/cancel`,
|
||||
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`,
|
||||
FOR_INVENTORY: (inventoryId: string) => `/warehouse-inventory/${inventoryId}/fee-invoices`,
|
||||
FOR_BOOKING: (bookingId: string) => `/bookings/${bookingId}/warehouse-fee-invoices`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
//export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
RingProgress,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
@@ -88,6 +90,10 @@ export default function TrainScheduleV2DetailPage() {
|
||||
const [previewResult, setPreviewResult] = useState<TrainSchedulePreviewResponse | null>(null);
|
||||
const [containerPlacements, setContainerPlacements] = useState<ContainerPlacement[]>([]);
|
||||
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 detailQuery = useQuery(
|
||||
@@ -98,6 +104,42 @@ export default function TrainScheduleV2DetailPage() {
|
||||
);
|
||||
const schedule = detailQuery.data;
|
||||
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(
|
||||
() =>
|
||||
@@ -133,6 +175,16 @@ export default function TrainScheduleV2DetailPage() {
|
||||
: 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(
|
||||
() => (schedule?.bookings ?? []).map((b) => b.id),
|
||||
[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">
|
||||
<Stack gap="lg">
|
||||
{/* Workflow header with ring progress */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
@@ -28,6 +28,7 @@ import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
WAREHOUSE_INVOICE_STATUSES,
|
||||
type WarehouseFeeInvoice,
|
||||
type WarehouseGatewayPaymentMethod,
|
||||
type WarehouseInvoiceStatus,
|
||||
} from '@/types/warehouse';
|
||||
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 payOnline = useMutation(api.warehouses.payInvoiceOnline.mutationOptions());
|
||||
const cancel = useMutation(api.warehouses.cancelInvoice.mutationOptions());
|
||||
const gateClear = useMutation(api.warehouses.gateClearance.mutationOptions());
|
||||
const [payAmount, setPayAmount] = useState<number | ''>('');
|
||||
const [driverName, setDriverName] = useState('');
|
||||
const [driverPhone, setDriverPhone] = useState('');
|
||||
const [gatewayMethod, setGatewayMethod] = useState<WarehouseGatewayPaymentMethod>('TELEBIRR');
|
||||
const [payerAccount, setPayerAccount] = useState('');
|
||||
|
||||
const canPay = inv && (inv.status === 'ISSUED' || inv.status === 'PARTIALLY_PAID');
|
||||
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 blob = buildWarehouseInvoicePdf(invoice, 'INVOICE');
|
||||
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 () => {
|
||||
if (!inv) return;
|
||||
try {
|
||||
@@ -337,7 +374,31 @@ function InvoiceDetailModal({ id, onClose }: { id: string | null; onClose: () =>
|
||||
|
||||
{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">
|
||||
<NumberInput
|
||||
label="Amount"
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { Info, Plus, Trash2 } from 'lucide-react';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { PageContainer, PageHeader } from '@/components/page';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import {
|
||||
@@ -29,7 +31,9 @@ import {
|
||||
useDeleteFeeRule,
|
||||
useFeeRules,
|
||||
} from '@/hooks/useWarehouses';
|
||||
import { api } from '@/services/api';
|
||||
import { FEE_RULE_TYPES, type FeeRuleType } from '@/types/warehouse';
|
||||
import { extractErrorMessage } from '@/components/warehouses/options';
|
||||
|
||||
const FREIGHT = [
|
||||
{ value: 'CONTAINER', label: 'Container' },
|
||||
@@ -38,6 +42,7 @@ const FREIGHT = [
|
||||
const TRADE = [
|
||||
{ value: 'IMPORT', label: 'Import' },
|
||||
{ value: 'EXPORT', label: 'Export' },
|
||||
{ value: 'BOTH', label: 'Import & Export' },
|
||||
{ value: 'DOMESTIC', label: 'Domestic' },
|
||||
];
|
||||
const CURRENCIES = [
|
||||
@@ -53,6 +58,23 @@ const numberValue = (value: string | number, fallback = 0) => {
|
||||
};
|
||||
const anyLabel = (value: string, label: string) => value.trim() || `Any ${label}`;
|
||||
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() {
|
||||
return (
|
||||
@@ -319,6 +341,12 @@ function AllocationRules() {
|
||||
function FeeRules() {
|
||||
const { toast } = useToast();
|
||||
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 remove = useDeleteFeeRule();
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -328,11 +356,56 @@ function FeeRules() {
|
||||
freightType: '',
|
||||
tradeDirection: '',
|
||||
cargoTypeCode: '',
|
||||
containerType: '',
|
||||
freeDays: 3,
|
||||
ratePerDay: 0,
|
||||
tiers: [] as Array<{ fromDay: number; toDay: number | null; ratePerDay: number }>,
|
||||
currency: 'USD',
|
||||
});
|
||||
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 () => {
|
||||
if (!form.name.trim()) {
|
||||
@@ -340,18 +413,68 @@ function FeeRules() {
|
||||
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(),
|
||||
ruleType: form.ruleType,
|
||||
freightType: clean(form.freightType) ?? 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,
|
||||
ratePerDay: form.ratePerDay,
|
||||
currency: form.currency || 'USD',
|
||||
} as never);
|
||||
toast({ title: 'Fee rule created' });
|
||||
setOpen(false);
|
||||
...(tiers.length ? { tiers } : {}),
|
||||
};
|
||||
|
||||
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 (
|
||||
@@ -370,7 +493,7 @@ function FeeRules() {
|
||||
<Loader />
|
||||
</Group>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={900}>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table striped highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
@@ -378,6 +501,9 @@ function FeeRules() {
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Freight</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>Rate / day</Table.Th>
|
||||
<Table.Th>Active</Table.Th>
|
||||
@@ -395,6 +521,20 @@ function FeeRules() {
|
||||
<Table.Td>{rule.name}</Table.Td>
|
||||
<Table.Td>{rule.freightType ?? 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>
|
||||
{Number(rule.ratePerDay).toLocaleString()} {rule.currency}
|
||||
@@ -454,7 +594,14 @@ function FeeRules() {
|
||||
label="Freight type"
|
||||
data={FREIGHT}
|
||||
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
|
||||
/>
|
||||
<Select
|
||||
@@ -464,15 +611,31 @@ function FeeRules() {
|
||||
onChange={(value) => setForm((f) => ({ ...f, tradeDirection: selectValue(value) }))}
|
||||
clearable
|
||||
/>
|
||||
<TextInput
|
||||
label="Cargo type code"
|
||||
value={form.cargoTypeCode}
|
||||
onChange={(e) => {
|
||||
const value = e.currentTarget.value;
|
||||
setForm((f) => ({ ...f, cargoTypeCode: value }));
|
||||
}}
|
||||
/>
|
||||
{isBulkRule && (
|
||||
<Select
|
||||
label="Cargo type"
|
||||
placeholder={cargoTypesLoading ? 'Loading cargo types...' : 'Any cargo'}
|
||||
data={cargoTypeOptions}
|
||||
value={form.cargoTypeCode || null}
|
||||
onChange={(value) => setForm((f) => ({ ...f, cargoTypeCode: selectValue(value) }))}
|
||||
searchable
|
||||
clearable
|
||||
disabled={cargoTypesLoading}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
<NumberInput
|
||||
label="Free days"
|
||||
@@ -494,6 +657,51 @@ function FeeRules() {
|
||||
allowDeselect={false}
|
||||
/>
|
||||
</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">
|
||||
<Button variant="default" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
|
||||
@@ -84,6 +84,7 @@ import type {
|
||||
InventoryInquiryFilter,
|
||||
InventoryInquiryResult,
|
||||
InventoryMovement,
|
||||
InitiateWarehouseInvoicePaymentPayload,
|
||||
LoadableWagon,
|
||||
LoadInventoryPayload,
|
||||
LoadPassedExportResult,
|
||||
@@ -102,6 +103,7 @@ import type {
|
||||
WarehouseActivityLog,
|
||||
WarehouseDashboard,
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoicePaymentResponse,
|
||||
WarehouseFilter,
|
||||
WarehouseInventoryItem,
|
||||
WarehouseInvoiceFilter,
|
||||
@@ -1111,6 +1113,18 @@ export const api = {
|
||||
() => [["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>(
|
||||
"warehouse-fee-invoices",
|
||||
"gate-clearance",
|
||||
|
||||
@@ -18,6 +18,8 @@ import type {
|
||||
WarehouseFeeInvoice,
|
||||
WarehouseInvoiceFilter,
|
||||
PayInvoicePayload,
|
||||
InitiateWarehouseInvoicePaymentPayload,
|
||||
WarehouseInvoicePaymentResponse,
|
||||
BookingScheduleView,
|
||||
InventoryFilter,
|
||||
InventoryInquiryFilter,
|
||||
@@ -294,6 +296,8 @@ export const warehouseService = {
|
||||
apiClient.patch<WarehouseFeeInvoice>(URL_CONSTANTS.WAREHOUSE_INVOICES.CANCEL(id)),
|
||||
payInvoice: (id: string, payload: PayInvoicePayload) =>
|
||||
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) =>
|
||||
apiClient.post<WarehouseInventoryItem>(URL_CONSTANTS.WAREHOUSE_INVOICES.GATE_CLEARANCE(inventoryId), {}),
|
||||
};
|
||||
|
||||
@@ -400,6 +400,7 @@ export interface TrainScheduleDetail {
|
||||
}
|
||||
|
||||
export type ImportDjiboutiDocumentType =
|
||||
| "GATE_PASS"
|
||||
| "DELIVERY_ORDER"
|
||||
| "PORT_INVOICE"
|
||||
| "DJIBOUTI_T1"
|
||||
@@ -422,6 +423,7 @@ export interface ImportDjiboutiOperation {
|
||||
status: {
|
||||
documentsComplete: boolean;
|
||||
missingDocuments: ImportDjiboutiDocumentType[];
|
||||
gatepassStatus: "SECURED" | "NOT_SECURED";
|
||||
gatepassGranted: boolean;
|
||||
readyForLoading: boolean;
|
||||
loadedOnTrain: boolean;
|
||||
@@ -430,6 +432,8 @@ export interface ImportDjiboutiOperation {
|
||||
};
|
||||
documents: Partial<Record<ImportDjiboutiDocumentType, ImportDjiboutiDocumentRecord>>;
|
||||
gatepassGrantedAt: string | null;
|
||||
gatepassSecuredAt?: string | null;
|
||||
gatepassStatus: "SECURED" | "NOT_SECURED";
|
||||
readyForLoadingAt: string | null;
|
||||
loadedOnTrainAt: string | null;
|
||||
departedFromDjiboutiAt: string | null;
|
||||
@@ -448,6 +452,10 @@ export interface UploadImportDjiboutiDocumentPayload {
|
||||
}
|
||||
|
||||
export interface ImportDjiboutiActionPayload {
|
||||
securedAt?: string;
|
||||
fileId?: string;
|
||||
fileUrl?: string;
|
||||
reference?: string;
|
||||
notes?: string;
|
||||
performedBy?: string;
|
||||
}
|
||||
|
||||
@@ -223,6 +223,11 @@ export interface InventoryBookingRef {
|
||||
tradeDirection?: string | null;
|
||||
// Present when the customer requested last-mile (door) delivery — gates the Last Mile action.
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
customerTruckPlateNumber?: string | null;
|
||||
customerTruckDriverName?: string | null;
|
||||
customerTruckType?: string | null;
|
||||
customerTruckContainerNumber?: string | null;
|
||||
customerTruckAssignedAt?: string | null;
|
||||
}
|
||||
|
||||
export interface InventoryMovement {
|
||||
@@ -398,6 +403,11 @@ export interface EligibleBooking {
|
||||
firstMileDriverPhone: string | null;
|
||||
firstMileDriverLicenseNumber: string | null;
|
||||
firstMileTruckType: string | null;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
}
|
||||
|
||||
export interface BulkReceivePayload {
|
||||
@@ -433,6 +443,7 @@ export interface TruckEntrancePayload {
|
||||
packagingType?: string;
|
||||
unitCount?: number;
|
||||
grossWeightKg?: number;
|
||||
weighingRequired?: boolean;
|
||||
netWeightKg?: number;
|
||||
volumeDimensions?: string;
|
||||
conditionAtReceipt?: string;
|
||||
@@ -442,7 +453,7 @@ export interface TruckEntrancePayload {
|
||||
driverPhone: string;
|
||||
driverLicenseNumber?: string;
|
||||
truckType?: string;
|
||||
entranceTareWeightKg: number;
|
||||
entranceTareWeightKg?: number;
|
||||
exitTareWeightKg?: number;
|
||||
driverSignatoryName?: string;
|
||||
warehouseManagerName?: string;
|
||||
@@ -573,6 +584,11 @@ export interface ImportUnloadedItem {
|
||||
inspectionStatus: string | null;
|
||||
pickupOption: string;
|
||||
lastMileRequested: boolean;
|
||||
customerTruckPlateNumber: string | null;
|
||||
customerTruckDriverName: string | null;
|
||||
customerTruckType: string | null;
|
||||
customerTruckContainerNumber: string | null;
|
||||
customerTruckAssignedAt: string | null;
|
||||
currentStatus: string;
|
||||
releaseDate: string | null;
|
||||
releaseOrderReference: string | null;
|
||||
@@ -738,11 +754,25 @@ export interface FeeRule {
|
||||
zoneId?: string | null;
|
||||
freeDays: number;
|
||||
ratePerDay: number;
|
||||
tiers?: FeeRuleTier[];
|
||||
currency: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
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 {
|
||||
ruleType: FeeRuleType;
|
||||
ruleId: string | null;
|
||||
@@ -760,6 +790,7 @@ export interface FeePreview {
|
||||
containerCount: number;
|
||||
billableUnits: number;
|
||||
amount: number;
|
||||
tiers?: FeePreviewTier[];
|
||||
}
|
||||
|
||||
export interface AllocationPreviewResult {
|
||||
@@ -868,6 +899,27 @@ export interface PayInvoicePayload {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SaveWarehousePayload {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
//export const API_BASE_URL = 'http://localhost:3001';
|
||||
//export const API_BASE_URL = 'https://edrfreightapi.triaplc.com';
|
||||
export const API_BASE_URL = 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* URL that streams an uploaded file through the API by its UUID. Routes the
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Table,
|
||||
@@ -44,6 +46,7 @@ function MetaItem({ label, value }: { label: string; value: string }) {
|
||||
export default function InvoiceDetailPage() {
|
||||
const { id = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [paymentMethod, setPaymentMethod] = useState<"TELEBIRR" | "WAAFI">("TELEBIRR");
|
||||
|
||||
const { data: invoice, isLoading, isError } = useQuery(
|
||||
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) {
|
||||
return (
|
||||
<Center py={80}>
|
||||
@@ -88,11 +96,12 @@ export default function InvoiceDetailPage() {
|
||||
|
||||
const payable = isPayable(invoice.status);
|
||||
const lines = invoice.lines ?? [];
|
||||
const amountDue = Number(invoice.balanceAmount ?? invoice.totalAmount);
|
||||
|
||||
const handlePay = () => {
|
||||
const returnUrl = `${window.location.origin}/payment/success`;
|
||||
const failureUrl = `${window.location.origin}/payment/failure`;
|
||||
payMutation.mutate({ id, payload: { returnUrl, failureUrl } });
|
||||
payMutation.mutate({ id, payload: { method: paymentMethod, returnUrl, failureUrl } });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -118,17 +127,29 @@ export default function InvoiceDetailPage() {
|
||||
<InvoiceStatusBadge status={invoice.status} />
|
||||
</Group>
|
||||
{payable && (
|
||||
<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(Number(invoice.totalAmount), invoice.currency)}
|
||||
</Button>
|
||||
<Group align="flex-end" gap="sm">
|
||||
<Select
|
||||
label="Payment provider"
|
||||
value={paymentMethod}
|
||||
onChange={(value) => setPaymentMethod((value as "TELEBIRR" | "WAAFI") ?? "TELEBIRR")}
|
||||
data={[
|
||||
{ value: "TELEBIRR", label: "Telebirr" },
|
||||
{ value: "WAAFI", label: "Waafi" },
|
||||
]}
|
||||
w={170}
|
||||
/>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ActivityCard } from "./components/ActivityCard";
|
||||
import { ClearanceCard } from "./components/ClearanceCard";
|
||||
import { ContainersCard } from "./components/ContainersCard";
|
||||
import { ContractCard } from "./components/ContractCard";
|
||||
import { CustomerTruckAssignmentCard } from "./components/CustomerTruckAssignmentCard";
|
||||
import { DocRow, IconSquare } from "./components/Documents";
|
||||
import { KeyFactsStrip } from "./components/KeyFactsStrip";
|
||||
import { BodyGrid, CardTitle, PageShell, SectionCard } from "./components/layout";
|
||||
@@ -35,7 +36,13 @@ import { StatusHero } from "./components/StatusHero";
|
||||
import { SupportCard } from "./components/SupportCard";
|
||||
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 status = booking.status as string;
|
||||
const [payModalOpen, setPayModalOpen] = useState(false);
|
||||
@@ -73,7 +80,14 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
(isGeneralContract
|
||||
? status === "FULLY_EXECUTED"
|
||||
: 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 isExpired = status === "EXPIRED";
|
||||
const isPendingConsolidation = status === "PENDING_CONSOLIDATION";
|
||||
@@ -164,6 +178,13 @@ export function ReadonlyBookingView({ booking }: { booking: Freight.IBooking })
|
||||
|
||||
<ShipmentTrackingCard bookingId={booking.id} />
|
||||
|
||||
{canAssignCustomerTruck && (
|
||||
<CustomerTruckAssignmentCard
|
||||
booking={booking}
|
||||
onAssigned={onBookingUpdated ?? (() => {})}
|
||||
/>
|
||||
)}
|
||||
|
||||
{booking.files && booking.files.length > 0 && (
|
||||
<SectionCard>
|
||||
<Group justify="space-between" align="center" mb="md">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -94,5 +94,5 @@ export default function BookingDetailPage() {
|
||||
<DraftBookingView booking={booking} onBookingUpdated={refetchBooking} />
|
||||
);
|
||||
}
|
||||
return <ReadonlyBookingView booking={booking} />;
|
||||
return <ReadonlyBookingView booking={booking} onBookingUpdated={refetchBooking} />;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ApproveDeliveryResponse,
|
||||
BookingListFilter,
|
||||
CreateBookingPayload,
|
||||
type CustomerTruckAssignmentPayload,
|
||||
GeneratePriceResponse,
|
||||
SubmitBookingResponse,
|
||||
} from "./bookings.service";
|
||||
@@ -210,6 +211,19 @@ export const api = {
|
||||
({ 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<
|
||||
{ payload: CreateBookingPayload; documents?: BookingDocuments },
|
||||
Freight.IBooking
|
||||
|
||||
@@ -79,6 +79,13 @@ export interface ApproveDeliveryResponse {
|
||||
signerDisplayName: string;
|
||||
}
|
||||
|
||||
export interface CustomerTruckAssignmentPayload {
|
||||
truckPlateNumber: string;
|
||||
driverName: string;
|
||||
truckType: string;
|
||||
containerNumberToLoad: string;
|
||||
}
|
||||
|
||||
export interface BookingListFilter {
|
||||
status?: string;
|
||||
/** Comma-separated statuses (overrides `status` when set). */
|
||||
@@ -111,6 +118,23 @@ export const bookingsService = {
|
||||
const { data } = await client.get(`/api/bookings/${id}`);
|
||||
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> => {
|
||||
const { data } = await client.get(`/api/bookings/${id}/tracking`);
|
||||
return data.data;
|
||||
|
||||
@@ -89,6 +89,7 @@ export enum BookingStatus {
|
||||
Cancelled = "CANCELLED",
|
||||
PendingConsolidation = "PENDING_CONSOLIDATION",
|
||||
Consolidated = "CONSOLIDATED",
|
||||
TruckAssigned = "TRUCK_ASSIGNED",
|
||||
/** General contract: paid umbrella contract that is accepting drawdown orders. */
|
||||
ContractActive = "CONTRACT_ACTIVE",
|
||||
/** General contract: closed because its quantity was exhausted (or period elapsed). */
|
||||
@@ -422,6 +423,12 @@ export interface IBooking extends BaseEntity {
|
||||
lastMileDeliveryAddress?: string | null;
|
||||
lastMileDeliveryLat?: 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;
|
||||
customsClearingAgent?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user