mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
@@ -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,
|
||||
|
||||
17
apps/edr-freight-api/src/common/mile-distance.util.spec.ts
Normal file
17
apps/edr-freight-api/src/common/mile-distance.util.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
48
apps/edr-freight-api/src/common/mile-distance.util.ts
Normal file
48
apps/edr-freight-api/src/common/mile-distance.util.ts
Normal 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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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) ───────────────────
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 10–100,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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] },
|
||||
];
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: (
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user