mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 16:35:42 +00:00
Approval now snapshots the rate estimate and generates a last-mile contract instead of invoicing immediately. The customer picks a delivery date on the confirm form, then reviews and signs the contract in the portal (saved signature or drawn); the signed PDF is stored as LM_<CustomerName>.pdf and only then is the advance invoice issued. Backoffice shows signature status and the contract download.
422 lines
18 KiB
TypeScript
422 lines
18 KiB
TypeScript
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 {
|
||
LastMileCharge,
|
||
computeLastMileCharge,
|
||
lastMileShipmentShape,
|
||
} from '../../common/last-mile-charge.util';
|
||
import { estimateMileKm } from '../../common/mile-distance.util';
|
||
import { usesEdrMileService } from '../../common/mile-haulage.util';
|
||
import { RatesService } from '../rule-engine/services/rates.service';
|
||
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 ratesService: RatesService,
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Rule-based price estimate for the approval dialog: estimated km (yard GPS →
|
||
* delivery point, straight-line) × the LIVE last-mile rate rules against the
|
||
* containers the customer confirmed (or the booking's bulk tonnage). All
|
||
* nulls when km or rate coverage is missing — the dialog then behaves as
|
||
* before (manually typed advance).
|
||
*/
|
||
async priceEstimate(id: string): Promise<{
|
||
estimatedKm: number | null;
|
||
mode: LastMileCharge['mode'] | null;
|
||
currency: string | null;
|
||
total: number | null;
|
||
lines: Array<{ description: string; amount: number }>;
|
||
}> {
|
||
const request = await this.findById(id);
|
||
const estimatedKm = await estimateMileKm(this.dataSource, request.bookingId, 'LAST');
|
||
if (!estimatedKm) {
|
||
return { estimatedKm: null, mode: null, currency: null, total: null, lines: [] };
|
||
}
|
||
const shape = await lastMileShipmentShape(
|
||
this.dataSource,
|
||
request.bookingId,
|
||
request.requestedContainerNumbers ?? [],
|
||
);
|
||
const charge = computeLastMileCharge({
|
||
...shape,
|
||
km: estimatedKm,
|
||
liveRates: await this.ratesService.findLiveRatesDetailed(),
|
||
});
|
||
return {
|
||
estimatedKm,
|
||
mode: charge?.mode ?? null,
|
||
currency: charge?.currency ?? null,
|
||
total: charge?.total ?? null,
|
||
lines: (charge?.lines ?? []).map(({ description, amount }) => ({ description, amount })),
|
||
};
|
||
}
|
||
|
||
/** 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[],
|
||
deliveryDate: 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,
|
||
requestedDeliveryDate: deliveryDate,
|
||
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,
|
||
});
|
||
|
||
// No invoice yet: the advance is invoiced by LastMileContractService.sign()
|
||
// once the customer has signed the LM contract — doc first, then payment.
|
||
// Snapshot the rate estimate now so the contract shows the numbers the
|
||
// chief actually approved against, immune to later rate edits.
|
||
const estimate = await this.priceEstimate(id);
|
||
|
||
await this.requestsRepository.update(id, {
|
||
status: LastMileRequestStatus.Approved,
|
||
reviewedByStaffId: staffId,
|
||
reviewedAt: new Date(),
|
||
resultingLastMileId: lastMile.id,
|
||
approvedAdvanceAmount: advanceAmount,
|
||
contractSummary: { ...estimate, advanceAmount },
|
||
contractGeneratedAt: new Date(),
|
||
} as Partial<LastMileRequest>);
|
||
|
||
if (booking.companyId) {
|
||
void this.notifications.notify({
|
||
recipients: { companyId: booking.companyId },
|
||
audience: NotificationAudience.PORTAL,
|
||
type: NotificationType.CONTRACT_STATUS,
|
||
title: 'Last-mile contract ready — view and sign',
|
||
body: `Your last-mile request for booking ${booking.reference ?? booking.id} was approved. Review and sign the last-mile contract to receive your advance invoice.`,
|
||
link: `/bookings/${booking.id}/last-mile-contract?requestId=${id}`,
|
||
data: { bookingId: booking.id, requestId: id, lastMileId: lastMile.id },
|
||
priority: NotificationPriority.HIGH,
|
||
});
|
||
}
|
||
|
||
return this.findById(id);
|
||
}
|
||
|
||
/** The advance invoice, deferred from approve() until the LM contract is signed. */
|
||
async generateAdvanceInvoice(request: LastMileRequest): Promise<void> {
|
||
const booking = request.booking ?? (await this.bookingsRepository.findById(request.bookingId));
|
||
if (!booking) throw new NotFoundException(`Booking ${request.bookingId} not found`);
|
||
const advanceAmount = request.approvedAdvanceAmount;
|
||
if (!advanceAmount || !request.resultingLastMileId) {
|
||
throw new BadRequestException('Request has no approved advance to invoice');
|
||
}
|
||
|
||
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: request.resultingLastMileId,
|
||
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,
|
||
});
|
||
|
||
if (booking.companyId) {
|
||
void this.notifications.notify({
|
||
recipients: { companyId: booking.companyId },
|
||
audience: NotificationAudience.PORTAL,
|
||
type: NotificationType.INVOICE_ISSUED,
|
||
title: 'Last-mile contract signed — payment due',
|
||
body: `Thank you for signing the last-mile contract for booking ${booking.reference ?? booking.id}. Pay the advance invoice to proceed.`,
|
||
link: '/billing/invoices',
|
||
data: { bookingId: booking.id, requestId: request.id, lastMileId: request.resultingLastMileId },
|
||
priority: NotificationPriority.HIGH,
|
||
});
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|