mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 00:50:56 +00:00
Last mile confirmation request , approval, payment Feature
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user