Merge pull request #1132 from Tria-plc/dev

freight dev
This commit is contained in:
marshal
2026-08-06 00:07:26 +03:00
committed by GitHub
43 changed files with 1633 additions and 41 deletions

View File

@@ -103,6 +103,7 @@ import { ProcurementModule } from "./modules/procurement/procurement.module";
import { GpsTrackingModule } from "./modules/gps-tracking/gps-tracking.module";
import { FirstMileModule } from "./modules/first-mile/first-mile.module";
import { LastMileModule } from "./modules/last-mile/last-mile.module";
import { LastMileRequestsModule } from "./modules/last-mile-requests/last-mile-requests.module";
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
import { AiModule } from "./modules/ai/ai.module";
@@ -241,6 +242,7 @@ if (!process.env.APPLICATION_NAME) {
GpsTrackingModule,
FirstMileModule,
LastMileModule,
LastMileRequestsModule,
InterchangeDocumentsModule,
ImportOperationsModule,
VerifaydaModule,

View File

@@ -0,0 +1,17 @@
import { haversineKm } from './mile-distance.util';
describe('haversineKm', () => {
it('is zero for the same point', () => {
expect(haversineKm(8.9, 38.6, 8.9, 38.6)).toBe(0);
});
it('matches one degree of longitude at the equator (~111.19 km)', () => {
expect(haversineKm(0, 0, 0, 1)).toBeCloseTo(111.19, 1);
});
it('Sebeta yard → Indode yard is roughly 26 km', () => {
const km = haversineKm(8.9096, 38.636, 8.7386, 38.7913);
expect(km).toBeGreaterThan(20);
expect(km).toBeLessThan(35);
});
});

View File

@@ -0,0 +1,48 @@
import { DataSource } from 'typeorm';
/** Great-circle distance in km between two WGS84 points (haversine). */
export function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const toRad = (d: number) => (d * Math.PI) / 180;
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a =
Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2;
return 2 * 6371 * Math.asin(Math.sqrt(a));
}
/**
* Estimated road-leg distance for a booking's first/last mile: straight-line km
* from the yard (freight.yard_locations) to the customer's pickup/delivery GPS
* point on the booking. FIRST = origin yard → pickup point, LAST = destination
* yard → delivery point. Null when either end has no coordinates.
*
* ponytail: haversine straight-line, not road routing — plug a routing API in
* here if real road km is ever required.
*/
export async function estimateMileKm(
dataSource: DataSource,
bookingId: string,
mile: 'FIRST' | 'LAST',
): Promise<number | null> {
const [row] = await dataSource.query(
mile === 'LAST'
? `SELECT b.last_mile_delivery_lat AS lat, b.last_mile_delivery_lng AS lng,
l.latitude AS yard_lat, l.longitude AS yard_lng
FROM freight.bookings b
LEFT JOIN freight.yard_locations l
ON l.yard_id = b.destination_yard_id AND l.deleted_at IS NULL
WHERE b.id = $1 AND b.deleted_at IS NULL`
: `SELECT b.first_mile_pickup_lat AS lat, b.first_mile_pickup_lng AS lng,
l.latitude AS yard_lat, l.longitude AS yard_lng
FROM freight.bookings b
LEFT JOIN freight.yard_locations l
ON l.yard_id = b.origin_yard_id AND l.deleted_at IS NULL
WHERE b.id = $1 AND b.deleted_at IS NULL`,
[bookingId],
);
if (!row || row.lat == null || row.lng == null || row.yard_lat == null || row.yard_lng == null) {
return null;
}
const km = haversineKm(Number(row.yard_lat), Number(row.yard_lng), Number(row.lat), Number(row.lng));
return Math.round(km * 100) / 100;
}

View File

@@ -0,0 +1,87 @@
import { MigrationInterface, QueryRunner, Table, TableForeignKey } from 'typeorm';
/**
* Create freight.last_mile_requests — the pre-approval confirmation stage that
* sits in front of freight.last_mile: a train departs Djibouti, the customer
* confirms which containers go via EDR last-mile, and the Truck & Machinery
* chief approves/rejects before a freight.last_mile execution record exists.
*/
export class CreateLastMileRequests3250000000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile_requests');
if (exists) return;
await queryRunner.createTable(
new Table({
name: 'freight.last_mile_requests',
columns: [
{ name: 'id', type: 'uuid', isPrimary: true, default: 'gen_random_uuid()' },
{ name: 'booking_id', type: 'uuid', isNullable: false },
{ name: 'train_schedule_id', type: 'uuid', isNullable: false },
{
name: 'status',
type: 'varchar',
length: '30',
default: `'AWAITING_CONFIRMATION'`,
isNullable: false,
},
{ name: 'requested_container_numbers', type: 'text', isArray: true, isNullable: true },
{ name: 'reminder_sent_at', type: 'timestamptz', isNullable: true },
{ name: 'submitted_by_user_id', type: 'uuid', isNullable: true },
{ name: 'submitted_at', type: 'timestamptz', isNullable: true },
{ name: 'reviewed_by_staff_id', type: 'uuid', isNullable: true },
{ name: 'reviewed_at', type: 'timestamptz', isNullable: true },
{ name: 'rejection_reason', type: 'text', isNullable: true },
{ name: 'resulting_last_mile_id', type: 'uuid', isNullable: true },
{ name: 'created_at', type: 'timestamptz', default: 'now()' },
{ name: 'updated_at', type: 'timestamptz', default: 'now()' },
{ name: 'deleted_at', type: 'timestamptz', isNullable: true },
],
}),
true,
);
await queryRunner.createForeignKey(
'freight.last_mile_requests',
new TableForeignKey({
columnNames: ['booking_id'],
referencedTableName: 'freight.bookings',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.last_mile_requests',
new TableForeignKey({
columnNames: ['train_schedule_id'],
referencedTableName: 'freight.train_schedules',
referencedColumnNames: ['id'],
onDelete: 'CASCADE',
}),
);
await queryRunner.createForeignKey(
'freight.last_mile_requests',
new TableForeignKey({
columnNames: ['resulting_last_mile_id'],
referencedTableName: 'freight.last_mile',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
}),
);
// One request per booking per departure — remind()/submit() are idempotent on this.
await queryRunner.query(
`CREATE UNIQUE INDEX IF NOT EXISTS "IDX_last_mile_requests_booking_schedule" ON "freight"."last_mile_requests" ("booking_id", "train_schedule_id") WHERE "deleted_at" IS NULL`,
);
await queryRunner.query(
`CREATE INDEX IF NOT EXISTS "IDX_last_mile_requests_status" ON "freight"."last_mile_requests" ("status")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
const exists = await queryRunner.hasTable('freight.last_mile_requests');
if (exists) {
await queryRunner.dropTable('freight.last_mile_requests');
}
}
}

View File

@@ -0,0 +1,53 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* Creates freight.yard_locations (GPS per yard, decimal degrees WGS84) and
* seeds the five facility yards: Sebeta, GMP/Indode (KALITY), Mojo, Adama,
* Dire Dawa. Also normalizes has_facility — only those five load/unload cargo.
*
* Coordinates are approximate — adjust rows directly if surveyed values arrive.
*/
export class YardGpsLocation3270000000000 implements MigrationInterface {
name = 'YardGpsLocation3270000000000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE IF NOT EXISTS freight.yard_locations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
yard_id uuid NOT NULL UNIQUE REFERENCES freight.yards(id) ON DELETE CASCADE,
latitude double precision NOT NULL,
longitude double precision NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz
)
`);
// LEGACY_DEST is the historical code for Sebeta in some environments.
await queryRunner.query(`
INSERT INTO freight.yard_locations (yard_id, latitude, longitude)
SELECT y.id, v.lat, v.lng
FROM (VALUES
('SEBETA', 8.9096, 38.6360),
('LEGACY_DEST', 8.9096, 38.6360),
('KALITY', 8.7386, 38.7913),
('MOJO', 8.5794, 39.1200),
('ADAMA', 8.5622, 39.2440),
('DIRE_DAWA', 9.6009, 41.8103)
) AS v(code, lat, lng)
JOIN freight.yards y ON y.code = v.code AND y.deleted_at IS NULL
ON CONFLICT (yard_id) DO NOTHING
`);
await queryRunner.query(`
UPDATE freight.yards
SET has_facility = (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA'))
WHERE deleted_at IS NULL
AND has_facility <> (code IN ('SEBETA', 'LEGACY_DEST', 'KALITY', 'MOJO', 'ADAMA', 'DIRE_DAWA'))
`);
}
public async down(): Promise<void> {
// Additive table + data normalization; no rollback.
}
}

View File

@@ -1059,16 +1059,24 @@ export class BillingService {
* settlement; settleByPaymentId still performs the real transition.
*/
async markInvoicePaymentProcessing(paymentId: string): Promise<void> {
await this.dataSource.getRepository(Invoice).update(
{
paymentId,
status: In([
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
]),
},
{ status: Freight.InvoiceStatus.PaymentProcessing },
);
const repo = this.dataSource.getRepository(Invoice);
const invoices = await repo.findBy({
paymentId,
status: In([
Freight.InvoiceStatus.Issued,
Freight.InvoiceStatus.Pending,
]),
});
for (const invoice of invoices) {
await repo.update(
{ id: invoice.id, status: invoice.status },
{ status: Freight.InvoiceStatus.PaymentProcessing },
);
this.emitInvoiceEvent("payment-processing", {
...invoice,
status: Freight.InvoiceStatus.PaymentProcessing,
} as Invoice);
}
}
/**
@@ -1077,12 +1085,21 @@ export class BillingService {
* retry. No-op from any other status.
*/
async revertInvoicePaymentProcessing(paymentId: string): Promise<void> {
await this.dataSource
.getRepository(Invoice)
.update(
{ paymentId, status: Freight.InvoiceStatus.PaymentProcessing },
const repo = this.dataSource.getRepository(Invoice);
const invoices = await repo.findBy({
paymentId,
status: Freight.InvoiceStatus.PaymentProcessing,
});
for (const invoice of invoices) {
await repo.update(
{ id: invoice.id, status: Freight.InvoiceStatus.PaymentProcessing },
{ status: Freight.InvoiceStatus.Pending },
);
this.emitInvoiceEvent("payment-processing-reverted", {
...invoice,
status: Freight.InvoiceStatus.Pending,
} as Invoice);
}
}
// ── Payment initiation & settlement (the gateway boundary) ───────────────────

View File

@@ -115,6 +115,34 @@ export class BookingInvoiceService {
}
}
/**
* Success-redirect ack: the customer finished provider checkout, webhook not
* in yet. Mirror the invoice's PAYMENT_PROCESSING on the booking so the
* portal stops offering "Pay now". Display state only — settlement
* (`booking.invoice.paid`) still drives PAID. Status-guarded, so it never
* touches a booking that already advanced or was terminated.
*/
@OnEvent("booking.invoice.payment-processing")
async onBookingInvoicePaymentProcessing(
payload: InvoiceEventPayload,
): Promise<void> {
await this.dataSource.getRepository(Booking).update(
{ id: payload.sourceId, status: "SELECTED_FOR_BATCH" },
{ status: "PAYMENT_VERIFICATION_IN_PROGRESS" },
);
}
/** Payment failed after a redirect ack — the booking reads payable again. */
@OnEvent("booking.invoice.payment-processing-reverted")
async onBookingInvoicePaymentProcessingReverted(
payload: InvoiceEventPayload,
): Promise<void> {
await this.dataSource.getRepository(Booking).update(
{ id: payload.sourceId, status: "PAYMENT_VERIFICATION_IN_PROGRESS" },
{ status: "SELECTED_FOR_BATCH" },
);
}
updateStatus(
invoiceId: string,
status: Freight.InvoiceStatus,

View File

@@ -1405,7 +1405,9 @@ export class BookingsRepository extends BaseRepository<Booking> {
.leftJoinAndSelect('bookingContainer.containerType', 'containerType')
.leftJoinAndSelect('booking.cargoType', 'cargoType')
.where('booking.train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere(
`booking.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
)
.getMany();
}

View File

@@ -3,6 +3,7 @@ import { FindOptionsWhere, In, IsNull, Not } from 'typeorm';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { attachMileFinancials } from '../../common/mile-financials.util';
import { estimateMileKm } from '../../common/mile-distance.util';
import { VehicleAvailability } from '../vehicles/entities/vehicle.entity';
import { BookingsRepository } from "../bookings/bookings.repository";
import { DriversService } from "../drivers/drivers.service";
@@ -285,7 +286,7 @@ export class FirstMileService {
status: dto.status ?? "READY_TO_TRANSIT",
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'FIRST')),
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsNumber, Min } from 'class-validator';
export class ApproveLastMileRequestDto {
// ponytail: flat manual advance amount — no rate model exists yet at this
// pre-distance stage (delivery-fee invoicing needs assigned-truck distance,
// which isn't known until after payment). Wire a FeeRule-based estimate
// (see double-handling/truck-detention fee rules) once one exists.
@ApiProperty({ description: 'Advance amount the customer must pay before execution proceeds', example: 3000 })
@Transform(({ value }) => Number(value))
@IsNumber()
@Min(0.01)
advanceAmount!: number;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsString, MaxLength } from 'class-validator';
export class RejectLastMileRequestDto {
@ApiProperty({ description: 'Why the request is rejected (e.g. no truck available)' })
@IsString()
@IsNotEmpty()
@MaxLength(500)
reason!: string;
}

View File

@@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayNotEmpty, ArrayUnique, IsArray, IsString } from 'class-validator';
export class SubmitLastMileRequestDto {
@ApiProperty({
type: [String],
description:
'Container numbers the customer wants delivered via EDR last-mile — pass every booking container to select "all".',
})
@IsArray()
@ArrayNotEmpty()
@ArrayUnique()
@IsString({ each: true })
containerNumbers!: string[];
}

View File

@@ -0,0 +1,71 @@
import { BaseEntity } from '@edr/api-common';
import { LastMileRequestStatus } from '@edr/types';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { TrainSchedule } from '../../train-schedules/entities/train-schedule.entity';
import { LastMile } from '../../last-mile/entities/last-mile.entity';
export const LAST_MILE_REQUEST_STATUSES = [
LastMileRequestStatus.AwaitingConfirmation,
LastMileRequestStatus.Submitted,
LastMileRequestStatus.Approved,
LastMileRequestStatus.Rejected,
] as const;
/**
* The pre-approval confirmation stage in front of `LastMile`: fired when a
* train departs Djibouti, filled by the customer, reviewed by the Truck &
* Machinery chief. One row per (bookingId, trainScheduleId) — a booking whose
* containers arrive across several departures gets a request per departure.
*/
@Entity({ name: 'last_mile_requests', schema: 'freight' })
@Index(['bookingId'])
@Index(['status'])
export class LastMileRequest extends BaseEntity {
@Column({ name: 'booking_id', type: 'uuid' })
bookingId!: string;
@ManyToOne(() => Booking, { nullable: false, eager: false })
@JoinColumn({ name: 'booking_id' })
booking?: Booking;
@Column({ name: 'train_schedule_id', type: 'uuid' })
trainScheduleId!: string;
@ManyToOne(() => TrainSchedule, { nullable: false, eager: false })
@JoinColumn({ name: 'train_schedule_id' })
trainSchedule?: TrainSchedule;
@Column({ name: 'status', type: 'varchar', length: 30, default: LastMileRequestStatus.AwaitingConfirmation })
status!: LastMileRequestStatus;
/** Customer's container selection — "all" is just every booking container listed here. */
@Column({ name: 'requested_container_numbers', type: 'text', array: true, nullable: true })
requestedContainerNumbers?: string[] | null;
@Column({ name: 'reminder_sent_at', type: 'timestamptz', nullable: true })
reminderSentAt?: Date | null;
@Column({ name: 'submitted_by_user_id', type: 'uuid', nullable: true })
submittedByUserId?: string | null;
@Column({ name: 'submitted_at', type: 'timestamptz', nullable: true })
submittedAt?: Date | null;
@Column({ name: 'reviewed_by_staff_id', type: 'uuid', nullable: true })
reviewedByStaffId?: string | null;
@Column({ name: 'reviewed_at', type: 'timestamptz', nullable: true })
reviewedAt?: Date | null;
@Column({ name: 'rejection_reason', type: 'text', nullable: true })
rejectionReason?: string | null;
@Column({ name: 'resulting_last_mile_id', type: 'uuid', nullable: true })
resultingLastMileId?: string | null;
@ManyToOne(() => LastMile, { nullable: true, eager: false })
@JoinColumn({ name: 'resulting_last_mile_id' })
resultingLastMile?: LastMile | null;
}

View File

@@ -0,0 +1,86 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '@edr/api-common';
import type { TCurrentUser } from '@tria-plc/api-common/modules/auth/types/current-user.type';
import { LastMileRequestStatus } from '@edr/types';
import { BookingStaff } from '../../common/booking-guards';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { ApproveLastMileRequestDto } from './dto/approve-last-mile-request.dto';
import { RejectLastMileRequestDto } from './dto/reject-last-mile-request.dto';
import { SubmitLastMileRequestDto } from './dto/submit-last-mile-request.dto';
import { LastMileRequestsService } from './last-mile-requests.service';
@ApiTags('last-mile-requests')
@ApiBearerAuth()
@Controller('last-mile-requests')
export class LastMileRequestsController {
constructor(private readonly requestsService: LastMileRequestsService) {}
@Get()
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'List last-mile confirmation requests' })
findAll(
@Query('status') status?: string,
@Query('bookingId') bookingId?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.requestsService.findAll({
status: status as LastMileRequestStatus | undefined,
bookingId,
page: page ? parseInt(page, 10) : undefined,
pageSize: pageSize ? parseInt(pageSize, 10) : undefined,
});
}
@Get('free-truck-count')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'Free (ACTIVE + unassigned) trucks — informational context for approval' })
freeTruckCount() {
return this.requestsService.freeTruckCount().then((count) => ({ count }));
}
@Get(':id')
@BookingStaff(FREIGHT_PERMS.lastMile.requestView)
@ApiOperation({ summary: 'Get a last-mile confirmation request by ID' })
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.requestsService.findById(id);
}
// No @BookingStaff — the customer (portal) fills this, not backoffice staff.
// TODO: integrate @edr/auth — @CurrentUser is a stub until then; the service
// still cross-checks the request's booking against the resolved company.
@Post(':id/submit')
@ApiOperation({ summary: "Customer confirms which containers go via EDR last-mile" })
submit(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: SubmitLastMileRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.submit(id, user?.id ?? null, dto.containerNumbers);
}
@Post(':id/approve')
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
@ApiOperation({ summary: 'Truck & Machinery chief approves the request — generates the advance invoice' })
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: ApproveLastMileRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.approve(id, user?.id ?? null, dto.advanceAmount);
}
@Post(':id/reject')
@BookingStaff(FREIGHT_PERMS.lastMile.requestApprove)
@ApiOperation({ summary: 'Truck & Machinery chief rejects the request with a reason' })
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: RejectLastMileRequestDto,
@CurrentUser() user: TCurrentUser,
) {
return this.requestsService.reject(id, user?.id ?? null, dto.reason);
}
}

View File

@@ -0,0 +1,25 @@
import { Module, forwardRef } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BillingModule } from '../billing/billing.module';
import { BookingsModule } from '../bookings/bookings.module';
import { LastMileModule } from '../last-mile/last-mile.module';
import { NotificationInboxModule } from '../notification-inbox/notification-inbox.module';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileRequestsController } from './last-mile-requests.controller';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
import { LastMileRequestsService } from './last-mile-requests.service';
@Module({
imports: [
TypeOrmModule.forFeature([LastMileRequest]),
BillingModule,
forwardRef(() => BookingsModule),
LastMileModule,
NotificationInboxModule,
],
controllers: [LastMileRequestsController],
providers: [LastMileRequestsRepository, LastMileRequestsService],
exports: [LastMileRequestsService],
})
export class LastMileRequestsModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BaseRepository } from '@edr/api-common';
import { LastMileRequest } from './entities/last-mile-request.entity';
@Injectable()
export class LastMileRequestsRepository extends BaseRepository<LastMileRequest> {
constructor(
@InjectRepository(LastMileRequest)
repository: Repository<LastMileRequest>,
) {
super(repository);
}
}

View File

@@ -0,0 +1,337 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { DataSource, FindOptionsWhere } from 'typeorm';
import { Freight, LastMileRequestStatus } from '@edr/types';
import { usesEdrMileService } from '../../common/mile-haulage.util';
import { FREIGHT_PERMS } from '../../seed/freight-permissions.registry';
import { BookingsRepository } from '../bookings/bookings.repository';
import { BookingsService } from '../bookings/bookings.service';
import { Booking } from '../bookings/entities/booking.entity';
import { BillingService } from '../billing/billing.service';
import { NotificationInboxService } from '../notification-inbox/notification-inbox.service';
import { NotificationAudience, NotificationPriority, NotificationType } from '@edr/types';
import { LastMileService } from '../last-mile/last-mile.service';
import { Vehicle, VehicleAvailability, VehicleStatus } from '../vehicles/entities/vehicle.entity';
import { LastMileRequest } from './entities/last-mile-request.entity';
import { LastMileRequestsRepository } from './last-mile-requests.repository';
type ListFilter = {
status?: LastMileRequestStatus;
bookingId?: string;
page?: number;
pageSize?: number;
};
/** Just what remind() needs off a departed schedule — deliberately not the full
* `TrainSchedule` entity so this module never has to import train-scheduling code. */
type DepartedSchedule = { id: string; trainNumber?: string | null };
@Injectable()
export class LastMileRequestsService {
private readonly logger = new Logger(LastMileRequestsService.name);
constructor(
private readonly requestsRepository: LastMileRequestsRepository,
private readonly bookingsRepository: BookingsRepository,
private readonly bookingsService: BookingsService,
private readonly lastMileService: LastMileService,
private readonly billing: BillingService,
private readonly notifications: NotificationInboxService,
private readonly dataSource: DataSource,
) {}
/** Container numbers on the booking (upper-cased) — mirrors LastMileService's own helper. */
private async bookingContainerNumbers(bookingId: string): Promise<string[]> {
const rows: Array<{ containerNumber: string }> = await this.dataSource.query(
`SELECT bcu.container_number AS "containerNumber"
FROM freight.booking_container_units bcu
JOIN freight.booking_container bc
ON bc.id = bcu.booking_container_id AND bc.deleted_at IS NULL
WHERE bc.booking_id = $1 AND bcu.deleted_at IS NULL`,
[bookingId],
);
return rows.map((r) => r.containerNumber.trim().toUpperCase());
}
/**
* Poll for trains that have departed Djibouti and remind their eligible
* bookings. Deliberately a self-contained poller (raw SQL against
* `import_djibouti_operations`/`train_schedules`, no import of train-scheduling
* module code) rather than a hook inside `TrainSchedulingService.dispatchSchedule`
* — keeps this feature decoupled from that module entirely. `remindForDeparture`
* is idempotent per (bookingId, scheduleId), so re-scanning the same recent
* window on every tick is safe — a schedule already fully reminded is a no-op.
*/
@Cron('*/2 * * * *', { name: 'last-mile-request-departure-scan' })
async scanDepartedSchedules(): Promise<void> {
let schedules: DepartedSchedule[] = [];
try {
schedules = await this.dataSource.query(
`SELECT ts.id AS "id", ts.train_number AS "trainNumber"
FROM freight.import_djibouti_operations op
JOIN freight.train_schedules ts
ON ts.id = op.train_schedule_id AND ts.deleted_at IS NULL
WHERE op.deleted_at IS NULL
AND op.departed_from_djibouti_at IS NOT NULL
AND op.departed_from_djibouti_at > now() - interval '14 days'`,
);
} catch (err) {
this.logger.warn(`Failed to scan for departed schedules: ${(err as Error).message}`);
return;
}
for (const schedule of schedules) {
await this.remindForDeparture(schedule);
}
}
/**
* Fired for a train that has departed Djibouti (import direction). For every booking
* already loaded on this schedule that bought EDR last-mile, idempotently
* creates the AWAITING_CONFIRMATION request and reminds both the customer and
* the Truck & Machinery department. Fire-and-forget per booking — one bad
* booking must never block the rest of the departure notification.
*/
async remindForDeparture(schedule: DepartedSchedule): Promise<void> {
let bookingIds: string[] = [];
try {
const rows: Array<{ bookingId: string }> = await this.dataSource.query(
`SELECT booking_id AS "bookingId"
FROM freight.train_schedule_bookings
WHERE train_schedule_id = $1 AND loading_status = 'LOADED' AND deleted_at IS NULL`,
[schedule.id],
);
bookingIds = rows.map((r) => r.bookingId);
} catch (err) {
this.logger.warn(`Failed to load schedule bookings for ${schedule.id}: ${(err as Error).message}`);
return;
}
if (!bookingIds.length) return;
for (const bookingId of bookingIds) {
try {
const booking = await this.bookingsRepository.findById(bookingId);
if (!booking) continue;
if (
!usesEdrMileService({
tradeDirection: booking.tradeDirection,
firstMile: booking.firstMilePickupAddress ?? null,
lastMile: booking.lastMileDeliveryAddress ?? null,
})
) {
continue;
}
await this.remind(booking, schedule);
} catch (err) {
this.logger.warn(`Failed to remind booking ${bookingId} for schedule ${schedule.id}: ${(err as Error).message}`);
}
}
}
private async remind(booking: Booking, schedule: DepartedSchedule): Promise<void> {
const [existing] = await this.requestsRepository.findAll({
where: { bookingId: booking.id, trainScheduleId: schedule.id },
take: 1,
});
if (existing) return; // already reminded for this departure
const request = await this.requestsRepository.create({
bookingId: booking.id,
trainScheduleId: schedule.id,
status: LastMileRequestStatus.AwaitingConfirmation,
reminderSentAt: new Date(),
});
const trainLabel = schedule.trainNumber ? `train ${schedule.trainNumber}` : 'your train';
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Confirm your last-mile delivery',
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti. Confirm which containers go via EDR last-mile.`,
link: `/bookings/${booking.id}/last-mile-confirm?requestId=${request.id}`,
data: { bookingId: booking.id, requestId: request.id },
priority: NotificationPriority.HIGH,
});
}
void this.notifications.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.SCHEDULE_UPDATE,
title: 'Last-mile confirmation expected',
body: `${trainLabel} carrying booking ${booking.reference ?? booking.id} has departed Djibouti — awaiting the customer's last-mile confirmation.`,
link: `/dashboard/operations/last-mile?tab=requests`,
data: { bookingId: booking.id, requestId: request.id },
priority: NotificationPriority.HIGH,
});
}
async findAll(filter: ListFilter = {}): Promise<{
data: LastMileRequest[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}> {
const page = filter.page ?? 1;
const pageSize = filter.pageSize ?? 50;
const where: FindOptionsWhere<LastMileRequest> = {};
if (filter.status) where.status = filter.status;
if (filter.bookingId) where.bookingId = filter.bookingId;
const [data, total] = await this.requestsRepository.findAndCount({
where,
relations: { booking: { company: true } },
order: { createdAt: 'DESC' },
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
data,
meta: { total, page, pageSize, totalPages: Math.max(1, Math.ceil(total / pageSize)) },
};
}
async findById(id: string): Promise<LastMileRequest> {
const record = await this.requestsRepository.findById(id, {
relations: { booking: { company: true } },
});
if (!record) throw new NotFoundException(`Last-mile request ${id} not found`);
return record;
}
/** Free (ACTIVE + unassigned) truck count — informational only for the approval screen. */
async freeTruckCount(): Promise<number> {
return this.dataSource.manager.count(Vehicle, {
where: { status: VehicleStatus.ACTIVE, availability: VehicleAvailability.FREE },
});
}
async submit(id: string, userId: string | null, containerNumbers: string[]): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.AwaitingConfirmation) {
throw new BadRequestException(`Request is already ${request.status.toLowerCase()}`);
}
if (userId) {
const companyId = await this.bookingsService.resolveCustomerCompanyId(userId);
if (companyId && request.booking?.companyId && companyId !== request.booking.companyId) {
throw new BadRequestException('This request does not belong to your company');
}
}
const bookingNumbers = await this.bookingContainerNumbers(request.bookingId);
const selected = containerNumbers.map((n) => n.trim().toUpperCase());
const unknown = selected.filter((n) => !bookingNumbers.includes(n));
if (unknown.length) {
throw new BadRequestException(`Container(s) not on this booking: ${unknown.join(', ')}`);
}
await this.requestsRepository.update(id, {
requestedContainerNumbers: selected,
status: LastMileRequestStatus.Submitted,
submittedByUserId: userId,
submittedAt: new Date(),
} as Partial<LastMileRequest>);
void this.notifications.notify({
recipients: { permissionKeys: [FREIGHT_PERMS.lastMile.requestReview] },
audience: NotificationAudience.BACKOFFICE,
type: NotificationType.REQUEST_SUBMITTED,
title: 'Last-mile request ready for review',
body: `Booking ${request.booking?.reference ?? request.bookingId} confirmed ${selected.length} container(s) for EDR last-mile.`,
link: `/dashboard/operations/last-mile?tab=requests`,
data: { bookingId: request.bookingId, requestId: request.id },
priority: NotificationPriority.NORMAL,
});
return this.findById(id);
}
async approve(id: string, staffId: string | null, advanceAmount: number): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be approved (current status: ${request.status})`);
}
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
// Idempotent per booking — reuses the record if one already exists.
const lastMile = await this.lastMileService.create({
bookingId: request.bookingId,
status: 'PAYMENT_PENDING',
advancedPayment: 0,
});
await this.billing.generateInvoice({
// 'last_mile' (not the InvoiceSource.LastMile enum value "lastmile") to
// match the existing source string LastMileInvoiceService/LastMileService
// already query by (findBySourceIds/findPayable/attachInvoices).
source: 'last_mile' as Freight.InvoiceSource,
sourceId: lastMile.id,
type: 'LAST_MILE_ADVANCE',
companyId: booking.companyId,
companyProfileId: booking.companyProfileId || '',
currency: booking.paymentCurrency || 'ETB',
lines: [
{
chargeType: 'LAST_MILE_ADVANCE',
description: 'Last-mile delivery advance',
amount: advanceAmount,
},
],
totalAmount: advanceAmount,
});
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Approved,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
resultingLastMileId: lastMile.id,
} as Partial<LastMileRequest>);
if (booking.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.INVOICE_ISSUED,
title: 'Last-mile request approved — payment due',
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Pay the advance invoice to proceed.`,
link: '/billing/invoices',
data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id },
priority: NotificationPriority.HIGH,
});
}
return this.findById(id);
}
async reject(id: string, staffId: string | null, reason: string): Promise<LastMileRequest> {
const request = await this.findById(id);
if (request.status !== LastMileRequestStatus.Submitted) {
throw new BadRequestException(`Only a submitted request can be rejected (current status: ${request.status})`);
}
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
await this.requestsRepository.update(id, {
status: LastMileRequestStatus.Rejected,
reviewedByStaffId: staffId,
reviewedAt: new Date(),
rejectionReason: reason,
} as Partial<LastMileRequest>);
if (booking?.companyId) {
void this.notifications.notify({
recipients: { companyId: booking.companyId },
audience: NotificationAudience.PORTAL,
type: NotificationType.BOOKING_STATUS,
title: 'Last-mile request rejected',
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was rejected: ${reason}`,
link: `/bookings/${booking.id}`,
data: { bookingId: booking.id, requestId: id },
priority: NotificationPriority.HIGH,
});
}
return this.findById(id);
}
}

View File

@@ -13,6 +13,7 @@ import {
usesEdrMileService,
} from '../../common/mile-haulage.util';
import { attachMileFinancials } from '../../common/mile-financials.util';
import { estimateMileKm } from '../../common/mile-distance.util';
import {
assertBulkTonnageRemains,
assertTruckCountWithinContainers,
@@ -426,7 +427,7 @@ export class LastMileService {
status: dto.status ?? 'READY_TO_TRANSIT',
advancedPayment: dto.advancedPayment ?? 0,
remainingPayment: dto.remainingPayment ?? 0,
estimatedKm: dto.estimatedKm ?? null,
estimatedKm: dto.estimatedKm ?? (await estimateMileKm(this.dataSource, dto.bookingId, 'LAST')),
exactKm: dto.exactKm ?? null,
vehicleId: dto.vehicleId ?? null,
paid: (dto as any).paid ?? false,

View File

@@ -242,11 +242,12 @@ export class PaymentService {
// debited against the intent amount, so the dev shortcut would break it.
// CAC bank rejects amounts below 10 (DJF bounds 10100,000), so its dev
// shortcut floor is 10, not 1.
amountMinor: isCbeBill
? input.amountMinor
: input.method === ProviderMethod.CAC_BANK
? 10
: 1,
// amountMinor: isCbeBill
// ? input.amountMinor
// : input.method === ProviderMethod.CAC_BANK
// ? 10
// : 1,
amountMinor: input.amountMinor,
currency: input.currency,
provider: input.method as ProviderMethod,
platform: input.platform,

View File

@@ -0,0 +1,26 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, OneToOne } from 'typeorm';
import { Yard } from './yard.entity';
/**
* GPS position of a yard (decimal degrees, WGS84). One record per yard;
* today only the five facility yards (Sebeta, GMP/Indode, Mojo, Adama,
* Dire Dawa) are seeded.
*/
@Entity({ schema: 'freight', name: 'yard_locations' })
@Index(['yardId'], { unique: true })
export class YardLocation extends BaseEntity {
@Column({ name: 'yard_id', type: 'uuid' })
yardId!: string;
@OneToOne(() => Yard, { nullable: false, onDelete: 'CASCADE' })
@JoinColumn({ name: 'yard_id' })
yard?: Yard;
@Column({ name: 'latitude', type: 'double precision' })
latitude!: number;
@Column({ name: 'longitude', type: 'double precision' })
longitude!: number;
}

View File

@@ -59,26 +59,57 @@ export class CargoTypesRepository implements ICargoTypesRepository {
}
async create(data: Partial<CargoType>): Promise<CargoType> {
const entity = this.repo.create(data);
return this.repo.save(entity);
const { wagonTypes, ...columns } = data;
const entity = this.repo.create(columns);
const saved = await this.repo.save(entity);
if (wagonTypes?.length) {
await this.syncWagonTypes(saved.id, wagonTypes.map((wt) => wt.id), []);
}
return (await this.findById(saved.id)) ?? saved;
}
async update(id: string, data: Partial<CargoType>): Promise<CargoType | null> {
// Relation lists can't ride a column UPDATE — sync them via entity save.
const { wagonTypes, ...columns } = data;
if (Object.keys(columns).length) {
await this.repo.update(id, columns as never);
}
if (wagonTypes) {
const entity = await this.repo.findOne({ where: { id } });
if (entity) {
entity.wagonTypes = wagonTypes;
await this.repo.save(entity);
const current = await this.repo.findOne({
where: { id },
relations: { wagonTypes: true },
});
if (current) {
await this.syncWagonTypes(
id,
wagonTypes.map((wt) => wt.id),
(current.wagonTypes ?? []).map((wt) => wt.id),
);
}
}
return this.findById(id);
}
/**
* Diffs the wagon-type links through the relation query builder rather than
* an entity save: junction-row inserts from save() broadcast afterInsert with
* no entity attached, which the @tria-plc/auditlog subscriber (deployed
* builds) dereferences and crashes the request on.
*/
private async syncWagonTypes(
id: string,
nextIds: string[],
currentIds: string[],
): Promise<void> {
const toAdd = nextIds.filter((x) => !currentIds.includes(x));
const toRemove = currentIds.filter((x) => !nextIds.includes(x));
if (!toAdd.length && !toRemove.length) return;
await this.repo
.createQueryBuilder()
.relation(CargoType, 'wagonTypes')
.of(id)
.addAndRemove(toAdd, toRemove);
}
async softDelete(id: string): Promise<void> {
await this.repo.softDelete(id);
}

View File

@@ -27,6 +27,7 @@ import { WeightLimitRule } from './entities/weight-limit-rule.entity';
import { Yard } from './entities/yard.entity';
import { YardDistance } from './entities/yard-distance.entity';
import { YardFacility } from './entities/yard-facility.entity';
import { YardLocation } from './entities/yard-location.entity';
import { APPROVAL_RULES_REPOSITORY } from './interfaces/approval-rules.repository.interface';
import { CARGO_TYPES_REPOSITORY } from './interfaces/cargo-types.repository.interface';
@@ -88,6 +89,7 @@ import { BookingRateSnapshot } from '../bookings/entities/booking-rate-snapshot.
Yard,
YardDistance,
YardFacility,
YardLocation,
ShippingLine,
Rate,
ApprovalRule,

View File

@@ -422,7 +422,9 @@ export class BookingBatchService implements OnModuleInit {
.getRepository(Booking)
.createQueryBuilder("b")
.select("DISTINCT b.train_schedule_id", "scheduleId")
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.where(
`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
)
.andWhere("b.train_schedule_id IS NOT NULL")
.getRawMany<{ scheduleId: string }>();
for (const { scheduleId } of reserved) this.armSettle(scheduleId);
@@ -2118,7 +2120,9 @@ export class BookingBatchService implements OnModuleInit {
if (linked) return "ALLOCATED";
if (
booking.status === "SELECTED_FOR_BATCH" ||
booking.status === "AWAITING_PAYMENT"
booking.status === "AWAITING_PAYMENT" ||
// Redirect-acked, webhook pending — still a reserved (unpaid) hold.
booking.status === "PAYMENT_VERIFICATION_IN_PROGRESS"
) {
return "SELECTED_FOR_BATCH";
}

View File

@@ -300,6 +300,19 @@ export class BookingNotifierService {
this.inApp(b, 'Removed from train', msg);
}
/**
* The train carrying this booking was cancelled. The booking is detached and
* returns to the eligible pool — the customer must rebook or pick a new schedule.
*/
scheduleCancelled(b: Booking): void {
const msg =
`The train for booking ${b.reference ?? b.id} has been cancelled. ` +
`Your booking is not lost — please rebook or select a new schedule from the portal.`;
void this.notifyContact(b, msg, 'TRAIN CANCELLED');
// HIGH: a cancelled train invalidates the customer's plans — must reach SMS/email.
this.inApp(b, 'Train cancelled', msg, { priority: NotificationPriority.HIGH });
}
/**
* The train carrying this booking was moved for maintenance to a new departure
* date. The booking stays on the train — only the date moved.

View File

@@ -610,7 +610,9 @@ export class BookingWindowService implements OnModuleInit {
.getRepository(Booking)
.createQueryBuilder('b')
.select('DISTINCT b.train_schedule_id', 'scheduleId')
.where(`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.where(
`b.status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
)
// Deadline is the line — expire() itself reconciles against the gateway
// before actually expiring, so a late in-window payment is still caught.
.andWhere('b.payment_deadline <= now()')

View File

@@ -2653,7 +2653,9 @@ export class TrainSchedulingService {
paymentDeadline: null,
})
.where('train_schedule_id = :scheduleId', { scheduleId })
.andWhere(`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT')`)
.andWhere(
`status IN ('SELECTED_FOR_BATCH', 'AWAITING_PAYMENT', 'PAYMENT_VERIFICATION_IN_PROGRESS')`,
)
.execute();
});
@@ -4247,6 +4249,15 @@ export class TrainSchedulingService {
}
});
// Best-effort customer notice (SMS + email + in-app) — the cancel itself has
// already committed, so a notification failure must never fail the cancel.
for (const sb of schedule.scheduleBookings ?? []) {
const booking = await this.bookingsRepository
.findByIdWithFiles(sb.bookingId)
.catch(() => null);
if (booking) this.bookingNotifier.scheduleCancelled(booking);
}
// Window retired (DONE) — remove the card from portal/GL lists right away.
void this.emitWindowState(id);
return this.getTrainScheduleById(id);

View File

@@ -35,13 +35,24 @@ async function main() {
// Demo seeders are intentionally not AppModule providers (they'd run on every
// boot), so construct them against the app's DataSource instead of via DI.
const dataSource = app.get(DataSource);
await new PricingDataSeeder(dataSource).run();
await new IndodeFacilitySeeder(dataSource).run();
await new Batch14TestDataSeeder(dataSource).run();
await new Batch5TestDataSeeder(dataSource).run();
await new Batch7TestDataSeeder(dataSource).run();
await new Batch8TestDataSeeder(dataSource).run();
await new WarehouseDemoSeeder(dataSource).run();
// Each bucket is independent: a seeder that has drifted from the current
// schema shouldn't stop the rest of the demo data from landing.
const step = async (name: string, run: () => Promise<void>) => {
try {
await run();
} catch (error) {
console.warn(` ! ${name} skipped: ${error instanceof Error ? error.message : String(error)}`);
}
};
await step('PricingDataSeeder', () => new PricingDataSeeder(dataSource).run());
await step('IndodeFacilitySeeder', () => new IndodeFacilitySeeder(dataSource).run());
await step('Batch14TestDataSeeder', () => new Batch14TestDataSeeder(dataSource).run());
await step('Batch5TestDataSeeder', () => new Batch5TestDataSeeder(dataSource).run());
await step('Batch7TestDataSeeder', () => new Batch7TestDataSeeder(dataSource).run());
await step('Batch8TestDataSeeder', () => new Batch8TestDataSeeder(dataSource).run());
await step('WarehouseDemoSeeder', () => new WarehouseDemoSeeder(dataSource).run());
console.log('Warehouse demo data seeded.');
} finally {

View File

@@ -306,4 +306,5 @@ export const EDR_FREIGHT_POSITIONS: FreightSeedPosition[] = [
{ key: "operation", name: { en: "Operation" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.operation] },
{ key: "operations_chief", name: { en: "Operations Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.operationsChief] },
{ key: "dispatcher", name: { en: "Dispatcher" }, rank: 4, permissionKeys: [...POSITION_PERMISSION_PRESETS.dispatcher] },
{ key: "truck_machinery_chief", name: { en: "Truck & Machinery Chief" }, rank: 2, permissionKeys: [...POSITION_PERMISSION_PRESETS.truckMachineryChief] },
];

View File

@@ -227,6 +227,9 @@ export const MILE_PERMISSIONS: FreightPermissionSeed[] = [
perm('d3b00001-0001-4000-8000-000000000006', 'edr_freight_app:last_mile:assign_vehicles', 'Assign last-mile vehicles'),
perm('d3b00001-0001-4000-8000-000000000007', 'edr_freight_app:last_mile:set_distances', 'Set last-mile distances'),
perm('d3b00001-0001-4000-8000-000000000008', 'edr_freight_app:last_mile:generate_invoice', 'Generate last-mile invoice'),
perm('d3b00001-0001-4000-8000-000000000009', 'edr_freight_app:last_mile:request_view', 'View last-mile confirmation requests'),
perm('d3b00001-0001-4000-8000-00000000000a', 'edr_freight_app:last_mile:request_review', 'Review last-mile confirmation requests (T&M dept)'),
perm('d3b00001-0001-4000-8000-00000000000b', 'edr_freight_app:last_mile:request_approve', 'Approve/reject last-mile confirmation requests'),
];
// F. Fleet — rail assets (splits the flat fleet:view/manage)
@@ -528,6 +531,11 @@ export const FREIGHT_PERMS = {
assignVehicles: 'edr_freight_app:last_mile:assign_vehicles',
setDistances: 'edr_freight_app:last_mile:set_distances',
generateInvoice: 'edr_freight_app:last_mile:generate_invoice',
// Pre-approval confirmation stage (Truck & Machinery department): view/review
// a submitted request, approve/reject it.
requestView: 'edr_freight_app:last_mile:request_view',
requestReview: 'edr_freight_app:last_mile:request_review',
requestApprove: 'edr_freight_app:last_mile:request_approve',
},
locomotives: {
view: 'edr_freight_app:locomotives:view',
@@ -1002,6 +1010,17 @@ export const POSITION_PERMISSION_PRESETS = {
FREIGHT_PERMS.trainScheduling.view,
FREIGHT_PERMS.bookings.operations,
]),
// Truck & Machinery chief: reviews and approves/rejects last-mile
// confirmation requests (the pre-approval gate ahead of vehicle assignment),
// plus enough fleet visibility to judge truck availability.
truckMachineryChief: dedupe([
FREIGHT_PERMS.lastMile.view,
FREIGHT_PERMS.lastMile.requestView,
FREIGHT_PERMS.lastMile.requestReview,
FREIGHT_PERMS.lastMile.requestApprove,
FREIGHT_PERMS.fleetDashboard.view,
FREIGHT_PERMS.vehicles.view,
]),
} as const;
/** Derive the module bucket from the resource segment of a permission key. */

View File

@@ -29,6 +29,7 @@ const STAFF_USERS = [
{ email: 'operation@edr.local', username: 'operation', roleKey: 'edr_operations_officer', positionKey: 'operation' },
{ email: 'gl-et@edr.local', username: 'gl_et', roleKey: 'edr_gl_ethiopia', positionKey: 'ethiopian_gl' },
{ email: 'gl-dj@edr.local', username: 'gl_dj', roleKey: 'edr_gl_djibouti', positionKey: 'djibouti_gl' },
{ email: 'tm-chief@edr.local', username: 'tm_chief', roleKey: 'edr_operations_officer', positionKey: 'truck_machinery_chief' },
] as const;
@Injectable()

View File

@@ -4,6 +4,11 @@ import { DataSource } from 'typeorm';
import { BookingContainer } from '../modules/bookings/entities/booking-container.entity';
import { Booking } from '../modules/bookings/entities/booking.entity';
import {
CompanyProfile,
ProfileStatus,
ProfileType,
} from '../modules/companies/entities/company-profile.entity';
import { Company, CompanyStatus, CompanyType } from '../modules/companies/entities/company.entity';
import { FirstMile } from '../modules/first-mile/entities/first-mile.entity';
import { LastMile } from '../modules/last-mile/entities/last-mile.entity';
@@ -13,7 +18,8 @@ import { ServiceType } from '../modules/rule-engine/entities/service-type.entity
import { Yard } from '../modules/rule-engine/entities/yard.entity';
const SERVICE_TYPE_CODE = 'RAIL_CONTAINER_PAID_MILE';
const COMPANY_TIN = 'PAIDMILE001';
// companies.tin is varchar(10) — an 11-char TIN 22001s the whole seeder.
const COMPANY_TIN = 'PAIDMILE01';
const COMPANY_EMAIL = 'paid-mile-demo@edr.local';
const YARDS = [
@@ -183,6 +189,22 @@ export class PaidImportExportMileDemoSeeder {
manager.getRepository(ContainerType).find(),
]);
// bookings.company_profile_id is NOT NULL — the demo company needs an
// approved importer profile of its own (no unique key to upsert on).
const profileRepo = manager.getRepository(CompanyProfile);
const companyProfile =
(await profileRepo.findOne({
where: { companyId: company.id, type: ProfileType.importer },
})) ??
(await profileRepo.save(
profileRepo.create({
companyId: company.id,
type: ProfileType.importer,
status: ProfileStatus.Active,
businessLicense: 'PMD-LIC-0001',
}),
));
const yardByCode = new Map(yards.map((yard) => [yard.code, yard]));
const containerTypeByCode = new Map(
containerTypes.map((containerType) => [containerType.code, containerType]),
@@ -206,6 +228,7 @@ export class PaidImportExportMileDemoSeeder {
{
reference: demoBooking.reference,
companyId: company.id,
companyProfileId: companyProfile.id,
status: 'APPROVED',
scheduledDate: new Date(demoBooking.scheduledDate),
estimatedShipmentDate: new Date(demoBooking.scheduledDate),

View File

@@ -126,6 +126,15 @@ const FleetRecordActions = ({
{removeLabel}
</MenuItem>
) : null}
{onPurge ? (
<MenuItem
color="red"
onClick={() => onPurge(record)}
leftSection={<ShieldAlert size={14} strokeWidth={2} />}
>
Delete permanently
</MenuItem>
) : null}
</Menu.Dropdown>
</Menu>
);

View File

@@ -0,0 +1,290 @@
import { useState } from "react";
import {
Badge,
Box,
Button,
Card,
Group,
Modal,
NumberInput,
Stack,
Text,
Textarea,
} from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { ColumnDef } from "@edr/ui-common";
import { DataTable, DataTableFooter, usePagination } from "@edr/ui-common";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import {
lastMileRequestsService,
type LastMileRequest,
type LastMileRequestStatus,
} from "@/services/last-mile-requests.service";
const STATUS_META: Record<LastMileRequestStatus, { label: string; color: string }> = {
AWAITING_CONFIRMATION: { label: "Awaiting Confirmation", color: "gray" },
SUBMITTED: { label: "Submitted", color: "yellow" },
APPROVED: { label: "Approved", color: "green" },
REJECTED: { label: "Rejected", color: "red" },
};
type StatusFilter = "ALL" | LastMileRequestStatus;
const FILTER_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "SUBMITTED", label: "Submitted" },
{ value: "APPROVED", label: "Approved" },
{ value: "REJECTED", label: "Rejected" },
{ value: "AWAITING_CONFIRMATION", label: "Awaiting Confirmation" },
{ value: "ALL", label: "All" },
];
const fmtDate = (iso?: string | null) =>
iso ? new Date(iso).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—";
export function LastMileRequestsPanel() {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canApprove = hasPermission(user, FREIGHT_PERMS.lastMile.requestApprove);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("SUBMITTED");
const { pagination, setPagination } = usePagination({ pageSize: 10 });
const [approveTarget, setApproveTarget] = useState<LastMileRequest | null>(null);
const [rejectTarget, setRejectTarget] = useState<LastMileRequest | null>(null);
const [advanceAmount, setAdvanceAmount] = useState<number | string>("");
const [rejectReason, setRejectReason] = useState("");
const filter = {
...(statusFilter !== "ALL" ? { status: statusFilter } : {}),
page: pagination.pageIndex + 1,
pageSize: pagination.pageSize,
};
const { data, isLoading } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.list(filter),
queryFn: async () => (await lastMileRequestsService.list(filter)).data,
});
const rows = data?.data ?? [];
const meta = data?.meta;
const { data: freeTrucks } = useQuery({
queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.freeTruckCount,
queryFn: async () => (await lastMileRequestsService.freeTruckCount()).data,
});
const invalidate = () =>
qc.invalidateQueries({ queryKey: QUERY_KEYS.LAST_MILE_REQUESTS.ROOT });
const approve = useMutation({
mutationFn: () =>
lastMileRequestsService.approve(approveTarget!.id, Number(advanceAmount)),
onSuccess: () => {
void invalidate();
toast({ title: "Request approved" });
setApproveTarget(null);
setAdvanceAmount("");
},
onError: (e: unknown) => {
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast({ title: "Approve failed", description, variant: "destructive" });
},
});
const reject = useMutation({
mutationFn: () => lastMileRequestsService.reject(rejectTarget!.id, rejectReason.trim()),
onSuccess: () => {
void invalidate();
toast({ title: "Request rejected" });
setRejectTarget(null);
setRejectReason("");
},
onError: (e: unknown) => {
const description = (e as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast({ title: "Reject failed", description, variant: "destructive" });
},
});
const columns: ColumnDef<LastMileRequest>[] = [
{
id: "booking",
header: () => <span>Booking</span>,
cell: ({ row }) => {
const r = row.original;
return (
<Stack gap={0}>
<Text size="sm" fw={600}>{r.booking?.reference ?? r.bookingId}</Text>
<Text size="xs" c="dimmed">{r.booking?.company?.name ?? "—"}</Text>
</Stack>
);
},
},
{
id: "containers",
header: () => <span>Requested Containers</span>,
cell: ({ row }) => {
const nums = row.original.requestedContainerNumbers;
return <Text size="sm">{nums?.length ? nums.join(", ") : "—"}</Text>;
},
},
{
id: "submittedAt",
header: () => <span>Submitted</span>,
cell: ({ row }) => <Text size="sm">{fmtDate(row.original.submittedAt)}</Text>,
},
{
id: "status",
header: () => <span>Status</span>,
cell: ({ row }) => {
const meta = STATUS_META[row.original.status];
return (
<Badge color={meta.color} variant="light" size="sm">
{meta.label}
</Badge>
);
},
},
...(canApprove
? [
{
id: "actions",
header: () => <span>Actions</span>,
cell: ({ row }: { row: { original: LastMileRequest } }) => {
const r = row.original;
if (r.status !== "SUBMITTED") return null;
return (
<Group gap="xs">
<Button size="xs" variant="light" color="green" onClick={() => setApproveTarget(r)}>
Approve
</Button>
<Button size="xs" variant="light" color="red" onClick={() => setRejectTarget(r)}>
Reject
</Button>
</Group>
);
},
} as ColumnDef<LastMileRequest>,
]
: []),
];
return (
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
<Stack gap="sm">
<Text size="sm" c="dimmed">
{freeTrucks?.count ?? 0} truck{freeTrucks?.count === 1 ? "" : "s"} currently free
</Text>
<Group gap="xs" wrap="wrap">
{FILTER_OPTIONS.map((option) => {
const active = statusFilter === option.value;
return (
<Button
key={option.value}
size="xs"
variant={active ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => {
setStatusFilter(option.value);
setPagination((p) => ({ ...p, pageIndex: 0 }));
}}
>
{option.label}
</Button>
);
})}
</Group>
</Stack>
</Box>
<DataTable
columns={columns}
data={rows}
status={isLoading ? "loading" : "success"}
emptyMessage="No last-mile confirmation requests found"
pagination={{
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
pageCount: meta?.totalPages ?? 1,
totalCount: meta?.total ?? 0,
}}
tableOptions={{
manualPagination: true,
pageCount: meta?.totalPages ?? 1,
state: { pagination },
onPaginationChange: setPagination,
}}
containerClassName="border-0 shadow-none bg-transparent"
footer={({ table, pagination: fp }) => (
<DataTableFooter table={table} pagination={fp} options={{ labels: { items: "requests" } }} />
)}
/>
</Stack>
<Modal
opened={Boolean(approveTarget)}
onClose={() => setApproveTarget(null)}
title={<Text fw={700}>Approve request{approveTarget?.booking?.reference ? ` · ${approveTarget.booking.reference}` : ""}</Text>}
centered
>
<Stack gap="md">
<NumberInput
label="Advance amount"
placeholder="0.00"
required
min={0.01}
value={advanceAmount}
onChange={setAdvanceAmount}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setApproveTarget(null)}>
Cancel
</Button>
<Button
disabled={!(Number(advanceAmount) > 0)}
loading={approve.isPending}
onClick={() => approve.mutate()}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={Boolean(rejectTarget)}
onClose={() => setRejectTarget(null)}
title={<Text fw={700}>Reject request{rejectTarget?.booking?.reference ? ` · ${rejectTarget.booking.reference}` : ""}</Text>}
centered
>
<Stack gap="md">
<Textarea
label="Reason"
placeholder="Why is this request being rejected?"
required
minRows={3}
value={rejectReason}
onChange={(e) => setRejectReason(e.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejectTarget(null)}>
Cancel
</Button>
<Button
color="red"
disabled={!rejectReason.trim()}
loading={reject.isPending}
onClick={() => reject.mutate()}
>
Confirm
</Button>
</Group>
</Stack>
</Modal>
</Card>
);
}

View File

@@ -155,6 +155,13 @@ export const QUERY_KEYS = {
byId: (id: string) => ["last-mile", "detail", id] as const,
},
LAST_MILE_REQUESTS: {
ROOT: ["last-mile-requests"] as const,
list: (filter?: Record<string, unknown>) =>
["last-mile-requests", "list", filter ?? {}] as const,
freeTruckCount: ["last-mile-requests", "free-truck-count"] as const,
},
RULE_ENGINE: {
ROOT: ["rule-engine"] as const,
list: (

View File

@@ -707,6 +707,14 @@ export const URL_CONSTANTS = {
PROOF_OF_DELIVERY: (id: string) => `/last-mile/${id}/proof-of-delivery`,
},
LAST_MILE_REQUESTS: {
BASE: "/last-mile-requests",
BY_ID: (id: string) => `/last-mile-requests/${id}`,
FREE_TRUCK_COUNT: "/last-mile-requests/free-truck-count",
APPROVE: (id: string) => `/last-mile-requests/${id}/approve`,
REJECT: (id: string) => `/last-mile-requests/${id}/reject`,
},
DRIVERS: {
BASE: "/drivers",
BY_ID: (id: string) => `/drivers/${id}`,

View File

@@ -116,6 +116,9 @@ export const FREIGHT_PERMS = {
assignVehicles: "edr_freight_app:last_mile:assign_vehicles",
setDistances: "edr_freight_app:last_mile:set_distances",
generateInvoice: "edr_freight_app:last_mile:generate_invoice",
requestView: "edr_freight_app:last_mile:request_view",
requestReview: "edr_freight_app:last_mile:request_review",
requestApprove: "edr_freight_app:last_mile:request_approve",
},
locomotives: {
view: "edr_freight_app:locomotives:view",

View File

@@ -47,6 +47,9 @@ import { bookingsService } from "@/services/bookings.service";
import { ruleEngineTable } from "@/components/ruleEngine/ruleEngineStyles";
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/auth/useAuth";
import { FREIGHT_PERMS, hasPermission } from "@/lib/permissions";
import { LastMileRequestsPanel } from "@/components/operations/LastMileRequestsPanel";
import {
LAST_MILE_STATUSES,
type LastMileApiStatus,
@@ -543,6 +546,9 @@ const buildTripSlipHtml = (record: LastMileRecord, vehicle?: TripSlipVehicle | n
const LastMilePage = () => {
const { toast } = useToast();
const qc = useQueryClient();
const { user } = useAuth();
const canViewRequests = hasPermission(user, FREIGHT_PERMS.lastMile.requestView);
const [view, setView] = useState<"legs" | "requests">("legs");
const [podRecord, setPodRecord] = useState<LastMileRecord | null>(null);
const navigate = useNavigate();
const { pagination, setPagination } = usePagination({ pageSize: 10 });
@@ -1590,6 +1596,30 @@ const LastMilePage = () => {
return (
<Stack gap="md" p="md">
{canViewRequests && (
<Group gap="xs">
<Button
size="xs"
variant={view === "legs" ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setView("legs")}
>
Deliveries
</Button>
<Button
size="xs"
variant={view === "requests" ? "filled" : "default"}
styles={{ label: { fontWeight: 500 } }}
onClick={() => setView("requests")}
>
Requests
</Button>
</Group>
)}
{view === "requests" && canViewRequests ? (
<LastMileRequestsPanel />
) : (
<Card radius="lg" padding={0} withBorder style={{ borderColor: "var(--mantine-color-gray-2)" }}>
<Stack gap={0}>
<Box px="md" pt="md" pb="sm" w="100%">
@@ -1672,6 +1702,7 @@ const LastMilePage = () => {
/>
</Stack>
</Card>
)}
{/* 2-step Assign Mile (arrival queue → vehicle) */}
<Modal

View File

@@ -0,0 +1,51 @@
import { api } from '../auth/http';
import { URL_CONSTANTS } from '@/constants/URLS';
export const LAST_MILE_REQUEST_STATUSES = [
'AWAITING_CONFIRMATION',
'SUBMITTED',
'APPROVED',
'REJECTED',
] as const;
export type LastMileRequestStatus = (typeof LAST_MILE_REQUEST_STATUSES)[number];
export interface LastMileRequest {
id: string;
bookingId: string;
booking?: {
id: string;
reference?: string;
companyId?: string;
company?: { id: string; name?: string } | null;
} | null;
trainScheduleId: string;
status: LastMileRequestStatus;
requestedContainerNumbers?: string[] | null;
reminderSentAt?: string | null;
submittedByUserId?: string | null;
submittedAt?: string | null;
reviewedByStaffId?: string | null;
reviewedAt?: string | null;
rejectionReason?: string | null;
resultingLastMileId?: string | null;
createdAt: string;
updatedAt: string;
}
export interface LastMileRequestListResponse {
data: LastMileRequest[];
meta: { total: number; page: number; pageSize: number; totalPages: number };
}
const LMR = URL_CONSTANTS.LAST_MILE_REQUESTS;
export const lastMileRequestsService = {
list: (params?: { status?: LastMileRequestStatus; bookingId?: string; page?: number; pageSize?: number }) =>
api.get<LastMileRequestListResponse>(LMR.BASE, { params }),
getById: (id: string) => api.get<LastMileRequest>(LMR.BY_ID(id)),
freeTruckCount: () => api.get<{ count: number }>(LMR.FREE_TRUCK_COUNT),
approve: (id: string, advanceAmount: number) =>
api.post<LastMileRequest>(LMR.APPROVE(id), { advanceAmount }),
reject: (id: string, reason: string) =>
api.post<LastMileRequest>(LMR.REJECT(id), { reason }),
};

View File

@@ -46,6 +46,7 @@ import BookingContractPage from "./pages/bookings/BookingContractPage";
import BookingDetailPage from "./pages/bookings/BookingDetailPage";
import BookingsListPage from "./pages/bookings/BookingsListPage";
import EditBookingPage from "./pages/bookings/EditBookingPage";
import LastMileConfirmPage from "./pages/bookings/last-mile-confirm/LastMileConfirmPage";
import ContractDetailPage from "./pages/contracts/ContractDetailPage";
import ContractViewPage from "./pages/contracts/ContractViewPage";
import ContractsList from "./pages/contracts/ContractsList";
@@ -318,6 +319,10 @@ const App = () => {
/>
<Route path="/bookings/:id/edit" element={<EditBookingPage />} />
<Route path="/bookings/:id" element={<BookingDetailPage />} />
<Route
path="/bookings/:id/last-mile-confirm"
element={<LastMileConfirmPage />}
/>
<Route
path="/bookings/:id/contract"
element={<BookingContractPage />}

View File

@@ -217,4 +217,9 @@ export const URL_CONSTANTS = {
RECEIPT: (id: string) => `/api/warehouse-fee-invoices/${id}/receipt`,
PAY_ONLINE: (id: string) => `/api/warehouse-fee-invoices/${id}/pay-online`,
},
LAST_MILE_REQUESTS: {
BY_ID: (id: string) => `/last-mile-requests/${id}`,
SUBMIT: (id: string) => `/last-mile-requests/${id}/submit`,
},
};

View File

@@ -0,0 +1,160 @@
import { Button, Center, Checkbox, Loader, Stack, Text } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import toast from "react-hot-toast";
import { bookingsService } from "@/services/bookings.service";
import { lastMileRequestsService } from "@/services/last-mile-requests.service";
import { CardTitle, PageShell, SectionCard } from "../BookingDetailPage/components/layout";
const errorMessage = (error: unknown, fallback: string) => {
const data = (error as { response?: { data?: { message?: string | string[] } } })
?.response?.data;
if (Array.isArray(data?.message)) return data.message.join(", ");
if (data?.message) return data.message;
return error instanceof Error ? error.message : fallback;
};
const STATUS_MESSAGE: Record<string, string> = {
SUBMITTED: "submitted",
APPROVED: "approved",
REJECTED: "rejected",
};
export default function LastMileConfirmPage() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const requestId = searchParams.get("requestId");
const navigate = useNavigate();
const queryClient = useQueryClient();
const [selected, setSelected] = useState<string[]>([]);
const {
data: request,
isLoading: requestLoading,
isError: requestError,
} = useQuery({
queryKey: ["last-mile-request", requestId],
queryFn: () => lastMileRequestsService.get(requestId!),
enabled: !!requestId,
});
const { data: booking, isLoading: bookingLoading } = useQuery({
queryKey: ["booking", id],
queryFn: () => bookingsService.get(id!),
enabled: !!id,
});
const containerNumbers = booking?.containerNumbers ?? [];
const submitMutation = useMutation({
mutationFn: () => lastMileRequestsService.submit(requestId!, selected),
onSuccess: () => {
toast.success("Last-mile confirmation submitted");
queryClient.invalidateQueries({ queryKey: ["last-mile-request", requestId] });
navigate(`/bookings/${id}`);
},
onError: (e) => toast.error(errorMessage(e, "Could not submit confirmation")),
});
if (!requestId) {
return (
<PageShell>
<SectionCard p={22}>
<Text c="dimmed">Missing request id.</Text>
</SectionCard>
</PageShell>
);
}
if (requestLoading || bookingLoading) {
return (
<Center mih={300} p="xl">
<Loader color="edr-green" />
</Center>
);
}
if (requestError || !request) {
return (
<PageShell>
<SectionCard p={22}>
<Text c="dimmed">Could not load this confirmation request.</Text>
</SectionCard>
</PageShell>
);
}
if (request.status !== "AWAITING_CONFIRMATION") {
return (
<PageShell>
<SectionCard p={22}>
<CardTitle>Last-mile confirmation</CardTitle>
<Text mt={12}>
This request has already been {STATUS_MESSAGE[request.status]}.
</Text>
{request.status === "REJECTED" && request.rejectionReason && (
<Text mt={8} c="dimmed" fz="sm">
Reason: {request.rejectionReason}
</Text>
)}
</SectionCard>
</PageShell>
);
}
const allSelected =
containerNumbers.length > 0 && selected.length === containerNumbers.length;
const toggleAll = (checked: boolean) => {
setSelected(checked ? [...containerNumbers] : []);
};
const toggleOne = (containerNumber: string, checked: boolean) => {
setSelected((prev) =>
checked ? [...prev, containerNumber] : prev.filter((c) => c !== containerNumber),
);
};
return (
<PageShell>
<SectionCard p={22}>
<CardTitle>Confirm last-mile containers</CardTitle>
<Text mt={8} mb={16} fz="sm" c="dimmed">
Select which containers on this booking should be delivered via EDR
last-mile.
</Text>
<Stack gap={8}>
<Checkbox
label="Select all"
checked={allSelected}
onChange={(e) => toggleAll(e.currentTarget.checked)}
fw={700}
/>
{containerNumbers.map((c) => (
<Checkbox
key={c}
label={c}
checked={selected.includes(c)}
onChange={(e) => toggleOne(c, e.currentTarget.checked)}
ml={12}
/>
))}
</Stack>
<Button
mt={20}
disabled={selected.length === 0}
loading={submitMutation.isPending}
onClick={() => submitMutation.mutate()}
>
Submit confirmation
</Button>
</SectionCard>
</PageShell>
);
}

View File

@@ -0,0 +1,33 @@
import { URL_CONSTANTS } from "@/constants/URLS";
import { client } from "../utils/api";
const L = URL_CONSTANTS.LAST_MILE_REQUESTS;
export interface LastMileRequest {
id: string;
bookingId: string;
booking?: { id: string; reference?: string } | null;
trainScheduleId: string;
status: "AWAITING_CONFIRMATION" | "SUBMITTED" | "APPROVED" | "REJECTED";
requestedContainerNumbers?: string[] | null;
rejectionReason?: string | null;
createdAt: string;
updatedAt: string;
}
export const lastMileRequestsService = {
/** One last-mile confirmation request, by id. */
get: async (id: string): Promise<LastMileRequest> => {
const { data } = await client.get(L.BY_ID(id));
return data.data ?? data;
},
/** Confirm which containers on the booking go via EDR last-mile. */
submit: async (
id: string,
containerNumbers: string[],
): Promise<LastMileRequest> => {
const { data } = await client.post(L.SUBMIT(id), { containerNumbers });
return data.data ?? data;
},
};